Compare commits
169 Commits
a9d0fd8443
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ba118be485 | |||
| df4db515d8 | |||
| e9927bf0f5 | |||
| d1092c7169 | |||
| 6ecdc33933 | |||
| 3305b12c02 | |||
| a1dcfa34dc | |||
| 3fffe96016 | |||
| 36e99d0973 | |||
| fe7dce1cde | |||
| b1e79e1808 | |||
| 63b4823315 | |||
| bdbd123dd4 | |||
| 3f46b46823 | |||
| c686ad82b7 | |||
| cac75041db | |||
| fbaefbf5b8 | |||
| 075aa0b6c5 | |||
| 20406c5dcf | |||
| 7424c2e899 | |||
| 547753772c | |||
| 16e1ce4558 | |||
| d476ffb613 | |||
| b5f698cf29 | |||
| 25067bca30 | |||
| c545211cf7 | |||
| 11d4207f72 | |||
| 6d6505e5dc | |||
| 19cc44c102 | |||
| 002edc1532 | |||
| 56c0d6403c | |||
| a0a0e6eaef | |||
| 8a909bc8aa | |||
| 998ac641f9 | |||
| 1276a10e9a | |||
| f9db58122c | |||
| 2abbdf407f | |||
| dc638ac4d3 | |||
| 9390c27f44 | |||
| db37fb1ae2 | |||
| e12cc6592e | |||
| 00c77ff3fe | |||
| a4cc53d774 | |||
| 37874e3eea | |||
| 9126589de3 | |||
| 413c677173 | |||
| 002fa037b7 | |||
| ea464ef797 | |||
| 50cf5823f9 | |||
| 7da20c37c1 | |||
| 4927fb9a93 | |||
| 2763b76abe | |||
| 0727ec7675 | |||
| 50dc009fdf | |||
| d8a568076d | |||
| 7eb28851b7 | |||
| 141ab1f4e3 | |||
| d161aeba72 | |||
| 9efa783ca3 | |||
| aa890a0d55 | |||
| e4fafeb7b7 | |||
| 83f071ec6b | |||
| d6e01e4bf5 | |||
| 36c02cea55 | |||
| 0f5e67a007 | |||
| d0e8ae322f | |||
| 0e95b7e543 | |||
| 27c7372a31 | |||
| 1a517c4655 | |||
| f4ee557e26 | |||
| eeef3283c8 | |||
| 4a0aa85859 | |||
| 6a586b936a | |||
| 0d731e56da | |||
| c9c7d22766 | |||
| bc9e30c918 | |||
| 28747a6c8f | |||
| 82649f6674 | |||
| f917891a31 | |||
| 695ba434e2 | |||
| a400f694fe | |||
| 5a0d1863ec | |||
| 83586c8bbb | |||
| bd08951717 | |||
| ee7e5ec06c | |||
| efe3c81576 | |||
| 782923f2e0 | |||
| a3ef948600 | |||
| 283855d7a3 | |||
| c8ff0e5dae | |||
| 8a9ff3582f | |||
| f20cedffd5 | |||
| 1d72f34b65 | |||
| 1f1b138089 | |||
| d99c88df0e | |||
| 0db8ea8773 | |||
| e1406f427e | |||
| 484423f299 | |||
| 061ea1d806 | |||
| 225a9ad7fb | |||
| 311d80bd41 | |||
| 84bc70b442 | |||
| 3a1e0153ac | |||
| 40214d9748 | |||
| 87f1384175 | |||
| 6d4426037c | |||
| 3a8b409d1d | |||
| b5812c97b4 | |||
| 45a48abc91 | |||
| dd71130977 | |||
| b560dcbc8e | |||
| 56cdb1ae4a | |||
| 4668d771c1 | |||
| 4898ad942b | |||
| 317a80dbad | |||
| f28a2d1eab | |||
| 7eeb8399b3 | |||
| 71c22e465e | |||
| 40e0b0e375 | |||
| 880bce157b | |||
| 2fb2eaa54c | |||
| 4ccf316184 | |||
| 57ba63aa32 | |||
| 0732c9c5e2 | |||
| 9bee255647 | |||
| da7f60cf04 | |||
| 5612176996 | |||
| 77e0114e96 | |||
| 08e1cac3a0 | |||
| 84cf6ecab0 | |||
| fbc8cdeebe | |||
| 1da6168f37 | |||
| 5f46ba8c74 | |||
| a604789285 | |||
| 5d6ace4e46 | |||
| 9c6b184f7e | |||
| bf4729fa4d | |||
| d61add82cd | |||
| d65a7c6c30 | |||
| f779d4aa9d | |||
| 3061260eec | |||
| ab930a3279 | |||
| 2d59f3aaca | |||
| 4e7fc24582 | |||
| 906b0e081b | |||
| 95b270810b | |||
| b7d427023e | |||
| 4108ffc23f | |||
| 43faed224f | |||
| 5ab1387165 | |||
| facb7e5161 | |||
| 90b045f349 | |||
| 6fbcdd3674 | |||
| 8ce12c4c70 | |||
| fd2ada4438 | |||
| 2160b9aaa0 | |||
| 7806bcbede | |||
| 3a100f8fde | |||
| 93bbc2ef22 | |||
| 81fa370ec9 | |||
| d767f9714c | |||
| 59ad5143eb | |||
| d9315ecf7d | |||
| 9a95e97150 | |||
| dbdd28d552 | |||
| 30f88c6f9d | |||
| e7cc825c54 | |||
| d1c95254b0 | |||
| d2dcd2ca9f |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -31,6 +31,13 @@ yarn-error.log*
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# wasm binaries
|
||||||
|
/public/wasm/*
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# agents
|
||||||
|
.github
|
||||||
|
.claude
|
||||||
11
.mcp.json
Normal file
11
.mcp.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"shadcn": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"shadcn@latest",
|
||||||
|
"mcp"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
CLAUDE.md
Normal file
77
CLAUDE.md
Normal 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`.
|
||||||
11
Dockerfile
11
Dockerfile
@@ -3,6 +3,17 @@ FROM node:20-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
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 files
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
|||||||
71
GEMINI.md
Normal file
71
GEMINI.md
Normal 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
314
README.md
@@ -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.
|
[](https://kit.pivoine.art)
|
||||||
|
[](https://nextjs.org)
|
||||||
|
[](https://react.dev)
|
||||||
|
[](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
|
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.
|
||||||
- 🎨 **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
|
|
||||||
|
|
||||||
## Tech Stack
|
---
|
||||||
|
|
||||||
- **Next.js 16** - React framework with App Router and Turbopack
|
## 🚀 The Toolkit
|
||||||
- **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
|
|
||||||
|
|
||||||
## 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
|
### Prerequisites
|
||||||
|
- **Node.js**: 20.x or higher
|
||||||
|
- **pnpm**: 9.x or higher
|
||||||
|
|
||||||
- Node.js 20+
|
### Local Development
|
||||||
- pnpm (via corepack)
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Clone and install
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
# Run development server
|
# Start development server with Turbopack
|
||||||
pnpm dev
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
# Build for production
|
### Production Build
|
||||||
|
```bash
|
||||||
|
# Build for static export
|
||||||
pnpm build
|
pnpm build
|
||||||
|
|
||||||
# Preview production build locally
|
# The output will be in the /out directory
|
||||||
pnpm start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit [http://localhost:3000](http://localhost:3000) to see the site.
|
### Docker Deployment
|
||||||
|
|
||||||
## 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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Pull and run the latest image
|
# Build locally
|
||||||
docker pull ghcr.io/valknarness/kit-ui:latest
|
docker build -t kit-ui .
|
||||||
docker run -p 80:80 ghcr.io/valknarness/kit-ui:latest
|
|
||||||
|
# Run with Nginx
|
||||||
|
docker run -p 80:80 kit-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build Locally
|
---
|
||||||
|
|
||||||
Or build and run locally:
|
## 📈 Performance & Optimization
|
||||||
|
|
||||||
```bash
|
- **Static Site Generation (SSG)**: Entire toolkit is exported as static HTML/JS for sub-second load times.
|
||||||
# Build the image
|
- **Client-Side WASM**: Complex processing (FFmpeg, ImageMagick, Color) is offloaded to WebAssembly for native-level performance without server latency.
|
||||||
docker build -t kit-landing .
|
- **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`.
|
© 2026 [pivoine.art](https://pivoine.art). All rights reserved.
|
||||||
|
|
||||||
### 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)
|
|
||||||
|
|||||||
16
app/(app)/animate/page.tsx
Normal file
16
app/(app)/animate/page.tsx
Normal 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
16
app/(app)/ascii/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/(app)/calculate/page.tsx
Normal file
16
app/(app)/calculate/page.tsx
Normal 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
16
app/(app)/color/page.tsx
Normal 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
19
app/(app)/cron/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/(app)/favicon/page.tsx
Normal file
16
app/(app)/favicon/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { FigletConverter } from '@/components/figlet/FigletConverter';
|
|
||||||
|
|
||||||
export default function FigletPage() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Figlet ASCII</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
ASCII Art Text Generator with 373 Fonts
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<FigletConverter />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import AnimatedBackground from '@/components/AnimatedBackground';
|
||||||
import { AppShell } from '@/components/layout/AppShell';
|
import { AppShell } from '@/components/layout/AppShell';
|
||||||
import { Providers } from '@/components/providers/Providers';
|
import { Providers } from '@/components/providers/Providers';
|
||||||
|
|
||||||
|
|||||||
16
app/(app)/media/page.tsx
Normal file
16
app/(app)/media/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
|
|
||||||
import { ColorDisplay } from '@/components/pastel/color/ColorDisplay';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Select } from '@/components/ui/Select';
|
|
||||||
import { useSimulateColorBlindness } from '@/lib/pastel/api/queries';
|
|
||||||
import { Loader2, Eye, Plus, X } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
type ColorBlindnessType = 'protanopia' | 'deuteranopia' | 'tritanopia';
|
|
||||||
|
|
||||||
export default function ColorBlindPage() {
|
|
||||||
const [colors, setColors] = useState<string[]>(['#ff0099']);
|
|
||||||
const [blindnessType, setBlindnessType] = useState<ColorBlindnessType>('protanopia');
|
|
||||||
const [simulations, setSimulations] = useState<
|
|
||||||
Array<{ input: string; output: string; difference_percentage: number }>
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const simulateMutation = useSimulateColorBlindness();
|
|
||||||
|
|
||||||
const handleSimulate = async () => {
|
|
||||||
try {
|
|
||||||
const result = await simulateMutation.mutateAsync({
|
|
||||||
colors,
|
|
||||||
type: blindnessType,
|
|
||||||
});
|
|
||||||
setSimulations(result.colors);
|
|
||||||
toast.success(`Simulated ${blindnessType}`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to simulate color blindness');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addColor = () => {
|
|
||||||
if (colors.length < 10) {
|
|
||||||
setColors([...colors, '#000000']);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeColor = (index: number) => {
|
|
||||||
if (colors.length > 1) {
|
|
||||||
setColors(colors.filter((_, i) => i !== index));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateColor = (index: number, color: string) => {
|
|
||||||
const newColors = [...colors];
|
|
||||||
newColors[index] = color;
|
|
||||||
setColors(newColors);
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeDescriptions: Record<ColorBlindnessType, string> = {
|
|
||||||
protanopia: 'Red-blind (affects ~1% of males)',
|
|
||||||
deuteranopia: 'Green-blind (affects ~1% of males)',
|
|
||||||
tritanopia: 'Blue-blind (rare, affects ~0.001%)',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Color Blindness Simulator</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Simulate how colors appear with different types of color blindness
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold">Colors to Test</h2>
|
|
||||||
<Button
|
|
||||||
onClick={addColor}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={colors.length >= 10}
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add Color
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{colors.map((color, index) => (
|
|
||||||
<div key={index} className="flex items-start gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<ColorPicker
|
|
||||||
color={color}
|
|
||||||
onChange={(newColor) => updateColor(index, newColor)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{colors.length > 1 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => removeColor(index)}
|
|
||||||
className="mt-8"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Blindness Type</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Select
|
|
||||||
label="Type"
|
|
||||||
value={blindnessType}
|
|
||||||
onChange={(e) => setBlindnessType(e.target.value as ColorBlindnessType)}
|
|
||||||
>
|
|
||||||
<option value="protanopia">Protanopia (Red-blind)</option>
|
|
||||||
<option value="deuteranopia">Deuteranopia (Green-blind)</option>
|
|
||||||
<option value="tritanopia">Tritanopia (Blue-blind)</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{typeDescriptions[blindnessType]}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleSimulate}
|
|
||||||
disabled={simulateMutation.isPending || colors.length === 0}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{simulateMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Simulating..
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
|
||||||
Simulate
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{simulations.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Simulation Results</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-6">
|
|
||||||
Compare original colors (left) with how they appear to people with{' '}
|
|
||||||
{blindnessType} (right)
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{simulations.map((sim, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="grid grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
|
||||||
Original
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ColorDisplay color={sim.input} size="md" />
|
|
||||||
<code className="text-sm font-mono">{sim.input}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">
|
|
||||||
As Seen ({sim.difference_percentage.toFixed(1)}% difference)
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ColorDisplay color={sim.output} size="md" />
|
|
||||||
<code className="text-sm font-mono">{sim.output}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card bg-blue-50 dark:bg-blue-950/20">
|
|
||||||
<h3 className="font-semibold mb-2 flex items-center gap-2">
|
|
||||||
<Eye className="h-5 w-5" />
|
|
||||||
Accessibility Tip
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Ensure important information isn't conveyed by color alone. Use text
|
|
||||||
labels, patterns, or icons to make your design accessible to everyone
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="p-12 border rounded-lg bg-card text-center text-muted-foreground">
|
|
||||||
<Eye className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
||||||
<p>Add colors and click Simulate to see how they appear</p>
|
|
||||||
<p className="text-sm mt-2">with different types of color blindness</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { getContrastRatio, hexToRgb, checkWCAGCompliance } from '@/lib/pastel/utils/color';
|
|
||||||
import { ArrowLeftRight, Check, X } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function ContrastPage() {
|
|
||||||
const [foreground, setForeground] = useState('#000000');
|
|
||||||
const [background, setBackground] = useState('#ffffff');
|
|
||||||
const [ratio, setRatio] = useState<number | null>(null);
|
|
||||||
const [compliance, setCompliance] = useState<any>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fgRgb = hexToRgb(foreground);
|
|
||||||
const bgRgb = hexToRgb(background);
|
|
||||||
|
|
||||||
if (fgRgb && bgRgb) {
|
|
||||||
const contrastRatio = getContrastRatio(fgRgb, bgRgb);
|
|
||||||
setRatio(contrastRatio);
|
|
||||||
setCompliance(checkWCAGCompliance(contrastRatio));
|
|
||||||
}
|
|
||||||
}, [foreground, background]);
|
|
||||||
|
|
||||||
const swapColors = () => {
|
|
||||||
const temp = foreground;
|
|
||||||
setForeground(background);
|
|
||||||
setBackground(temp);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ComplianceItem = ({
|
|
||||||
label,
|
|
||||||
passed,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
passed: boolean;
|
|
||||||
}) => (
|
|
||||||
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
|
|
||||||
<span className="text-sm">{label}</span>
|
|
||||||
<Badge variant={passed ? 'success' : 'destructive'}>
|
|
||||||
{passed ? (
|
|
||||||
<>
|
|
||||||
<Check className="h-3 w-3 mr-1" />
|
|
||||||
Pass
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<X className="h-3 w-3 mr-1" />
|
|
||||||
Fail
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Contrast Checker</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Test color combinations for WCAG 2.1 compliance
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* Color Pickers */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold">Foreground Color</h2>
|
|
||||||
</div>
|
|
||||||
<ColorPicker color={foreground} onChange={setForeground} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Button
|
|
||||||
onClick={swapColors}
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
<ArrowLeftRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Background Color</h2>
|
|
||||||
<ColorPicker color={background} onChange={setBackground} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Preview */}
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Preview</h2>
|
|
||||||
<div
|
|
||||||
className="rounded-lg p-8 text-center"
|
|
||||||
style={{ backgroundColor: background, color: foreground }}
|
|
||||||
>
|
|
||||||
<p className="text-xl font-bold mb-2">Normal Text (16px)</p>
|
|
||||||
<p className="text-3xl font-bold">Large Text (24px)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contrast Ratio */}
|
|
||||||
{ratio !== null && (
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Contrast Ratio</h2>
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<div className="text-5xl font-bold">{ratio.toFixed(2)}:1</div>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
|
||||||
{ratio >= 7
|
|
||||||
? 'Excellent contrast'
|
|
||||||
: ratio >= 4.5
|
|
||||||
? 'Good contrast'
|
|
||||||
: ratio >= 3
|
|
||||||
? 'Minimum contrast'
|
|
||||||
: 'Poor contrast'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* WCAG Compliance */}
|
|
||||||
{compliance && (
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">WCAG 2.1 Compliance</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold mb-2">Level AA</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<ComplianceItem
|
|
||||||
label="Normal Text (4.5:1)"
|
|
||||||
passed={compliance.aa.normalText}
|
|
||||||
/>
|
|
||||||
<ComplianceItem
|
|
||||||
label="Large Text (3:1)"
|
|
||||||
passed={compliance.aa.largeText}
|
|
||||||
/>
|
|
||||||
<ComplianceItem
|
|
||||||
label="UI Components (3:1)"
|
|
||||||
passed={compliance.aa.ui}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold mb-2">Level AAA</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<ComplianceItem
|
|
||||||
label="Normal Text (7:1)"
|
|
||||||
passed={compliance.aaa.normalText}
|
|
||||||
/>
|
|
||||||
<ComplianceItem
|
|
||||||
label="Large Text (4.5:1)"
|
|
||||||
passed={compliance.aaa.largeText}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { Contrast, Eye, Palette } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function AccessibilityPage() {
|
|
||||||
const tools = [
|
|
||||||
{
|
|
||||||
title: 'Contrast Checker',
|
|
||||||
description: 'Test color combinations for WCAG 2.1 AA and AAA compliance',
|
|
||||||
href: '/pastel/accessibility/contrast',
|
|
||||||
icon: Contrast,
|
|
||||||
features: ['WCAG 2.1 standards', 'AA/AAA ratings', 'Live preview'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Color Blindness Simulator',
|
|
||||||
description: 'Simulate how colors appear with different types of color blindness',
|
|
||||||
href: '/pastel/accessibility/colorblind',
|
|
||||||
icon: Eye,
|
|
||||||
features: ['Protanopia', 'Deuteranopia', 'Tritanopia'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Text Color Optimizer',
|
|
||||||
description: 'Find the best text color for any background automatically',
|
|
||||||
href: '/pastel/accessibility/textcolor',
|
|
||||||
icon: Palette,
|
|
||||||
features: ['Automatic optimization', 'WCAG guaranteed', 'Light/dark options'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Accessibility Tools</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Ensure your colors are accessible to everyone
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{tools.map((tool) => {
|
|
||||||
const Icon = tool.icon;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tool.href}
|
|
||||||
href={tool.href}
|
|
||||||
className="p-6 border rounded-lg bg-card hover:border-primary transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<Icon className="h-8 w-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold mb-2">{tool.title}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">{tool.description}</p>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{tool.features.map((feature) => (
|
|
||||||
<li key={feature} className="text-sm text-muted-foreground flex items-center">
|
|
||||||
<span className="mr-2">•</span>
|
|
||||||
{feature}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Educational Content */}
|
|
||||||
<div className="p-6 border rounded-lg bg-card mt-8">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">About WCAG 2.1</h2>
|
|
||||||
<div className="space-y-4 text-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
The Web Content Accessibility Guidelines (WCAG) 2.1 provide standards for making web
|
|
||||||
content more accessible to people with disabilities
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground mb-2">Level AA (Minimum)</h3>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
<li>• Normal text: 4.5:1 contrast ratio</li>
|
|
||||||
<li>• Large text: 3:1 contrast ratio</li>
|
|
||||||
<li>• UI components: 3:1 contrast ratio</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold text-foreground mb-2">Level AAA (Enhanced)</h3>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
<li>• Normal text: 7:1 contrast ratio</li>
|
|
||||||
<li>• Large text: 4.5:1 contrast ratio</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
|
|
||||||
import { ColorDisplay } from '@/components/pastel/color/ColorDisplay';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { useTextColor } from '@/lib/pastel/api/queries';
|
|
||||||
import { Loader2, Palette, Plus, X, CheckCircle2, XCircle } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export default function TextColorPage() {
|
|
||||||
const [backgrounds, setBackgrounds] = useState<string[]>(['#ff0099']);
|
|
||||||
const [results, setResults] = useState<
|
|
||||||
Array<{
|
|
||||||
background: string;
|
|
||||||
textcolor: string;
|
|
||||||
contrast_ratio: number;
|
|
||||||
wcag_aa: boolean;
|
|
||||||
wcag_aaa: boolean;
|
|
||||||
}>
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const textColorMutation = useTextColor();
|
|
||||||
|
|
||||||
const handleOptimize = async () => {
|
|
||||||
try {
|
|
||||||
const result = await textColorMutation.mutateAsync({
|
|
||||||
backgrounds,
|
|
||||||
});
|
|
||||||
setResults(result.colors);
|
|
||||||
toast.success(`Optimized text colors for ${result.colors.length} background(s)`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to optimize text colors');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addBackground = () => {
|
|
||||||
if (backgrounds.length < 10) {
|
|
||||||
setBackgrounds([...backgrounds, '#000000']);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeBackground = (index: number) => {
|
|
||||||
if (backgrounds.length > 1) {
|
|
||||||
setBackgrounds(backgrounds.filter((_, i) => i !== index));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateBackground = (index: number, color: string) => {
|
|
||||||
const newBackgrounds = [...backgrounds];
|
|
||||||
newBackgrounds[index] = color;
|
|
||||||
setBackgrounds(newBackgrounds);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Text Color Optimizer</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Automatically find the best text color (black or white) for any background color
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* Input */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold">Background Colors</h2>
|
|
||||||
<Button
|
|
||||||
onClick={addBackground}
|
|
||||||
disabled={backgrounds.length >= 10}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{backgrounds.map((color, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<ColorPicker
|
|
||||||
color={color}
|
|
||||||
onChange={(newColor) => updateBackground(index, newColor)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{backgrounds.length > 1 && (
|
|
||||||
<Button
|
|
||||||
onClick={() => removeBackground(index)}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleOptimize}
|
|
||||||
disabled={textColorMutation.isPending || backgrounds.length === 0}
|
|
||||||
className="w-full mt-4"
|
|
||||||
>
|
|
||||||
{textColorMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Optimizing..
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Palette className="mr-2 h-4 w-4" />
|
|
||||||
Optimize Text Colors
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card bg-blue-50 dark:bg-blue-950/20">
|
|
||||||
<h3 className="font-semibold mb-2">How it works</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
This tool analyzes each background color and automatically selects either black
|
|
||||||
or white text to ensure maximum readability. The algorithm guarantees WCAG AA
|
|
||||||
compliance (4.5:1 contrast ratio) for normal text
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{results.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Optimized Results</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{results.map((result, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="p-4 border rounded-lg"
|
|
||||||
style={{ backgroundColor: result.background }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ColorDisplay color={result.background} size="sm" />
|
|
||||||
<code className="text-sm font-mono text-inherit">
|
|
||||||
{result.background}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="p-4 rounded border-2"
|
|
||||||
style={{
|
|
||||||
backgroundColor: result.background,
|
|
||||||
color: result.textcolor,
|
|
||||||
borderColor: result.textcolor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="font-semibold mb-2" style={{ color: result.textcolor }}>
|
|
||||||
Sample Text Preview
|
|
||||||
</p>
|
|
||||||
<p className="text-sm" style={{ color: result.textcolor }}>
|
|
||||||
The quick brown fox jumps over the lazy dog. This is how your text
|
|
||||||
will look on this background color
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Text Color: </span>
|
|
||||||
<code className="font-mono">{result.textcolor}</code>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Contrast: </span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{result.contrast_ratio.toFixed(2)}:1
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{result.wcag_aa ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-4 w-4 text-red-500" />
|
|
||||||
)}
|
|
||||||
<span className={result.wcag_aa ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
|
|
||||||
WCAG AA
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{result.wcag_aaa ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-4 w-4 text-yellow-500" />
|
|
||||||
)}
|
|
||||||
<span className={result.wcag_aaa ? 'text-green-600 dark:text-green-400' : 'text-yellow-600 dark:text-yellow-400'}>
|
|
||||||
WCAG AAA
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="p-12 border rounded-lg bg-card text-center text-muted-foreground">
|
|
||||||
<Palette className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
||||||
<p>Add background colors and click Optimize to see results</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Select } from '@/components/ui/Select';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { PaletteGrid } from '@/components/pastel/color/PaletteGrid';
|
|
||||||
import { ExportMenu } from '@/components/pastel/tools/ExportMenu';
|
|
||||||
import { useLighten, useDarken, useSaturate, useDesaturate, useRotate } from '@/lib/pastel/api/queries';
|
|
||||||
import { Loader2, Upload, Download } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
type Operation = 'lighten' | 'darken' | 'saturate' | 'desaturate' | 'rotate';
|
|
||||||
|
|
||||||
export default function BatchPage() {
|
|
||||||
const [inputColors, setInputColors] = useState('');
|
|
||||||
const [operation, setOperation] = useState<Operation>('lighten');
|
|
||||||
const [amount, setAmount] = useState(0.2);
|
|
||||||
const [outputColors, setOutputColors] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const lightenMutation = useLighten();
|
|
||||||
const darkenMutation = useDarken();
|
|
||||||
const saturateMutation = useSaturate();
|
|
||||||
const desaturateMutation = useDesaturate();
|
|
||||||
const rotateMutation = useRotate();
|
|
||||||
|
|
||||||
const parseColors = (text: string): string[] => {
|
|
||||||
// Parse colors from text (one per line, or comma-separated)
|
|
||||||
return text
|
|
||||||
.split(/[\n,]/)
|
|
||||||
.map((c) => c.trim())
|
|
||||||
.filter((c) => c.length > 0 && c.match(/^#?[0-9a-fA-F]{3,8}$/));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProcess = async () => {
|
|
||||||
const colors = parseColors(inputColors);
|
|
||||||
|
|
||||||
if (colors.length === 0) {
|
|
||||||
toast.error('No valid colors found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (colors.length > 100) {
|
|
||||||
toast.error('Maximum 100 colors allowed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let result;
|
|
||||||
|
|
||||||
switch (operation) {
|
|
||||||
case 'lighten':
|
|
||||||
result = await lightenMutation.mutateAsync({ colors, amount });
|
|
||||||
break;
|
|
||||||
case 'darken':
|
|
||||||
result = await darkenMutation.mutateAsync({ colors, amount });
|
|
||||||
break;
|
|
||||||
case 'saturate':
|
|
||||||
result = await saturateMutation.mutateAsync({ colors, amount });
|
|
||||||
break;
|
|
||||||
case 'desaturate':
|
|
||||||
result = await desaturateMutation.mutateAsync({ colors, amount });
|
|
||||||
break;
|
|
||||||
case 'rotate':
|
|
||||||
result = await rotateMutation.mutateAsync({ colors, amount: amount * 360 });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract output colors from the result
|
|
||||||
const processed = result.colors.map((c) => c.output);
|
|
||||||
setOutputColors(processed);
|
|
||||||
toast.success(`Processed ${processed.length} colors`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to process colors');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isPending =
|
|
||||||
lightenMutation.isPending ||
|
|
||||||
darkenMutation.isPending ||
|
|
||||||
saturateMutation.isPending ||
|
|
||||||
desaturateMutation.isPending ||
|
|
||||||
rotateMutation.isPending;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Batch Operations</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Process multiple colors at once with manipulation operations
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* Input */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Input Colors</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Enter colors (one per line or comma-separated). Supports hex format
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
value={inputColors}
|
|
||||||
onChange={(e) => setInputColors(e.target.value)}
|
|
||||||
placeholder="#ff0099, #00ff99, #9900ff #ff5533 #3355ff"
|
|
||||||
className="w-full h-48 p-3 border border-border rounded-xl bg-input font-mono text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary/50 transition-all duration-200"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
|
||||||
{parseColors(inputColors).length} valid colors found
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Operation</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Select
|
|
||||||
label="Operation"
|
|
||||||
value={operation}
|
|
||||||
onChange={(e) => setOperation(e.target.value as Operation)}
|
|
||||||
>
|
|
||||||
<option value="lighten">Lighten</option>
|
|
||||||
<option value="darken">Darken</option>
|
|
||||||
<option value="saturate">Saturate</option>
|
|
||||||
<option value="desaturate">Desaturate</option>
|
|
||||||
<option value="rotate">Rotate Hue</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium mb-2 block">
|
|
||||||
Amount: {operation === 'rotate' ? (amount * 360).toFixed(0) + '°' : (amount * 100).toFixed(0) + '%'}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.01"
|
|
||||||
value={amount}
|
|
||||||
onChange={(e) => setAmount(parseFloat(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleProcess}
|
|
||||||
disabled={isPending || parseColors(inputColors).length === 0}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Processing..
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
Process Colors
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Output */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{outputColors.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">
|
|
||||||
Output Colors ({outputColors.length})
|
|
||||||
</h2>
|
|
||||||
<PaletteGrid colors={outputColors} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<ExportMenu colors={outputColors} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="p-12 border rounded-lg bg-card text-center text-muted-foreground">
|
|
||||||
<Download className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
||||||
<p>Enter colors and click Process to see results</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@plugin "@tailwindcss/typography";
|
|
||||||
|
|
||||||
@source "../components/color/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "../components/layout/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "../components/providers/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "../components/tools/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "../components/ui/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "./playground/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "./palettes/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "./palettes/distinct/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "./palettes/gradient/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "./palettes/harmony/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "./names/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "./batch/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "./accessibility/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "./accessibility/colorblind/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "./accessibility/contrast/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "*.{js,ts,jsx,tsx}";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--radius: 0.5rem;
|
|
||||||
|
|
||||||
/* Light Mode Colors - Using OKLCH for better color precision */
|
|
||||||
--background: oklch(100% 0 0);
|
|
||||||
--foreground: oklch(9.8% 0.038 285.8);
|
|
||||||
--card: oklch(100% 0 0);
|
|
||||||
--card-foreground: oklch(9.8% 0.038 285.8);
|
|
||||||
--popover: oklch(100% 0 0);
|
|
||||||
--popover-foreground: oklch(9.8% 0.038 285.8);
|
|
||||||
--primary: oklch(22.4% 0.053 285.8);
|
|
||||||
--primary-foreground: oklch(98% 0.016 240);
|
|
||||||
--secondary: oklch(96.1% 0.016 240);
|
|
||||||
--secondary-foreground: oklch(22.4% 0.053 285.8);
|
|
||||||
--muted: oklch(96.1% 0.016 240);
|
|
||||||
--muted-foreground: oklch(46.9% 0.025 244.1);
|
|
||||||
--accent: oklch(96.1% 0.016 240);
|
|
||||||
--accent-foreground: oklch(22.4% 0.053 285.8);
|
|
||||||
--destructive: oklch(60.2% 0.168 29.2);
|
|
||||||
--destructive-foreground: oklch(98% 0.016 240);
|
|
||||||
--border: oklch(91.4% 0.026 243.1);
|
|
||||||
--input: oklch(91.4% 0.026 243.1);
|
|
||||||
--ring: oklch(9.8% 0.038 285.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
/* Tailwind v4 theme color definitions */
|
|
||||||
--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);
|
|
||||||
|
|
||||||
/* Custom Animations */
|
|
||||||
--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-slide-in-right: slideInRight 0.3s ease-out;
|
|
||||||
--animate-slide-in-left: slideInLeft 0.3s ease-out;
|
|
||||||
--animate-scale-in: scaleIn 0.2s ease-out;
|
|
||||||
--animate-bounce-gentle: bounceGentle 0.5s ease-in-out;
|
|
||||||
--animate-shimmer: shimmer 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: oklch(9.8% 0.038 285.8);
|
|
||||||
--foreground: oklch(98% 0.016 240);
|
|
||||||
--card: oklch(9.8% 0.038 285.8);
|
|
||||||
--card-foreground: oklch(98% 0.016 240);
|
|
||||||
--popover: oklch(9.8% 0.038 285.8);
|
|
||||||
--popover-foreground: oklch(98% 0.016 240);
|
|
||||||
--primary: oklch(98% 0.016 240);
|
|
||||||
--primary-foreground: oklch(22.4% 0.053 285.8);
|
|
||||||
--secondary: oklch(17.5% 0.036 242.3);
|
|
||||||
--secondary-foreground: oklch(98% 0.016 240);
|
|
||||||
--muted: oklch(17.5% 0.036 242.3);
|
|
||||||
--muted-foreground: oklch(65.1% 0.031 244);
|
|
||||||
--accent: oklch(17.5% 0.036 242.3);
|
|
||||||
--accent-foreground: oklch(98% 0.016 240);
|
|
||||||
--destructive: oklch(30.6% 0.125 29.2);
|
|
||||||
--destructive-foreground: oklch(98% 0.016 240);
|
|
||||||
--border: oklch(17.5% 0.036 242.3);
|
|
||||||
--input: oklch(17.5% 0.036 242.3);
|
|
||||||
--ring: oklch(83.9% 0.031 243.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border outline-ring/50;
|
|
||||||
transition-property: background-color, border-color, color, fill, stroke;
|
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
transition-duration: 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Disable transitions during theme switch to prevent flash */
|
|
||||||
.theme-transitioning * {
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
@apply bg-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
@apply bg-muted-foreground/20 rounded-lg hover:bg-muted-foreground/30;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Screen reader only */
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation Keyframes */
|
|
||||||
@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 slideInRight {
|
|
||||||
from { transform: translateX(-20px); opacity: 0; }
|
|
||||||
to { transform: translateX(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideInLeft {
|
|
||||||
from { transform: translateX(20px); opacity: 0; }
|
|
||||||
to { transform: translateX(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scaleIn {
|
|
||||||
from { transform: scale(0.95); opacity: 0; }
|
|
||||||
to { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounceGentle {
|
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
50% { transform: translateY(-5px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
from { background-position: -1000px 0; }
|
|
||||||
to { background-position: 1000px 0; }
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export default function PastelLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
|
||||||
import { ColorSwatch } from '@/components/pastel/color/ColorSwatch';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Select } from '@/components/ui/Select';
|
|
||||||
import { useNamedColors } from '@/lib/pastel/api/queries';
|
|
||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function NamedColorsPage() {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [sortBy, setSortBy] = useState<'name' | 'hue'>('name');
|
|
||||||
|
|
||||||
const { data, isLoading, isError } = useNamedColors();
|
|
||||||
|
|
||||||
const filteredColors = useMemo(() => {
|
|
||||||
if (!data) return [];
|
|
||||||
|
|
||||||
let colors = data.colors.filter(
|
|
||||||
(color) =>
|
|
||||||
color.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
color.hex.toLowerCase().includes(search.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sortBy === 'name') {
|
|
||||||
colors.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}
|
|
||||||
// For hue sorting, we'd need to convert to HSL which requires the API
|
|
||||||
// For now, just keep alphabetical as default
|
|
||||||
|
|
||||||
return colors;
|
|
||||||
}, [data, search, sortBy]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Named Colors</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Explore 148 CSS/X11 named colors
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search and Filters */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search by name or hex..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-full sm:w-48">
|
|
||||||
<Select value={sortBy} onChange={(e) => setSortBy(e.target.value as 'name' | 'hue')}>
|
|
||||||
<option value="name">Sort by Name</option>
|
|
||||||
<option value="hue">Sort by Hue</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Colors Grid */}
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isError && (
|
|
||||||
<div className="text-center py-12 text-destructive">
|
|
||||||
Failed to load named colors
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredColors.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="mb-4 text-sm text-muted-foreground">
|
|
||||||
Showing {filteredColors.length} colors
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-6">
|
|
||||||
{filteredColors.map((color) => (
|
|
||||||
<div key={color.name} className="flex flex-col items-center gap-2">
|
|
||||||
<ColorSwatch color={color.hex} showLabel={false} />
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-sm font-medium">{color.name}</div>
|
|
||||||
<div className="text-xs font-mono text-muted-foreground">
|
|
||||||
{color.hex}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredColors.length === 0 && !isLoading && !isError && (
|
|
||||||
<div className="text-center py-12 text-muted-foreground">
|
|
||||||
No colors match your search
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
|
||||||
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
|
|
||||||
import { ColorDisplay } from '@/components/pastel/color/ColorDisplay';
|
|
||||||
import { ColorInfo } from '@/components/pastel/color/ColorInfo';
|
|
||||||
import { ManipulationPanel } from '@/components/pastel/tools/ManipulationPanel';
|
|
||||||
import { useColorInfo } from '@/lib/pastel/api/queries';
|
|
||||||
import { useKeyboard } from '@/lib/pastel/hooks/useKeyboard';
|
|
||||||
import { useColorHistory } from '@/lib/pastel/stores/historyStore';
|
|
||||||
import { Loader2, Share2, History, X } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
function PlaygroundContent() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
const [color, setColor] = useState(() => {
|
|
||||||
// Initialize from URL if available
|
|
||||||
const urlColor = searchParams.get('color');
|
|
||||||
return urlColor ? `#${urlColor.replace('#', '')}` : '#ff0099';
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useColorInfo({
|
|
||||||
colors: [color],
|
|
||||||
});
|
|
||||||
|
|
||||||
const colorInfo = data?.colors[0];
|
|
||||||
|
|
||||||
// Color history
|
|
||||||
const { history, addColor, removeColor, clearHistory, getRecent } = useColorHistory();
|
|
||||||
const recentColors = getRecent(10);
|
|
||||||
|
|
||||||
// Update URL and history when color changes
|
|
||||||
useEffect(() => {
|
|
||||||
const hex = color.replace('#', '');
|
|
||||||
router.push(`/pastel?color=${hex}`, { scroll: false });
|
|
||||||
addColor(color);
|
|
||||||
}, [color, router, addColor]);
|
|
||||||
|
|
||||||
// Share color via URL
|
|
||||||
const handleShare = () => {
|
|
||||||
const url = `${window.location.origin}/pastel?color=${color.replace('#', '')}`;
|
|
||||||
navigator.clipboard.writeText(url);
|
|
||||||
toast.success('Link copied to clipboard!');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Copy color to clipboard
|
|
||||||
const handleCopyColor = () => {
|
|
||||||
navigator.clipboard.writeText(color);
|
|
||||||
toast.success('Color copied to clipboard!');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Random color generation
|
|
||||||
const handleRandomColor = () => {
|
|
||||||
const randomHex = '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0');
|
|
||||||
setColor(randomHex);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
useKeyboard([
|
|
||||||
{
|
|
||||||
key: 'c',
|
|
||||||
meta: true,
|
|
||||||
handler: handleCopyColor,
|
|
||||||
description: 'Copy color',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 's',
|
|
||||||
meta: true,
|
|
||||||
handler: handleShare,
|
|
||||||
description: 'Share color',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'r',
|
|
||||||
meta: true,
|
|
||||||
handler: handleRandomColor,
|
|
||||||
description: 'Random color',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Color Playground</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Interactive color manipulation and analysis tool
|
|
||||||
</p>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
||||||
<kbd className="px-2 py-1 bg-muted rounded border">⌘C</kbd>
|
|
||||||
<span>Copy</span>
|
|
||||||
<kbd className="px-2 py-1 bg-muted rounded border ml-3">⌘S</kbd>
|
|
||||||
<span>Share</span>
|
|
||||||
<kbd className="px-2 py-1 bg-muted rounded border ml-3">⌘R</kbd>
|
|
||||||
<span>Random</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* Left Column: Color Picker and Display */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Color Picker</h2>
|
|
||||||
<ColorPicker color={color} onChange={setColor} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold">Preview</h2>
|
|
||||||
<Button onClick={handleShare} variant="outline" size="sm">
|
|
||||||
<Share2 className="h-4 w-4 mr-2" />
|
|
||||||
Share
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<ColorDisplay color={color} size="xl" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{recentColors.length > 0 && (
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<History className="h-5 w-5" />
|
|
||||||
<h2 className="text-xl font-semibold">Recent Colors</h2>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={clearHistory}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-5 gap-2">
|
|
||||||
{recentColors.map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.timestamp}
|
|
||||||
className="group relative aspect-square rounded-lg border-2 border-border hover:border-primary transition-all hover:scale-110 cursor-pointer"
|
|
||||||
style={{ backgroundColor: entry.color }}
|
|
||||||
onClick={() => setColor(entry.color)}
|
|
||||||
title={entry.color}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
setColor(entry.color);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/30 rounded-lg">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
removeColor(entry.color);
|
|
||||||
toast.success('Color removed from history');
|
|
||||||
}}
|
|
||||||
className="p-1 bg-destructive rounded-full hover:bg-destructive/80"
|
|
||||||
aria-label="Remove color"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3 text-destructive-foreground" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column: Color Information */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Color Information</h2>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isError && (
|
|
||||||
<div className="p-4 bg-destructive/10 text-destructive rounded-lg">
|
|
||||||
<p className="font-medium">Error loading color information</p>
|
|
||||||
<p className="text-sm mt-1">{error?.message || 'Unknown error'}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{colorInfo && <ColorInfo info={colorInfo} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Color Manipulation</h2>
|
|
||||||
<ManipulationPanel color={color} onColorChange={setColor} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlaygroundPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 flex items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<PlaygroundContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { PaletteGrid } from '@/components/pastel/color/PaletteGrid';
|
|
||||||
import { ExportMenu } from '@/components/pastel/tools/ExportMenu';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Select } from '@/components/ui/Select';
|
|
||||||
import { useGenerateDistinct } from '@/lib/pastel/api/queries';
|
|
||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export default function DistinctPage() {
|
|
||||||
const [count, setCount] = useState(8);
|
|
||||||
const [metric, setMetric] = useState<'cie76' | 'ciede2000'>('ciede2000');
|
|
||||||
const [colors, setColors] = useState<string[]>([]);
|
|
||||||
const [stats, setStats] = useState<{
|
|
||||||
min_distance: number;
|
|
||||||
avg_distance: number;
|
|
||||||
generation_time_ms: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const generateMutation = useGenerateDistinct();
|
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
|
||||||
try {
|
|
||||||
const result = await generateMutation.mutateAsync({
|
|
||||||
count,
|
|
||||||
metric,
|
|
||||||
});
|
|
||||||
setColors(result.colors);
|
|
||||||
setStats(result.stats);
|
|
||||||
toast.success(`Generated ${result.colors.length} distinct colors`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to generate distinct colors');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Distinct Colors Generator</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Generate visually distinct colors using simulated annealing
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<div className="p-6 border rounded-lg bg-card space-y-6">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Settings</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="count" className="text-sm font-medium mb-2 block">
|
|
||||||
Number of Colors
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="count"
|
|
||||||
type="number"
|
|
||||||
min={2}
|
|
||||||
max={100}
|
|
||||||
value={count}
|
|
||||||
onChange={(e) => setCount(parseInt(e.target.value))}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Higher counts take longer to generate
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
label="Distance Metric"
|
|
||||||
value={metric}
|
|
||||||
onChange={(e) => setMetric(e.target.value as 'cie76' | 'ciede2000')}
|
|
||||||
>
|
|
||||||
<option value="cie76">CIE76 (Faster)</option>
|
|
||||||
<option value="ciede2000">CIEDE2000 (More Accurate)</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={generateMutation.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{generateMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Generating..
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Generate Colors'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{generateMutation.isPending && (
|
|
||||||
<div className="text-sm text-muted-foreground text-center">
|
|
||||||
This may take a few moments..
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stats && (
|
|
||||||
<div className="pt-4 border-t space-y-2">
|
|
||||||
<h3 className="font-semibold text-sm">Statistics</h3>
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Min Distance:</span>
|
|
||||||
<span className="font-mono">{stats.min_distance.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Avg Distance:</span>
|
|
||||||
<span className="font-mono">{stats.avg_distance.toFixed(2)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">Generation Time:</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{(stats.generation_time_ms / 1000).toFixed(2)}s
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
<div className="lg:col-span-2 space-y-6">
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">
|
|
||||||
Generated Colors {colors.length > 0 && `(${colors.length})`}
|
|
||||||
</h2>
|
|
||||||
<PaletteGrid colors={colors} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{colors.length > 0 && (
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<ExportMenu colors={colors} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
|
|
||||||
import { PaletteGrid } from '@/components/pastel/color/PaletteGrid';
|
|
||||||
import { ExportMenu } from '@/components/pastel/tools/ExportMenu';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Select } from '@/components/ui/Select';
|
|
||||||
import { useGenerateGradient } from '@/lib/pastel/api/queries';
|
|
||||||
import { Loader2, Plus, X } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export default function GradientPage() {
|
|
||||||
const [stops, setStops] = useState<string[]>(['#ff0099', '#0099ff']);
|
|
||||||
const [count, setCount] = useState(10);
|
|
||||||
const [colorspace, setColorspace] = useState<
|
|
||||||
'rgb' | 'hsl' | 'hsv' | 'lab' | 'oklab' | 'lch' | 'oklch'
|
|
||||||
>('lch');
|
|
||||||
const [gradient, setGradient] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const generateMutation = useGenerateGradient();
|
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
|
||||||
try {
|
|
||||||
const result = await generateMutation.mutateAsync({
|
|
||||||
stops,
|
|
||||||
count,
|
|
||||||
colorspace,
|
|
||||||
});
|
|
||||||
setGradient(result.gradient);
|
|
||||||
toast.success(`Generated ${result.gradient.length} colors`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to generate gradient');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addStop = () => {
|
|
||||||
setStops([...stops, '#000000']);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeStop = (index: number) => {
|
|
||||||
if (stops.length > 2) {
|
|
||||||
setStops(stops.filter((_, i) => i !== index));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStop = (index: number, color: string) => {
|
|
||||||
const newStops = [...stops];
|
|
||||||
newStops[index] = color;
|
|
||||||
setStops(newStops);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Gradient Creator</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Create smooth color gradients with multiple stops
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Color Stops</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{stops.map((stop, index) => (
|
|
||||||
<div key={index} className="flex items-start gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<ColorPicker
|
|
||||||
color={stop}
|
|
||||||
onChange={(color) => updateStop(index, color)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{stops.length > 2 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => removeStop(index)}
|
|
||||||
className="mt-8"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button onClick={addStop} variant="outline" className="w-full">
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add Stop
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Settings</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="count" className="text-sm font-medium mb-2 block">
|
|
||||||
Number of Colors
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="count"
|
|
||||||
type="number"
|
|
||||||
min={2}
|
|
||||||
max={1000}
|
|
||||||
value={count}
|
|
||||||
onChange={(e) => setCount(parseInt(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
label="Color Space"
|
|
||||||
value={colorspace}
|
|
||||||
onChange={(e) =>
|
|
||||||
setColorspace(
|
|
||||||
e.target.value as
|
|
||||||
| 'rgb'
|
|
||||||
| 'hsl'
|
|
||||||
| 'hsv'
|
|
||||||
| 'lab'
|
|
||||||
| 'oklab'
|
|
||||||
| 'lch'
|
|
||||||
| 'oklch'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="rgb">RGB</option>
|
|
||||||
<option value="hsl">HSL</option>
|
|
||||||
<option value="hsv">HSV</option>
|
|
||||||
<option value="lab">Lab</option>
|
|
||||||
<option value="oklab">OkLab</option>
|
|
||||||
<option value="lch">LCH</option>
|
|
||||||
<option value="oklch">OkLCH (Recommended)</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleGenerate}
|
|
||||||
disabled={generateMutation.isPending || stops.length < 2}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{generateMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Generating..
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Generate Gradient'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{gradient && gradient.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Gradient Preview</h2>
|
|
||||||
<div
|
|
||||||
className="h-32 rounded-lg"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to right, ${gradient.join(', ')})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">
|
|
||||||
Colors ({gradient.length})
|
|
||||||
</h2>
|
|
||||||
<PaletteGrid colors={gradient} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<ExportMenu colors={gradient} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { ColorPicker } from '@/components/pastel/color/ColorPicker';
|
|
||||||
import { PaletteGrid } from '@/components/pastel/color/PaletteGrid';
|
|
||||||
import { ExportMenu } from '@/components/pastel/tools/ExportMenu';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Select } from '@/components/ui/Select';
|
|
||||||
import { useGeneratePalette } from '@/lib/pastel/api/queries';
|
|
||||||
import { Loader2, Palette } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
type HarmonyType =
|
|
||||||
| 'monochromatic'
|
|
||||||
| 'analogous'
|
|
||||||
| 'complementary'
|
|
||||||
| 'split-complementary'
|
|
||||||
| 'triadic'
|
|
||||||
| 'tetradic';
|
|
||||||
|
|
||||||
export default function HarmonyPage() {
|
|
||||||
const [baseColor, setBaseColor] = useState('#ff0099');
|
|
||||||
const [harmonyType, setHarmonyType] = useState<HarmonyType>('complementary');
|
|
||||||
const [palette, setPalette] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const paletteMutation = useGeneratePalette();
|
|
||||||
|
|
||||||
const generateHarmony = async () => {
|
|
||||||
try {
|
|
||||||
const result = await paletteMutation.mutateAsync({
|
|
||||||
base: baseColor,
|
|
||||||
scheme: harmonyType,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Combine primary and secondary colors into flat array
|
|
||||||
const colors = [result.palette.primary, ...result.palette.secondary];
|
|
||||||
setPalette(colors);
|
|
||||||
toast.success(`Generated ${harmonyType} harmony palette`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to generate harmony palette');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const harmonyDescriptions: Record<HarmonyType, string> = {
|
|
||||||
monochromatic: 'Single color with variations',
|
|
||||||
analogous: 'Colors adjacent on the color wheel (±30°)',
|
|
||||||
complementary: 'Colors opposite on the color wheel (180°)',
|
|
||||||
'split-complementary': 'Base color + two colors flanking its complement',
|
|
||||||
triadic: 'Three colors evenly spaced on the color wheel (120°)',
|
|
||||||
tetradic: 'Four colors evenly spaced on the color wheel (90°)',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Harmony Palette Generator</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Create color harmonies based on color theory principles
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Base Color</h2>
|
|
||||||
<ColorPicker color={baseColor} onChange={setBaseColor} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Harmony Type</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Select
|
|
||||||
label="Harmony"
|
|
||||||
value={harmonyType}
|
|
||||||
onChange={(e) => setHarmonyType(e.target.value as HarmonyType)}
|
|
||||||
>
|
|
||||||
<option value="monochromatic">Monochromatic</option>
|
|
||||||
<option value="analogous">Analogous</option>
|
|
||||||
<option value="complementary">Complementary</option>
|
|
||||||
<option value="split-complementary">Split-Complementary</option>
|
|
||||||
<option value="triadic">Triadic</option>
|
|
||||||
<option value="tetradic">Tetradic (Square)</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{harmonyDescriptions[harmonyType]}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={generateHarmony}
|
|
||||||
disabled={paletteMutation.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{paletteMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Generating..
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Palette className="mr-2 h-4 w-4" />
|
|
||||||
Generate Harmony
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{palette.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">
|
|
||||||
Generated Palette ({palette.length} colors)
|
|
||||||
</h2>
|
|
||||||
<PaletteGrid colors={palette} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border rounded-lg bg-card">
|
|
||||||
<ExportMenu colors={palette} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{palette.length === 0 && (
|
|
||||||
<div className="p-12 border rounded-lg bg-card text-center text-muted-foreground">
|
|
||||||
<Palette className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
||||||
<p>Select a harmony type and click Generate to create your palette</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import Link from 'next/link';
|
|
||||||
import { Palette, Sparkles, GraduationCap } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function PalettesPage() {
|
|
||||||
const paletteTypes = [
|
|
||||||
{
|
|
||||||
title: 'Gradient Creator',
|
|
||||||
description: 'Create smooth color gradients with multiple stops and color spaces',
|
|
||||||
href: '/pastel/palettes/gradient',
|
|
||||||
icon: GraduationCap,
|
|
||||||
features: ['Multiple color stops', 'Various color spaces', 'Live preview'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Distinct Colors',
|
|
||||||
description: 'Generate visually distinct colors using simulated annealing algorithm',
|
|
||||||
href: '/pastel/palettes/distinct',
|
|
||||||
icon: Sparkles,
|
|
||||||
features: ['Perceptual distance', 'Configurable count', 'Quality metrics'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Harmony Palettes',
|
|
||||||
description: 'Create color palettes based on color theory and harmony rules',
|
|
||||||
href: '/pastel/palettes/harmony',
|
|
||||||
icon: Palette,
|
|
||||||
features: ['Color theory', 'Multiple schemes', 'Instant generation'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Palette Generation</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Create beautiful color palettes using various generation methods
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{paletteTypes.map((type) => {
|
|
||||||
const Icon = type.icon;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={type.href}
|
|
||||||
href={type.href}
|
|
||||||
className="p-6 border rounded-lg bg-card hover:border-primary transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<Icon className="h-8 w-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-xl font-semibold mb-2">{type.title}</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">{type.description}</p>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{type.features.map((feature) => (
|
|
||||||
<li key={feature} className="text-sm text-muted-foreground flex items-center">
|
|
||||||
<span className="mr-2">•</span>
|
|
||||||
{feature}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
16
app/(app)/qrcode/page.tsx
Normal file
16
app/(app)/qrcode/page.tsx
Normal 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
16
app/(app)/random/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
import MainConverter from '@/components/units/converter/MainConverter';
|
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() {
|
export default function UnitsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen py-12">
|
<AppPage>
|
||||||
<div className="max-w-7xl mx-auto px-8 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold mb-2">Units Converter</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Smart unit converter with 187 units across 23 categories
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<MainConverter />
|
<MainConverter />
|
||||||
</div>
|
</AppPage>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const dynamic = 'force-static';
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const fontsDir = path.join(process.cwd(), 'public/fonts/figlet-fonts');
|
const fontsDir = path.join(process.cwd(), 'public/fonts/ascii-fonts');
|
||||||
const files = fs.readdirSync(fontsDir);
|
const files = fs.readdirSync(fontsDir);
|
||||||
|
|
||||||
const fonts = files
|
const fonts = files
|
||||||
@@ -16,7 +16,7 @@ export async function GET() {
|
|||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
fileName: file,
|
fileName: file,
|
||||||
path: `/fonts/figlet-fonts/${file}`,
|
path: `/fonts/ascii-fonts/${file}`,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|||||||
103
app/globals.css
103
app/globals.css
@@ -1,9 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "tailwind-scrollbar";
|
||||||
|
|
||||||
@source "../components/*.{js,ts,jsx,tsx}";
|
@source "../components/*.{js,ts,jsx,tsx}";
|
||||||
@source "../components/ui/*.{js,ts,jsx,tsx}";
|
@source "../components/ui/*.{js,ts,jsx,tsx}";
|
||||||
@source "*.{js,ts,jsx,tsx}";
|
@source "*.{js,ts,jsx,tsx}";
|
||||||
|
|
||||||
|
@custom-variant hover (&:hover);
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
@@ -28,32 +31,6 @@
|
|||||||
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
|
||||||
/* Category colors for 23 unit types */
|
|
||||||
--color-category-angle: oklch(69.2% 0.154 237.7);
|
|
||||||
--color-category-apparent-power: oklch(64.8% 0.190 293.6);
|
|
||||||
--color-category-area: oklch(75.8% 0.159 70.5);
|
|
||||||
--color-category-current: oklch(75.8% 0.159 70.5);
|
|
||||||
--color-category-digital: oklch(72.3% 0.134 216.8);
|
|
||||||
--color-category-each: oklch(52.5% 0.033 257.5);
|
|
||||||
--color-category-energy: oklch(80.3% 0.162 97.3);
|
|
||||||
--color-category-frequency: oklch(66.8% 0.238 301.6);
|
|
||||||
--color-category-illuminance: oklch(78.3% 0.184 128.6);
|
|
||||||
--color-category-length: oklch(62.1% 0.214 255.5);
|
|
||||||
--color-category-mass: oklch(72.4% 0.159 165.1);
|
|
||||||
--color-category-pace: oklch(71.5% 0.145 192.2);
|
|
||||||
--color-category-parts-per: oklch(69.4% 0.224 350.3);
|
|
||||||
--color-category-power: oklch(62.8% 0.230 16.6);
|
|
||||||
--color-category-pressure: oklch(61.3% 0.218 281.3);
|
|
||||||
--color-category-reactive-energy: oklch(67.5% 0.276 320.6);
|
|
||||||
--color-category-reactive-power: oklch(74.5% 0.233 316.8);
|
|
||||||
--color-category-speed: oklch(72.4% 0.159 165.1);
|
|
||||||
--color-category-temperature: oklch(62.8% 0.257 29.2);
|
|
||||||
--color-category-tempo: oklch(70% 0.18 30);
|
|
||||||
--color-category-time: oklch(58.5% 0.238 293.1);
|
|
||||||
--color-category-voltage: oklch(75.5% 0.159 55.3);
|
|
||||||
--color-category-volume: oklch(64.8% 0.190 293.6);
|
|
||||||
--color-category-volume-flow-rate: oklch(77.9% 0.162 208.8);
|
|
||||||
|
|
||||||
/* Custom animations */
|
/* Custom animations */
|
||||||
--animate-gradient: gradient 8s linear infinite;
|
--animate-gradient: gradient 8s linear infinite;
|
||||||
--animate-float: float 3s ease-in-out infinite;
|
--animate-float: float 3s ease-in-out infinite;
|
||||||
@@ -107,16 +84,36 @@
|
|||||||
from { transform: scale(0.95); opacity: 0; }
|
from { transform: scale(0.95); opacity: 0; }
|
||||||
to { transform: scale(1); opacity: 1; }
|
to { transform: scale(1); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root, .dark {
|
@keyframes logoStamp {
|
||||||
color-scheme: dark;
|
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) */
|
/* CORPORATE DARK THEME (The Standard) */
|
||||||
--background: #0a0a0f;
|
--background: #0a0a0f;
|
||||||
--foreground: #ffffff;
|
--foreground: #ffffff;
|
||||||
--card: rgba(255, 255, 255, 0.03);
|
--card: rgba(255, 255, 255, 0.03);
|
||||||
--card-foreground: #ffffff;
|
--card-foreground: #ffffff;
|
||||||
--popover: #0f0f15;
|
--popover: #363665;
|
||||||
--popover-foreground: #ffffff;
|
--popover-foreground: #ffffff;
|
||||||
--primary: #8b5cf6;
|
--primary: #8b5cf6;
|
||||||
--primary-foreground: #ffffff;
|
--primary-foreground: #ffffff;
|
||||||
@@ -128,42 +125,18 @@
|
|||||||
--accent-foreground: #ffffff;
|
--accent-foreground: #ffffff;
|
||||||
--destructive: #ef4444;
|
--destructive: #ef4444;
|
||||||
--destructive-foreground: #ffffff;
|
--destructive-foreground: #ffffff;
|
||||||
--border: rgba(255, 255, 255, 0.08);
|
--border: rgba(255, 255, 255, 0.15);
|
||||||
--input: rgba(255, 255, 255, 0.05);
|
--input: rgba(255, 255, 255, 0.05);
|
||||||
--ring: rgba(139, 92, 246, 0.5);
|
--ring: rgba(139, 92, 246, 0.5);
|
||||||
--radius: 1rem;
|
--radius: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.light {
|
|
||||||
color-scheme: light;
|
|
||||||
/* LIGHT ADAPTATION (Keeping the "Glass" look) */
|
|
||||||
--background: oklch(98% 0.005 255);
|
|
||||||
--foreground: oklch(20% 0.04 255);
|
|
||||||
--card: rgba(255, 255, 255, 0.4);
|
|
||||||
--card-foreground: oklch(20% 0.04 255);
|
|
||||||
--popover: oklch(100% 0 0);
|
|
||||||
--popover-foreground: oklch(20% 0.04 255);
|
|
||||||
--primary: oklch(55% 0.22 270);
|
|
||||||
--primary-foreground: oklch(100% 0 0);
|
|
||||||
--secondary: rgba(0, 0, 0, 0.02);
|
|
||||||
--secondary-foreground: oklch(20% 0.04 255);
|
|
||||||
--muted: rgba(0, 0, 0, 0.02);
|
|
||||||
--muted-foreground: oklch(45% 0.04 255);
|
|
||||||
--accent: rgba(0, 0, 0, 0.03);
|
|
||||||
--accent-foreground: oklch(15% 0.05 255);
|
|
||||||
--destructive: oklch(60% 0.2 25);
|
|
||||||
--destructive-foreground: oklch(100% 0 0);
|
|
||||||
--border: rgba(0, 0, 0, 0.06);
|
|
||||||
--input: rgba(0, 0, 0, 0.01);
|
|
||||||
--ring: rgba(139, 92, 246, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent hover:scrollbar-thumb-primary/40;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
@@ -202,23 +175,3 @@ html {
|
|||||||
-webkit-backdrop-filter: blur(12px);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@utility gradient-brand {
|
|
||||||
background: linear-gradient(to right, #a78bfa, #f472b6, #22d3ee);
|
|
||||||
}
|
|
||||||
|
|||||||
BIN
app/icon.png
Normal file
BIN
app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
55
app/icon.svg
55
app/icon.svg
@@ -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 |
@@ -1,36 +1,20 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { Providers } from '@/components/providers/Providers';
|
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Kit - Your Creative Toolkit',
|
title: {
|
||||||
|
default: 'Kit - Your Creative Toolkit',
|
||||||
|
template: '%s | Kit',
|
||||||
|
},
|
||||||
description: 'A curated collection of creative and utility tools for developers and creators. Features file conversion, image editing, and color manipulation.',
|
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'],
|
keywords: ['tools', 'utilities', 'file converter', 'image editor', 'color palette', 'creative toolkit', 'convert', 'paint', 'color', 'open source'],
|
||||||
authors: [{ name: 'pivoine.art' }],
|
metadataBase: new URL(siteUrl),
|
||||||
creator: 'pivoine.art',
|
icons: {
|
||||||
publisher: 'pivoine.art',
|
icon: '/icon.png',
|
||||||
metadataBase: new URL('https://kit.pivoine.art'),
|
shortcut: '/icon.png',
|
||||||
openGraph: {
|
apple: '/icon.png',
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
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'],
|
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
@@ -50,33 +34,23 @@ export default function RootLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const umamiScript = process.env.UMAMI_SCRIPT;
|
||||||
|
const umamiId = process.env.UMAMI_ID;
|
||||||
|
const isProd = process.env.NODE_ENV === 'production';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark" suppressHydrationWarning>
|
<html lang="en" className="scrollbar-thin scrollbar-thumb-primary/20 scrollbar-track-transparent">
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="preconnect" href="https://kit.pivoine.art" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<script
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
dangerouslySetInnerHTML={{
|
<meta name="apple-mobile-web-app-title" content="Kit" />
|
||||||
__html: `
|
<meta name="format-detection" content="telephone=no" />
|
||||||
(function() {
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
try {
|
<meta name="theme-color" content="#8b5cf6" />
|
||||||
var theme = localStorage.getItem('theme');
|
{isProd && umamiScript && umamiId && (
|
||||||
var isLanding = window.location.pathname === '/';
|
<script defer src={umamiScript} data-website-id={umamiId}></script>
|
||||||
if (isLanding) {
|
)}
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
document.documentElement.classList.remove('light');
|
|
||||||
} else if (theme === 'light' || (!theme && window.matchMedia('(prefers-color-scheme: light)').matches)) {
|
|
||||||
document.documentElement.classList.add('light');
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
document.documentElement.classList.remove('light');
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
})();
|
|
||||||
`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</head>
|
</head>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
29
app/manifest.ts
Normal file
29
app/manifest.ts
Normal 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
59
app/not-found.tsx
Normal 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're looking for doesn'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
app/page.tsx
13
app/page.tsx
@@ -1,24 +1,13 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import AnimatedBackground from '@/components/AnimatedBackground';
|
import AnimatedBackground from '@/components/AnimatedBackground';
|
||||||
import Hero from '@/components/Hero';
|
import Hero from '@/components/Hero';
|
||||||
import Stats from '@/components/Stats';
|
import Stats from '@/components/Stats';
|
||||||
import ToolsGrid from '@/components/ToolsGrid';
|
import ToolsGrid from '@/components/ToolsGrid';
|
||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
import BackToTop from '@/components/BackToTop';
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
useEffect(() => {
|
|
||||||
// Force dark mode on html element for the landing page
|
|
||||||
document.documentElement.classList.remove('light');
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative min-h-screen dark text-foreground">
|
<main className="relative min-h-screen text-foreground">
|
||||||
<AnimatedBackground />
|
<AnimatedBackground />
|
||||||
<BackToTop />
|
|
||||||
<Hero />
|
<Hero />
|
||||||
<Stats />
|
<Stats />
|
||||||
<ToolsGrid />
|
<ToolsGrid />
|
||||||
|
|||||||
23
components.json
Normal file
23
components.json
Normal 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": {}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ export default function AnimatedBackground() {
|
|||||||
<div className="fixed inset-0 -z-10 overflow-hidden bg-background transition-colors duration-500">
|
<div className="fixed inset-0 -z-10 overflow-hidden bg-background transition-colors duration-500">
|
||||||
{/* Animated gradient background */}
|
{/* Animated gradient background */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-[0.08] dark:opacity-50"
|
className="absolute inset-0 opacity-50"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #4facfe 75%, #667eea 100%)',
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #4facfe 75%, #667eea 100%)',
|
||||||
backgroundSize: '400% 400%',
|
backgroundSize: '400% 400%',
|
||||||
@@ -15,7 +15,7 @@ export default function AnimatedBackground() {
|
|||||||
|
|
||||||
{/* Signature Grid pattern overlay - Original landing page specification */}
|
{/* Signature Grid pattern overlay - Original landing page specification */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-[0.05] dark:opacity-10"
|
className="absolute inset-0 opacity-10"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `
|
backgroundImage: `
|
||||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||||
@@ -26,9 +26,9 @@ export default function AnimatedBackground() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Floating orbs */}
|
{/* Floating orbs */}
|
||||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply dark:mix-blend-normal filter blur-3xl opacity-[0.03] dark:opacity-20 animate-float" />
|
<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-multiply dark:mix-blend-normal filter blur-3xl opacity-[0.03] dark:opacity-20 animate-float" style={{ animationDelay: '2s' }} />
|
<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-multiply dark:mix-blend-normal filter blur-3xl opacity-[0.03] dark:opacity-20 animate-float" style={{ animationDelay: '4s' }} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
105
components/AppIcons.tsx
Normal file
105
components/AppIcons.tsx
Normal 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>
|
||||||
|
);
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion, useScroll, useSpring } from 'framer-motion';
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
export default function BackToTop() {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
const { scrollYProgress } = useScroll();
|
|
||||||
const scaleX = useSpring(scrollYProgress, {
|
|
||||||
stiffness: 100,
|
|
||||||
damping: 30,
|
|
||||||
restDelta: 0.001,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const toggleVisibility = () => {
|
|
||||||
if (window.pageYOffset > 300) {
|
|
||||||
setIsVisible(true);
|
|
||||||
} else {
|
|
||||||
setIsVisible(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('scroll', toggleVisibility);
|
|
||||||
return () => window.removeEventListener('scroll', toggleVisibility);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scrollToTop = () => {
|
|
||||||
window.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Progress bar */}
|
|
||||||
<motion.div
|
|
||||||
className="fixed top-0 left-0 right-0 h-1 bg-gradient-to-r from-purple-500 to-cyan-500 transform origin-left z-50"
|
|
||||||
style={{ scaleX }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Back to top button */}
|
|
||||||
{isVisible && (
|
|
||||||
<motion.button
|
|
||||||
onClick={scrollToTop}
|
|
||||||
className="fixed bottom-8 right-8 p-4 rounded-full glass hover:bg-accent/50 text-purple-400 hover:text-purple-300 transition-colors shadow-lg z-40 group"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: 20 }}
|
|
||||||
whileHover={{ scale: 1.1 }}
|
|
||||||
whileTap={{ scale: 0.9 }}
|
|
||||||
aria-label="Back to top"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-6 h-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M5 10l7-7m0 0l7 7m-7-7v18"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Tooltip */}
|
|
||||||
<span className="absolute bottom-full right-0 mb-2 px-3 py-1 text-xs text-white bg-gray-900 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
|
|
||||||
Back to top
|
|
||||||
</span>
|
|
||||||
</motion.button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +1,35 @@
|
|||||||
'use client';
|
import { GitFork, Heart } from 'lucide-react';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="relative py-12 px-4">
|
<footer className="relative py-10 px-6">
|
||||||
<div className="max-w-6xl mx-auto border-t border-border pt-12">
|
<div className="max-w-5xl mx-auto border-t border-white/[0.06] pt-8">
|
||||||
<motion.div
|
<div className="flex items-center justify-between">
|
||||||
className="flex flex-col md:flex-row items-center justify-between gap-6"
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground/35 font-mono">
|
||||||
initial={{ opacity: 0 }}
|
<span>© {currentYear} Kit</span>
|
||||||
whileInView={{ opacity: 1 }}
|
<Heart className="w-2.5 h-2.5 text-primary/60 shrink-0 animate-pulse" fill="currentColor" />
|
||||||
viewport={{ once: true }}
|
<a
|
||||||
transition={{ duration: 0.6 }}
|
href="https://pivoine.art"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:text-foreground/60 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
{/* Brand Section */}
|
Valknar
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-primary/50 bg-primary/5">
|
</a>
|
||||||
<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-muted-foreground/30">•</span>
|
|
||||||
<span className="text-base text-primary font-medium">Open Source</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Copyright - centered */}
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
© {currentYear} Kit. Built with Next.js 16 & Tailwind CSS 4
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dev Link */}
|
|
||||||
<a
|
<a
|
||||||
href="https://dev.pivoine.art/valknar/kit-ui"
|
href="https://dev.pivoine.art/valknar/kit-ui"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="group flex items-center gap-3 px-4 py-2 rounded-full border border-border hover:border-primary transition-all duration-300 bg-card/50"
|
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-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
<GitFork className="w-3.5 h-3.5" />
|
||||||
<line x1="6" y1="3" x2="6" y2="15" strokeLinecap="round" />
|
<span className="hidden sm:inline">Source</span>
|
||||||
<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-sm text-muted-foreground group-hover:text-primary transition-colors font-medium">
|
|
||||||
View on Dev
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
</motion.div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,113 +1,74 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { ArrowDown } from 'lucide-react';
|
||||||
import Logo from './Logo';
|
import Logo from './Logo';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const MotionLink = motion.create(Link);
|
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
|
const scrollToTools = () => {
|
||||||
|
document.getElementById('tools')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative min-h-screen flex flex-col items-center justify-center px-4 py-20">
|
<section className="relative min-h-screen flex flex-col items-center justify-center px-6 py-24">
|
||||||
<div className="max-w-6xl mx-auto text-center">
|
<div className="flex flex-col items-center text-center max-w-2xl mx-auto">
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<motion.div
|
<Logo size={72} />
|
||||||
className="mb-8 flex justify-center"
|
|
||||||
initial={{ opacity: 0, y: -50 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8 }}
|
|
||||||
>
|
|
||||||
<Logo size={160} />
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Main heading */}
|
{/* Badge */}
|
||||||
<motion.h1
|
<div
|
||||||
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"
|
className="mt-8 flex items-center gap-2 px-3 py-1.5 glass rounded-full border border-white/[0.06]"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
style={{ animation: 'slideUp 0.5s ease-out 0.2s both' }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
|
||||||
>
|
>
|
||||||
Kit
|
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse shrink-0" />
|
||||||
</motion.h1>
|
<span className="text-[10px] font-mono text-muted-foreground/55 tracking-widest uppercase">
|
||||||
|
Browser-first
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Subtitle */}
|
{/* Title */}
|
||||||
<motion.p
|
<h1
|
||||||
className="text-xl md:text-2xl text-muted-foreground mb-4 max-w-2xl mx-auto"
|
className="mt-6 font-bold tracking-tight leading-none"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
style={{ animation: 'slideUp 0.5s ease-out 0.3s both' }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
|
||||||
>
|
>
|
||||||
Your Creative Toolkit
|
<span className="text-6xl md:text-8xl text-foreground">Kit</span>
|
||||||
</motion.p>
|
<span className="text-6xl md:text-8xl text-primary">.</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<motion.p
|
<p
|
||||||
className="text-base md:text-lg text-muted-foreground/80 mb-12 max-w-xl mx-auto"
|
className="mt-6 text-sm text-muted-foreground/55 max-w-xs leading-relaxed"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
style={{ animation: 'slideUp 0.5s ease-out 0.4s both' }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.6 }}
|
|
||||||
>
|
>
|
||||||
A curated collection of creative and utility tools for developers and creators
|
A curated collection of browser-based tools for developers and creators.
|
||||||
Simple, powerful, and always at your fingertips
|
Everything runs locally — no data leaves your machine.
|
||||||
</motion.p>
|
</p>
|
||||||
|
|
||||||
{/* CTA Buttons */}
|
{/* CTA */}
|
||||||
<motion.div
|
<div
|
||||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-16"
|
className="mt-8"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
style={{ animation: 'slideUp 0.5s ease-out 0.5s both' }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.8, delay: 0.8 }}
|
|
||||||
>
|
>
|
||||||
<MotionLink
|
<button
|
||||||
href="#tools"
|
onClick={scrollToTools}
|
||||||
className="group relative px-8 py-4 rounded-full bg-gradient-to-r from-purple-500 to-cyan-500 text-white font-semibold shadow-lg overflow-hidden"
|
className="flex items-center gap-2 px-6 py-2.5 rounded-xl border border-primary/30 bg-primary/[0.07] hover:border-primary/55 hover:bg-primary/[0.13] text-sm font-medium text-foreground/70 hover:text-foreground transition-all duration-200"
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
>
|
||||||
<span className="relative z-10">Explore Tools</span>
|
Explore Tools
|
||||||
<motion.div
|
<ArrowDown className="w-3.5 h-3.5 text-primary" />
|
||||||
className="absolute inset-0 bg-gradient-to-r from-purple-600 to-cyan-600"
|
</button>
|
||||||
initial={{ x: '100%' }}
|
</div>
|
||||||
whileHover={{ x: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
/>
|
|
||||||
</MotionLink>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Scroll indicator */}
|
{/* Scroll indicator */}
|
||||||
<MotionLink
|
<button
|
||||||
href="#tools"
|
onClick={scrollToTools}
|
||||||
className="flex flex-col items-center gap-2 cursor-pointer group"
|
className="mt-24 flex flex-col items-center gap-2 group"
|
||||||
initial={{ opacity: 0 }}
|
style={{ animation: 'fadeIn 0.5s ease-out 0.9s both' }}
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ duration: 0.8, delay: 1 }}
|
|
||||||
>
|
>
|
||||||
<span className="text-base text-gray-500 group-hover:text-gray-400 transition-colors">Scroll to explore</span>
|
<div className="w-px h-8 bg-gradient-to-b from-transparent via-primary/30 to-primary/60 group-hover:via-primary/50 group-hover:to-primary transition-colors duration-300" />
|
||||||
<motion.div
|
<span className="text-[9px] font-mono text-muted-foreground/25 uppercase tracking-widest group-hover:text-muted-foreground/50 transition-colors">
|
||||||
className="w-6 h-10 border-2 border-gray-600 group-hover:border-purple-400 rounded-full p-1 transition-colors"
|
Scroll
|
||||||
animate={{ y: [0, 10, 0] }}
|
</span>
|
||||||
transition={{ duration: 1.5, repeat: Infinity }}
|
</button>
|
||||||
>
|
|
||||||
<div className="w-1 h-2 bg-gradient-to-b from-purple-400 to-cyan-400 rounded-full mx-auto" />
|
|
||||||
</motion.div>
|
|
||||||
</MotionLink>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,28 +1,20 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
export default function Logo({ className = '', size = 120 }: { className?: string; size?: number }) {
|
export default function Logo({ className = '', size = 120 }: { className?: string; size?: number }) {
|
||||||
return (
|
return (
|
||||||
<motion.svg
|
<svg
|
||||||
width={size}
|
width={size}
|
||||||
height={size}
|
height={size}
|
||||||
viewBox="0 0 200 200"
|
viewBox="0 0 64 64"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className={className}
|
className={className}
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
style={{ animation: 'logoStamp 0.65s cubic-bezier(0.22, 1, 0.36, 1) both' }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.6, ease: 'easeOut' }}
|
|
||||||
>
|
>
|
||||||
{/* Wrench (Lucide) - vertical */}
|
{/* Wrench (Lucide) - vertical */}
|
||||||
<motion.g
|
<g
|
||||||
transform="translate(100, 100) rotate(0) scale(5) translate(-12, -12)"
|
transform="translate(32, 32) rotate(0) scale(3.15) translate(-12.5, -11.5)"
|
||||||
initial={{ pathLength: 0, opacity: 0 }}
|
style={{ animation: 'pathFlicker 0.9s ease-out 0.15s both' }}
|
||||||
animate={{ pathLength: 1, opacity: 1 }}
|
|
||||||
transition={{ duration: 1.2, ease: 'easeInOut' }}
|
|
||||||
>
|
>
|
||||||
<motion.path
|
<path
|
||||||
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||||
stroke="url(#wrenchGradient)"
|
stroke="url(#wrenchGradient)"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
@@ -31,16 +23,14 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
|
|||||||
fill="none"
|
fill="none"
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
</motion.g>
|
</g>
|
||||||
|
|
||||||
{/* Brush (Lucide) - horizontal flipped */}
|
{/* Brush (Lucide) - horizontal flipped */}
|
||||||
<motion.g
|
<g
|
||||||
transform="translate(100, 97) rotate(90) scale(5) translate(-12, -12)"
|
transform="translate(32, 30) rotate(90) scale(3.025) translate(-11.25, -11)"
|
||||||
initial={{ pathLength: 0, opacity: 0 }}
|
style={{ animation: 'pathFlicker 0.9s ease-out 0.15s both' }}
|
||||||
animate={{ pathLength: 1, opacity: 1 }}
|
|
||||||
transition={{ duration: 1.2, delay: 0.3, ease: 'easeInOut' }}
|
|
||||||
>
|
>
|
||||||
<motion.path
|
<path
|
||||||
d="m11 10l3 3m-7.5 8A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z"
|
d="m11 10l3 3m-7.5 8A3.5 3.5 0 1 0 3 17.5a2.62 2.62 0 0 1-.708 1.792A1 1 0 0 0 3 21z"
|
||||||
stroke="url(#brushGradient)"
|
stroke="url(#brushGradient)"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
@@ -49,7 +39,7 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
|
|||||||
fill="none"
|
fill="none"
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
<motion.path
|
<path
|
||||||
d="M9.969 17.031L21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"
|
d="M9.969 17.031L21.378 5.624a1 1 0 0 0-3.002-3.002L6.967 14.031"
|
||||||
stroke="url(#brushGradient)"
|
stroke="url(#brushGradient)"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
@@ -58,7 +48,7 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
|
|||||||
fill="none"
|
fill="none"
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
</motion.g>
|
</g>
|
||||||
|
|
||||||
{/* Gradient definitions */}
|
{/* Gradient definitions */}
|
||||||
<defs>
|
<defs>
|
||||||
@@ -71,6 +61,6 @@ export default function Logo({ className = '', size = 120 }: { className?: strin
|
|||||||
<stop offset="100%" stopColor="#ec4899" />
|
<stop offset="100%" stopColor="#ec4899" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
</motion.svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,39 @@
|
|||||||
'use client';
|
import { tools } from '@/lib/tools';
|
||||||
|
import { Box, Code2, Globe } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{ value: tools.length, label: 'Tools available', icon: Box },
|
||||||
number: '3',
|
{ value: '100%', label: 'Open source', icon: Code2 },
|
||||||
label: 'Tools',
|
{ value: '100%', label: 'Browser-first', icon: Globe },
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: '100%',
|
|
||||||
label: 'Open Source',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
number: '∞',
|
|
||||||
label: 'Privacy First',
|
|
||||||
icon: (
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Stats() {
|
export default function Stats() {
|
||||||
return (
|
return (
|
||||||
<section className="relative py-16 px-4">
|
<section className="relative py-4 px-6">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{stats.map((stat, index) => (
|
{stats.map((stat, i) => {
|
||||||
<motion.div
|
const Icon = stat.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
key={stat.label}
|
key={stat.label}
|
||||||
className="glass rounded-2xl p-8 text-center"
|
className="glass rounded-2xl p-5 flex items-center gap-4 border border-white/[0.06]"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
style={{ animation: `slideUp 0.5s ease-out ${0.1 + i * 0.1}s both` }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
|
||||||
whileHover={{ y: -5 }}
|
|
||||||
>
|
>
|
||||||
<motion.div
|
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/15 flex items-center justify-center shrink-0">
|
||||||
className="inline-flex items-center justify-center w-12 h-12 mb-4 rounded-xl bg-primary/10 text-primary"
|
<Icon className="w-4.5 h-4.5 text-primary" />
|
||||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300 }}
|
|
||||||
>
|
|
||||||
{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>
|
</div>
|
||||||
<div className="text-muted-foreground text-base font-medium">
|
<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}
|
{stat.label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,102 +1,64 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { ReactNode } from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
const MotionLink = motion.create(Link);
|
import { ElementType } from 'react';
|
||||||
|
|
||||||
interface ToolCardProps {
|
interface ToolCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: ReactNode;
|
icon: ElementType;
|
||||||
url: string;
|
url: string;
|
||||||
gradient: string;
|
|
||||||
accentColor: string;
|
|
||||||
index: number;
|
index: number;
|
||||||
badges?: string[];
|
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 (
|
return (
|
||||||
<MotionLink
|
<Link
|
||||||
href={url}
|
href={url}
|
||||||
className="group relative block"
|
className="group relative glass rounded-2xl p-6 flex flex-col h-full transition-all duration-300 border border-white/[0.06] hover:border-primary/35 hover:shadow-[0_12px_48px_rgba(139,92,246,0.11)] overflow-hidden"
|
||||||
initial={{ opacity: 0, y: 50 }}
|
style={{ animation: `slideUp 0.5s ease-out ${0.05 * index}s both` }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
|
||||||
whileHover={{ y: -10 }}
|
|
||||||
>
|
>
|
||||||
<div className="glass relative overflow-hidden rounded-2xl p-8 h-full transition-all duration-300 group-hover:shadow-2xl group-hover:bg-card/80">
|
{/* Top shimmer accent on hover */}
|
||||||
{/* Gradient overlay 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" />
|
||||||
<div
|
|
||||||
className={`absolute inset-0 opacity-0 group-hover:opacity-10 dark:group-hover:opacity-15 transition-opacity duration-300 ${gradient}`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Glow effect */}
|
{/* Radial glow on hover */}
|
||||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl -z-10">
|
<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" />
|
||||||
<div className={`w-full h-full ${gradient} opacity-20 dark:opacity-30`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<motion.div
|
<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)]">
|
||||||
className="mb-6 flex justify-center"
|
<Icon className="w-5 h-5 text-primary" />
|
||||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300 }}
|
|
||||||
>
|
|
||||||
<div className={`p-4 rounded-xl ${gradient} shadow-lg shadow-black/10`}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h3
|
<h3 className="text-base font-semibold text-foreground/80 group-hover:text-foreground transition-colors duration-200 mb-2 leading-snug">
|
||||||
className="text-2xl font-bold mb-3 text-foreground transition-all duration-300 group-hover:text-primary"
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Badges */}
|
{/* Description */}
|
||||||
{badges && badges.length > 0 && (
|
<p className="text-[13px] text-muted-foreground/50 leading-relaxed flex-1 mb-5">
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Footer: badges + arrow */}
|
||||||
|
<div className="flex items-end justify-between gap-2">
|
||||||
|
{badges && badges.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
{badges.map((badge) => (
|
{badges.map((badge) => (
|
||||||
<span
|
<span
|
||||||
key={badge}
|
key={badge}
|
||||||
className="text-xs px-2 py-1 rounded-full bg-primary/5 border border-primary/10 text-muted-foreground font-medium"
|
className="text-[9px] font-mono px-1.5 py-0.5 rounded-md bg-primary/[0.07] border border-primary/20 text-primary/55 transition-colors duration-200 group-hover:border-primary/35 group-hover:text-primary/75"
|
||||||
>
|
>
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
)}
|
)}
|
||||||
|
<div className="w-7 h-7 rounded-xl glass border border-white/[0.06] flex items-center justify-center shrink-0 transition-all duration-200 group-hover:border-primary/30 group-hover:bg-primary/10">
|
||||||
{/* Description */}
|
<ArrowRight className="w-3.5 h-3.5 text-muted-foreground/30 group-hover:text-primary group-hover:translate-x-0.5 transition-all duration-200" />
|
||||||
<p className="text-muted-foreground group-hover:text-foreground/80 transition-colors duration-300">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Arrow icon */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute bottom-8 right-8 text-muted-foreground group-hover:text-primary transition-colors duration-300"
|
|
||||||
initial={{ x: 0 }}
|
|
||||||
whileHover={{ x: 5 }}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-6 h-6"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
</div>
|
||||||
</MotionLink>
|
</div>
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,88 +1,36 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import ToolCard from './ToolCard';
|
import ToolCard from './ToolCard';
|
||||||
|
import { tools } from '@/lib/tools';
|
||||||
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: '/pastel',
|
|
||||||
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: '/units',
|
|
||||||
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: '/figlet',
|
|
||||||
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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ToolsGrid() {
|
export default function ToolsGrid() {
|
||||||
return (
|
return (
|
||||||
<section id="tools" className="relative py-20 px-4">
|
<section id="tools" className="relative py-16 px-6">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
|
|
||||||
{/* Section heading */}
|
{/* Section heading */}
|
||||||
<motion.div
|
<div
|
||||||
className="text-center mb-16"
|
className="mb-10"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
style={{ animation: 'fadeIn 0.5s ease-out both' }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ duration: 0.6 }}
|
|
||||||
>
|
>
|
||||||
<h2 className="text-4xl md:text-5xl font-bold mb-4 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">
|
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-foreground">
|
||||||
Available Tools
|
Available{' '}
|
||||||
|
<span className="bg-gradient-to-r from-primary via-violet-400 to-pink-400 bg-clip-text text-transparent">
|
||||||
|
Tools
|
||||||
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
<p className="text-sm text-muted-foreground/40 mt-2">
|
||||||
Explore our collection of carefully crafted tools designed to boost your productivity and creativity
|
{tools.length} tools — everything runs in your browser, no data leaves your machine
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Tools grid */}
|
{/* Tools grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{tools.map((tool, index) => (
|
{tools.map((tool, index) => (
|
||||||
<ToolCard
|
<ToolCard
|
||||||
key={tool.title}
|
key={tool.href}
|
||||||
title={tool.title}
|
title={tool.title}
|
||||||
description={tool.description}
|
description={tool.summary}
|
||||||
icon={tool.icon}
|
icon={tool.icon}
|
||||||
url={tool.url}
|
url={tool.href}
|
||||||
gradient={tool.gradient}
|
|
||||||
accentColor={tool.accentColor}
|
|
||||||
badges={tool.badges}
|
badges={tool.badges}
|
||||||
index={index}
|
index={index}
|
||||||
/>
|
/>
|
||||||
|
|||||||
140
components/animate/AnimationEditor.tsx
Normal file
140
components/animate/AnimationEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
components/animate/AnimationPreview.tsx
Normal file
160
components/animate/AnimationPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
components/animate/AnimationSettings.tsx
Normal file
216
components/animate/AnimationSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/animate/ExportPanel.tsx
Normal file
43
components/animate/ExportPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
components/animate/KeyframeProperties.tsx
Normal file
134
components/animate/KeyframeProperties.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
components/animate/KeyframeTimeline.tsx
Normal file
141
components/animate/KeyframeTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
components/animate/PresetLibrary.tsx
Normal file
83
components/animate/PresetLibrary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
components/ascii/ASCIIConverter.tsx
Normal file
172
components/ascii/ASCIIConverter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
components/ascii/FontPreview.tsx
Normal file
276
components/ascii/FontPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
components/ascii/FontSelector.tsx
Normal file
210
components/ascii/FontSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
components/ascii/TextInput.tsx
Normal file
31
components/ascii/TextInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
components/calculate/Calculator.tsx
Normal file
51
components/calculate/Calculator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
328
components/calculate/ExpressionPanel.tsx
Normal file
328
components/calculate/ExpressionPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
components/calculate/GraphCanvas.tsx
Normal file
370
components/calculate/GraphCanvas.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
components/calculate/GraphPanel.tsx
Normal file
112
components/calculate/GraphPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
components/color/ColorInfo.tsx
Normal file
81
components/color/ColorInfo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
357
components/color/ColorManipulation.tsx
Normal file
357
components/color/ColorManipulation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
components/color/ColorPicker.tsx
Normal file
33
components/color/ColorPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
components/color/ColorSwatch.tsx
Normal file
55
components/color/ColorSwatch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
components/color/ExportMenu.tsx
Normal file
121
components/color/ExportMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
components/color/ManipulationPanel.tsx
Normal file
146
components/color/ManipulationPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,16 +19,12 @@ export function PaletteGrid({ colors, onColorClick, className }: PaletteGridProp
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn('grid grid-cols-4 sm:grid-cols-5 gap-2', className)}>
|
||||||
className={cn(
|
|
||||||
'grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{colors.map((color, index) => (
|
{colors.map((color, index) => (
|
||||||
<ColorSwatch
|
<ColorSwatch
|
||||||
key={`${color}-${index}`}
|
key={`${color}-${index}`}
|
||||||
color={color}
|
color={color}
|
||||||
|
size="sm"
|
||||||
onClick={onColorClick ? () => onColorClick(color) : undefined}
|
onClick={onColorClick ? () => onColorClick(color) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
372
components/cron/CronEditor.tsx
Normal file
372
components/cron/CronEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
components/cron/CronFieldEditor.tsx
Normal file
262
components/cron/CronFieldEditor.tsx
Normal 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: '9–17', 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: '1–7', 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
components/cron/CronPresets.tsx
Normal file
91
components/cron/CronPresets.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
components/favicon/FaviconFileUpload.tsx
Normal file
126
components/favicon/FaviconFileUpload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
275
components/favicon/FaviconGenerator.tsx
Normal file
275
components/favicon/FaviconGenerator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import { TextInput } from './TextInput';
|
|
||||||
import { FontPreview } from './FontPreview';
|
|
||||||
import { FontSelector } from './FontSelector';
|
|
||||||
import { textToAscii } from '@/lib/figlet/figletService';
|
|
||||||
import { getFontList } from '@/lib/figlet/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 { FigletFont } from '@/types/figlet';
|
|
||||||
|
|
||||||
export function FigletConverter() {
|
|
||||||
const [text, setText] = React.useState('Figlet UI');
|
|
||||||
const [selectedFont, setSelectedFont] = React.useState('Standard');
|
|
||||||
const [asciiArt, setAsciiArt] = React.useState('');
|
|
||||||
const [fonts, setFonts] = React.useState<FigletFont[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
|
||||||
|
|
||||||
// Load fonts and check URL params on mount
|
|
||||||
React.useEffect(() => {
|
|
||||||
getFontList().then(setFonts);
|
|
||||||
|
|
||||||
// Check for URL parameters
|
|
||||||
const urlState = decodeFromUrl();
|
|
||||||
if (urlState) {
|
|
||||||
if (urlState.text) setText(urlState.text);
|
|
||||||
if (urlState.font) setSelectedFont(urlState.font);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Generate ASCII art
|
|
||||||
const generateAsciiArt = React.useCallback(
|
|
||||||
debounce(async (inputText: string, fontName: string) => {
|
|
||||||
if (!inputText) {
|
|
||||||
setAsciiArt('');
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await textToAscii(inputText, fontName);
|
|
||||||
setAsciiArt(result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating ASCII art:', error);
|
|
||||||
setAsciiArt('Error generating ASCII art. Please try a different font.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, 300),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trigger generation when text or font changes
|
|
||||||
React.useEffect(() => {
|
|
||||||
generateAsciiArt(text, selectedFont);
|
|
||||||
// Track recent fonts
|
|
||||||
if (selectedFont) {
|
|
||||||
addRecentFont(selectedFont);
|
|
||||||
}
|
|
||||||
// Update URL
|
|
||||||
updateUrl(text, selectedFont);
|
|
||||||
}, [text, selectedFont, generateAsciiArt]);
|
|
||||||
|
|
||||||
// Copy to clipboard
|
|
||||||
const handleCopy = async () => {
|
|
||||||
if (!asciiArt) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(asciiArt);
|
|
||||||
toast.success('Copied to clipboard!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to copy:', error);
|
|
||||||
toast.error('Failed to copy');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Download as text file
|
|
||||||
const handleDownload = () => {
|
|
||||||
if (!asciiArt) return;
|
|
||||||
|
|
||||||
const blob = new Blob([asciiArt], { type: 'text/plain' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `figlet-${selectedFont}-${Date.now()}.txt`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Share (copy URL to clipboard)
|
|
||||||
const handleShare = async () => {
|
|
||||||
const shareUrl = getShareableUrl(text, selectedFont);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
|
||||||
toast.success('Shareable URL copied!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to copy URL:', error);
|
|
||||||
toast.error('Failed to copy URL');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Random font
|
|
||||||
const handleRandomFont = () => {
|
|
||||||
if (fonts.length === 0) return;
|
|
||||||
const randomIndex = Math.floor(Math.random() * fonts.length);
|
|
||||||
setSelectedFont(fonts[randomIndex].name);
|
|
||||||
toast.info(`Random font: ${fonts[randomIndex].name}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch lg:max-h-[800px]">
|
|
||||||
{/* Left Column - Input and Preview */}
|
|
||||||
<div className="lg:col-span-2 space-y-6 overflow-y-auto pr-2 custom-scrollbar">
|
|
||||||
<TextInput
|
|
||||||
value={text}
|
|
||||||
onChange={setText}
|
|
||||||
placeholder="Type your text here..."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FontPreview
|
|
||||||
text={asciiArt}
|
|
||||||
font={selectedFont}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onCopy={handleCopy}
|
|
||||||
onDownload={handleDownload}
|
|
||||||
onShare={handleShare}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column - Font Selector */}
|
|
||||||
<div className="lg:col-span-1 h-[500px] lg:h-auto relative">
|
|
||||||
<div className="lg:absolute lg:inset-0 h-full">
|
|
||||||
<FontSelector
|
|
||||||
fonts={fonts}
|
|
||||||
selectedFont={selectedFont}
|
|
||||||
onSelectFont={setSelectedFont}
|
|
||||||
onRandomFont={handleRandomFont}
|
|
||||||
className="h-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import { toPng } from 'html-to-image';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Skeleton } from '@/components/ui/Skeleton';
|
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
|
||||||
import { Copy, Download, Share2, Image as ImageIcon, AlignLeft, AlignCenter, AlignRight, Type } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export interface FontPreviewProps {
|
|
||||||
text: string;
|
|
||||||
font?: string;
|
|
||||||
isLoading?: boolean;
|
|
||||||
onCopy?: () => void;
|
|
||||||
onDownload?: () => void;
|
|
||||||
onShare?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TextAlign = 'left' | 'center' | 'right';
|
|
||||||
|
|
||||||
export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare, className }: FontPreviewProps) {
|
|
||||||
const lineCount = text ? text.split('\n').length : 0;
|
|
||||||
const charCount = text ? text.length : 0;
|
|
||||||
const previewRef = React.useRef<HTMLDivElement>(null);
|
|
||||||
const [textAlign, setTextAlign] = React.useState<TextAlign>('left');
|
|
||||||
const [fontSize, setFontSize] = React.useState<'xs' | 'sm' | 'base'>('sm');
|
|
||||||
|
|
||||||
const handleExportPNG = async () => {
|
|
||||||
if (!previewRef.current || !text) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dataUrl = await toPng(previewRef.current, {
|
|
||||||
backgroundColor: getComputedStyle(previewRef.current).backgroundColor,
|
|
||||||
pixelRatio: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.download = `figlet-${font || 'export'}-${Date.now()}.png`;
|
|
||||||
link.href = dataUrl;
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
toast.success('Exported as PNG!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to export PNG:', error);
|
|
||||||
toast.error('Failed to export PNG');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Card className={cn('relative', className)}>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="space-y-3 mb-4">
|
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="text-sm font-medium">Preview</h3>
|
|
||||||
{font && (
|
|
||||||
<span className="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-md font-mono">
|
|
||||||
{font}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{onCopy && (
|
|
||||||
<Button variant="outline" size="sm" onClick={onCopy}>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
Copy
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{onShare && (
|
|
||||||
<Button variant="outline" size="sm" onClick={onShare} title="Copy shareable URL">
|
|
||||||
<Share2 className="h-4 w-4" />
|
|
||||||
Share
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="outline" size="sm" onClick={handleExportPNG} title="Export as PNG">
|
|
||||||
<ImageIcon className="h-4 w-4" />
|
|
||||||
PNG
|
|
||||||
</Button>
|
|
||||||
{onDownload && (
|
|
||||||
<Button variant="outline" size="sm" onClick={onDownload}>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
TXT
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<div className="flex items-center gap-1 border rounded-md p-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setTextAlign('left')}
|
|
||||||
className={cn(
|
|
||||||
'p-1.5 rounded transition-colors',
|
|
||||||
textAlign === 'left' ? 'bg-accent' : 'hover:bg-accent/50'
|
|
||||||
)}
|
|
||||||
title="Align left"
|
|
||||||
>
|
|
||||||
<AlignLeft className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setTextAlign('center')}
|
|
||||||
className={cn(
|
|
||||||
'p-1.5 rounded transition-colors',
|
|
||||||
textAlign === 'center' ? 'bg-accent' : 'hover:bg-accent/50'
|
|
||||||
)}
|
|
||||||
title="Align center"
|
|
||||||
>
|
|
||||||
<AlignCenter className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setTextAlign('right')}
|
|
||||||
className={cn(
|
|
||||||
'p-1.5 rounded transition-colors',
|
|
||||||
textAlign === 'right' ? 'bg-accent' : 'hover:bg-accent/50'
|
|
||||||
)}
|
|
||||||
title="Align right"
|
|
||||||
>
|
|
||||||
<AlignRight className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 border rounded-md p-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setFontSize('xs')}
|
|
||||||
className={cn(
|
|
||||||
'px-2 py-1 text-xs rounded transition-colors',
|
|
||||||
fontSize === 'xs' ? 'bg-accent' : 'hover:bg-accent/50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
XS
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFontSize('sm')}
|
|
||||||
className={cn(
|
|
||||||
'px-2 py-1 text-xs rounded transition-colors',
|
|
||||||
fontSize === 'sm' ? 'bg-accent' : 'hover:bg-accent/50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
SM
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFontSize('base')}
|
|
||||||
className={cn(
|
|
||||||
'px-2 py-1 text-xs rounded transition-colors',
|
|
||||||
fontSize === 'base' ? 'bg-accent' : 'hover:bg-accent/50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
MD
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isLoading && text && (
|
|
||||||
<div className="flex gap-4 mb-2 text-xs text-muted-foreground">
|
|
||||||
<span>{lineCount} lines</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{charCount} chars</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={previewRef}
|
|
||||||
className={cn(
|
|
||||||
'relative min-h-[200px] bg-muted/50 rounded-lg p-4 overflow-x-auto',
|
|
||||||
textAlign === 'center' && 'text-center',
|
|
||||||
textAlign === 'right' && 'text-right'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Skeleton className="h-6 w-3/4" />
|
|
||||||
<Skeleton className="h-6 w-full" />
|
|
||||||
<Skeleton className="h-6 w-5/6" />
|
|
||||||
<Skeleton className="h-6 w-2/3" />
|
|
||||||
<Skeleton className="h-6 w-full" />
|
|
||||||
<Skeleton className="h-6 w-4/5" />
|
|
||||||
</div>
|
|
||||||
) : text ? (
|
|
||||||
<pre className={cn(
|
|
||||||
'font-mono whitespace-pre overflow-x-auto animate-in',
|
|
||||||
fontSize === 'xs' && 'text-[10px]',
|
|
||||||
fontSize === 'sm' && 'text-xs sm:text-sm',
|
|
||||||
fontSize === 'base' && 'text-sm sm:text-base'
|
|
||||||
)}>
|
|
||||||
{text}
|
|
||||||
</pre>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={Type}
|
|
||||||
title="Start typing to see your ASCII art"
|
|
||||||
description="Enter text in the input field above to generate ASCII art with the selected font"
|
|
||||||
className="py-8"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import Fuse from 'fuse.js';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
|
||||||
import { Search, X, Heart, Clock, List, Shuffle } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import type { FigletFont } from '@/types/figlet';
|
|
||||||
import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites';
|
|
||||||
|
|
||||||
export interface FontSelectorProps {
|
|
||||||
fonts: FigletFont[];
|
|
||||||
selectedFont: string;
|
|
||||||
onSelectFont: (fontName: string) => void;
|
|
||||||
onRandomFont?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FilterType = 'all' | 'favorites' | '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 searchInputRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Load favorites and recent fonts
|
|
||||||
React.useEffect(() => {
|
|
||||||
setFavorites(getFavorites());
|
|
||||||
setRecentFonts(getRecentFonts());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
// "/" to focus search
|
|
||||||
if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
}
|
|
||||||
// "Esc" to clear search
|
|
||||||
if (e.key === 'Escape' && searchQuery) {
|
|
||||||
e.preventDefault();
|
|
||||||
setSearchQuery('');
|
|
||||||
searchInputRef.current?.blur();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
// Initialize Fuse.js for fuzzy search
|
|
||||||
const fuse = React.useMemo(() => {
|
|
||||||
return new Fuse(fonts, {
|
|
||||||
keys: ['name', 'fileName'],
|
|
||||||
threshold: 0.3,
|
|
||||||
includeScore: true,
|
|
||||||
});
|
|
||||||
}, [fonts]);
|
|
||||||
|
|
||||||
const filteredFonts = React.useMemo(() => {
|
|
||||||
let fontsToFilter = fonts;
|
|
||||||
|
|
||||||
// Apply category filter
|
|
||||||
if (filter === 'favorites') {
|
|
||||||
fontsToFilter = fonts.filter(f => favorites.includes(f.name));
|
|
||||||
} else if (filter === 'recent') {
|
|
||||||
fontsToFilter = fonts.filter(f => recentFonts.includes(f.name));
|
|
||||||
// Sort by recent order
|
|
||||||
fontsToFilter.sort((a, b) => {
|
|
||||||
return recentFonts.indexOf(a.name) - recentFonts.indexOf(b.name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply search query
|
|
||||||
if (!searchQuery) return fontsToFilter;
|
|
||||||
|
|
||||||
const results = fuse.search(searchQuery);
|
|
||||||
const searchResults = results.map(result => result.item);
|
|
||||||
|
|
||||||
// Filter search results by category
|
|
||||||
if (filter === 'favorites') {
|
|
||||||
return searchResults.filter(f => favorites.includes(f.name));
|
|
||||||
} else if (filter === 'recent') {
|
|
||||||
return searchResults.filter(f => recentFonts.includes(f.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchResults;
|
|
||||||
}, [fonts, searchQuery, fuse, filter, favorites, recentFonts]);
|
|
||||||
|
|
||||||
const handleToggleFavorite = (fontName: string, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleFavorite(fontName);
|
|
||||||
setFavorites(getFavorites());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={cn("flex flex-col min-h-0 overflow-hidden", className)}>
|
|
||||||
<div className="p-6 flex flex-col flex-1 min-h-0">
|
|
||||||
<div className="flex items-center justify-between mb-4 shrink-0">
|
|
||||||
<h3 className="text-sm font-medium">Select Font</h3>
|
|
||||||
{onRandomFont && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onRandomFont}
|
|
||||||
title="Random font"
|
|
||||||
>
|
|
||||||
<Shuffle className="h-3 w-3 mr-2" />
|
|
||||||
Random
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter Tabs */}
|
|
||||||
<div className="flex gap-1 mb-4 p-1 bg-muted rounded-lg shrink-0">
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('all')}
|
|
||||||
className={cn(
|
|
||||||
'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
|
|
||||||
filter === 'all' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<List className="inline-block h-3 w-3 mr-1" />
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('favorites')}
|
|
||||||
className={cn(
|
|
||||||
'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
|
|
||||||
filter === 'favorites' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Heart className="inline-block h-3 w-3 mr-1" />
|
|
||||||
Favorites
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilter('recent')}
|
|
||||||
className={cn(
|
|
||||||
'flex-1 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
|
|
||||||
filter === 'recent' ? 'bg-background shadow-sm' : 'hover:bg-background/50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Clock className="inline-block h-3 w-3 mr-1" />
|
|
||||||
Recent
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Input */}
|
|
||||||
<div className="relative mb-4 shrink-0">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
|
||||||
<Input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="Search fonts... (Press / to focus)"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-9 pr-9"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchQuery('')}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
aria-label="Clear search"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Font List */}
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-1 pr-2">
|
|
||||||
{filteredFonts.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={filter === 'favorites' ? Heart : (filter === 'recent' ? Clock : Search)}
|
|
||||||
title={
|
|
||||||
filter === 'favorites'
|
|
||||||
? 'No favorite fonts yet'
|
|
||||||
: filter === 'recent'
|
|
||||||
? 'No recent fonts'
|
|
||||||
: 'No fonts found'
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
filter === 'favorites'
|
|
||||||
? 'Click the heart icon on any font to add it to your favorites'
|
|
||||||
: filter === 'recent'
|
|
||||||
? 'Fonts you use will appear here'
|
|
||||||
: searchQuery
|
|
||||||
? 'Try a different search term'
|
|
||||||
: 'Loading fonts...'
|
|
||||||
}
|
|
||||||
className="py-8"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
filteredFonts.map((font) => (
|
|
||||||
<div
|
|
||||||
key={font.name}
|
|
||||||
className={cn(
|
|
||||||
'group flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors',
|
|
||||||
'hover:bg-accent hover:text-accent-foreground',
|
|
||||||
selectedFont === font.name && 'bg-accent text-accent-foreground font-medium'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => onSelectFont(font.name)}
|
|
||||||
className="flex-1 text-left"
|
|
||||||
>
|
|
||||||
{font.name}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleToggleFavorite(font.name, e)}
|
|
||||||
className="p-1"
|
|
||||||
aria-label={isFavorite(font.name) ? 'Remove from favorites' : 'Add to favorites'}
|
|
||||||
>
|
|
||||||
<Heart
|
|
||||||
className={cn(
|
|
||||||
'h-4 w-4 transition-colors',
|
|
||||||
isFavorite(font.name) ? 'fill-red-500 text-red-500' : 'text-muted-foreground/30 hover:text-red-500/50'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="mt-4 pt-4 border-t text-xs text-muted-foreground shrink-0">
|
|
||||||
{filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''}
|
|
||||||
{filter === 'favorites' && ` • ${favorites.length} total favorites`}
|
|
||||||
{filter === 'recent' && ` • ${recentFonts.length} recent`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
|
|
||||||
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...'}
|
|
||||||
className="w-full h-32 px-4 py-3 text-base border border-border rounded-lg bg-input resize-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring placeholder:text-muted-foreground transition-all duration-200"
|
|
||||||
maxLength={100}
|
|
||||||
/>
|
|
||||||
<div className="absolute bottom-2 right-2 text-xs text-muted-foreground">
|
|
||||||
{value.length}/100
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +1,60 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Menu, Search, Bell, ChevronRight, Moon, Sun, X } from 'lucide-react';
|
import { Menu, X, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { useSidebar } from './SidebarProvider';
|
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() {
|
export function AppHeader() {
|
||||||
|
const { toggle, isOpen, isCollapsed, toggleCollapse } = useSidebar();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { toggle, isOpen } = useSidebar();
|
const tool = getToolByHref(pathname);
|
||||||
|
|
||||||
// Custom breadcrumb logic
|
|
||||||
const pathSegments = pathname.split('/').filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-16 border-b border-border bg-background/10 backdrop-blur-xl sticky top-0 z-40 flex items-center justify-between px-4 lg:px-8">
|
<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-4">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<Button
|
{/* Desktop: sidebar collapse toggle */}
|
||||||
variant="ghost"
|
<button
|
||||||
size="icon"
|
onClick={toggleCollapse}
|
||||||
className="lg:hidden text-muted-foreground hover:text-foreground"
|
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
onClick={toggle}
|
className={cn(iconBtn, 'hidden lg:flex shrink-0')}
|
||||||
>
|
>
|
||||||
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
{isCollapsed
|
||||||
</Button>
|
? <PanelLeftOpen className="w-4 h-4" />
|
||||||
|
: <PanelLeftClose className="w-4 h-4" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
<nav className="hidden sm:flex items-center text-sm font-medium text-muted-foreground">
|
{/* Mobile: logo home link */}
|
||||||
<Link href="/" className="hover:text-foreground transition-colors flex items-center gap-2">
|
<Link href="/" className="lg:hidden shrink-0 ml-2">
|
||||||
<span>Kit</span>
|
<Logo size={20} />
|
||||||
</Link>
|
</Link>
|
||||||
{pathSegments.map((segment, index) => {
|
|
||||||
const href = `/${pathSegments.slice(0, index + 1).join('/')}`;
|
|
||||||
const isLast = index === pathSegments.length - 1;
|
|
||||||
|
|
||||||
return (
|
{/* Current tool breadcrumb */}
|
||||||
<React.Fragment key={href}>
|
{tool && (
|
||||||
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground/30" />
|
<div className="flex items-center gap-1.5 min-w-0 ml-1">
|
||||||
<Link
|
<span className="text-border/50 text-xs select-none">/</span>
|
||||||
href={href}
|
<span className="text-sm text-foreground/60 truncate font-mono">
|
||||||
className={cn(
|
{tool.navTitle}
|
||||||
"capitalize transition-colors",
|
</span>
|
||||||
isLast ? "text-foreground font-semibold" : "hover:text-foreground"
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
{segment.replace(/-/g, ' ')}
|
|
||||||
</Link>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 sm:gap-4">
|
{/* Mobile: open/close sidebar */}
|
||||||
<ThemeToggleComponent />
|
<button
|
||||||
</div>
|
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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeToggleComponent() {
|
|
||||||
const { resolvedTheme, setTheme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
|
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
|
||||||
title={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
|
|
||||||
>
|
|
||||||
{resolvedTheme === 'dark' ? (
|
|
||||||
<Sun className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Moon className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
17
components/layout/AppPage.tsx
Normal file
17
components/layout/AppPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,12 +13,12 @@ interface AppShellProps {
|
|||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<div className="flex h-screen overflow-hidden bg-background text-foreground relative">
|
<div className="flex h-screen overflow-hidden bg-transparent text-foreground relative">
|
||||||
<AnimatedBackground />
|
<AnimatedBackground />
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<div className="flex-1 flex flex-col min-w-0 relative z-10">
|
<div className="flex-1 flex flex-col min-w-0 relative z-10">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto scrollbar">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,228 +1,158 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import {
|
import { X, GitFork, Heart } from 'lucide-react';
|
||||||
ChevronRight,
|
|
||||||
MousePointer2,
|
|
||||||
Palette,
|
|
||||||
Eye,
|
|
||||||
Languages,
|
|
||||||
Layers,
|
|
||||||
ChevronLeft,
|
|
||||||
X
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
import { useSidebar } from './SidebarProvider';
|
import { useSidebar } from './SidebarProvider';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { tools } from '@/lib/tools';
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
icon: React.ElementType | React.ReactNode;
|
|
||||||
items?: { title: string; href: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavGroup {
|
|
||||||
label: string;
|
|
||||||
items: NavItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PastelIcon = (props: any) => (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
const UnitsIcon = (props: any) => (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
const FigletIcon = (props: any) => (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
const navigation: NavGroup[] = [
|
|
||||||
{
|
|
||||||
label: 'Toolkit',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: 'Units Converter',
|
|
||||||
href: '/units',
|
|
||||||
icon: <UnitsIcon className="h-4 w-4" />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Figlet ASCII',
|
|
||||||
href: '/figlet',
|
|
||||||
icon: <FigletIcon className="h-4 w-4" />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Pastel (Color)',
|
|
||||||
href: '/pastel',
|
|
||||||
icon: <PastelIcon className="h-4 w-4" />,
|
|
||||||
items: [
|
|
||||||
{ title: 'Harmony Palettes', href: '/pastel/palettes/harmony' },
|
|
||||||
{ title: 'Distinct Colors', href: '/pastel/palettes/distinct' },
|
|
||||||
{ title: 'Gradients', href: '/pastel/palettes/gradient' },
|
|
||||||
{ title: 'Contrast Checker', href: '/pastel/accessibility/contrast' },
|
|
||||||
{ title: 'Color Blindness', href: '/pastel/accessibility/colorblind' },
|
|
||||||
{ title: 'Text Color', href: '/pastel/accessibility/textcolor' },
|
|
||||||
{ title: 'Named Colors', href: '/pastel/names' },
|
|
||||||
{ title: 'Batch Operations', href: '/pastel/batch' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isOpen, isCollapsed, close, toggleCollapse } = useSidebar();
|
const { isOpen, isCollapsed, close } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Mobile Overlay Backdrop */}
|
{/* Mobile backdrop */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-40 lg:hidden"
|
className="fixed inset-0 bg-transparent backdrop-blur-sm z-40 lg:hidden"
|
||||||
onClick={close}
|
onClick={close}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<aside className={cn(
|
<aside className={cn(
|
||||||
"fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border bg-background/40 backdrop-blur-2xl transition-all duration-300 ease-in-out lg:relative lg:h-full",
|
'fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border/20 bg-background/10 backdrop-blur-2xl transition-all duration-300 ease-in-out lg:relative lg:h-full',
|
||||||
isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0',
|
||||||
isCollapsed ? "lg:w-20" : "w-64"
|
isCollapsed ? 'lg:w-14' : 'w-60'
|
||||||
)}>
|
)}>
|
||||||
{/* Sidebar Header */}
|
|
||||||
<div className="flex h-16 items-center justify-between px-6 shrink-0 border-b border-border">
|
{/* Header */}
|
||||||
<Link href="/" className="flex items-center gap-3 group overflow-hidden">
|
<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">
|
<div className="shrink-0">
|
||||||
<Logo size={isCollapsed ? 32 : 32} />
|
<Logo size={isCollapsed ? 18 : 24} />
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="font-bold text-xl bg-clip-text text-transparent bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400 group-hover:opacity-80 transition-opacity whitespace-nowrap">
|
<div className="min-w-0">
|
||||||
Kit
|
<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>
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
{!isCollapsed && (
|
||||||
size="icon"
|
<button
|
||||||
className="lg:hidden text-muted-foreground"
|
|
||||||
onClick={close}
|
onClick={close}
|
||||||
|
className="lg:hidden w-7 h-7 flex items-center justify-center rounded-lg text-muted-foreground/40 hover:text-foreground hover:bg-white/5 transition-all"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 overflow-y-auto px-4 py-2 space-y-8 mt-4 scrollbar-hide">
|
<nav className={cn(
|
||||||
{navigation.map((group) => (
|
'flex-1 overflow-y-auto py-3 space-y-0.5 scrollbar-thin scrollbar-thumb-primary/10 scrollbar-track-transparent',
|
||||||
<div key={group.label} className="space-y-2">
|
isCollapsed ? 'px-2' : 'px-3'
|
||||||
{!isCollapsed && (
|
)}>
|
||||||
<h4 className="px-3 text-xs font-semibold text-muted-foreground/50 uppercase tracking-wider">
|
{tools.map((tool) => {
|
||||||
{group.label}
|
const isActive = pathname === tool.href || (tool.href !== '/' && pathname.startsWith(tool.href));
|
||||||
</h4>
|
const Icon = tool.icon;
|
||||||
)}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{group.items.map((item) => {
|
|
||||||
const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.href} className="space-y-1">
|
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
key={tool.href}
|
||||||
|
href={tool.href}
|
||||||
onClick={() => { if (window.innerWidth < 1024) close(); }}
|
onClick={() => { if (window.innerWidth < 1024) close(); }}
|
||||||
|
title={isCollapsed ? tool.navTitle : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center px-3 py-2 rounded-xl text-sm font-medium transition-all duration-300 relative group/item",
|
'relative flex items-center rounded-lg text-sm transition-all duration-200 group/item',
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary/10 text-primary shadow-[0_0_15px_rgba(139,92,246,0.15)] ring-1 ring-primary/20"
|
? 'bg-primary/10 text-primary'
|
||||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
: 'text-foreground/55 hover:bg-white/4 hover:text-foreground',
|
||||||
isCollapsed ? "justify-center" : "justify-between"
|
isCollapsed ? 'justify-center p-2' : 'gap-3 px-3 py-2'
|
||||||
)}
|
)}
|
||||||
title={isCollapsed ? item.title : undefined}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
{/* Active left bar */}
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute left-0 inset-y-2 w-0.5 rounded-r-full bg-primary" />
|
||||||
|
)}
|
||||||
|
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"transition-colors duration-300 shrink-0",
|
'shrink-0 transition-colors duration-200',
|
||||||
isActive ? "text-primary" : "text-muted-foreground group-hover/item:text-foreground"
|
isActive ? 'text-primary' : 'text-foreground/40 group-hover/item:text-foreground/70'
|
||||||
)}>
|
)}>
|
||||||
{React.isValidElement(item.icon) ? item.icon : null}
|
<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>
|
</span>
|
||||||
{!isCollapsed && <span className="whitespace-nowrap">{item.title}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isCollapsed && item.items && (
|
|
||||||
<ChevronRight className={cn(
|
|
||||||
"h-3.5 w-3.5 transition-transform duration-300",
|
|
||||||
pathname.startsWith(item.href) && "rotate-90"
|
|
||||||
)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Collapsed Active Indicator */}
|
|
||||||
{isCollapsed && isActive && (
|
|
||||||
<div className="absolute left-0 w-1 h-6 bg-primary rounded-r-full" />
|
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{item.items && pathname.startsWith(item.href) && !isCollapsed && (
|
|
||||||
<div className="ml-9 space-y-1 border-l border-border pl-2 mt-1">
|
|
||||||
{item.items.map((subItem) => (
|
|
||||||
<Link
|
|
||||||
key={subItem.href}
|
|
||||||
href={subItem.href}
|
|
||||||
onClick={() => { if (window.innerWidth < 1024) close(); }}
|
|
||||||
className={cn(
|
|
||||||
"block px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
|
|
||||||
pathname === subItem.href
|
|
||||||
? "text-primary bg-primary/5 font-semibold"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{subItem.title}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Sidebar Footer / Desktop Toggle */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-border hidden lg:block">
|
<div className={cn(
|
||||||
<Button
|
'shrink-0 border-t border-border/20 py-3',
|
||||||
variant="ghost"
|
isCollapsed ? 'flex justify-center px-2' : 'px-4'
|
||||||
size="sm"
|
)}>
|
||||||
className="w-full flex items-center justify-center gap-2 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={toggleCollapse}
|
|
||||||
>
|
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<ChevronRight className="h-4 w-4" />
|
<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">
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<p className="flex items-center gap-1 text-[9px] text-muted-foreground/40 font-mono">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider">Collapse Sidebar</span>
|
© {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>
|
||||||
)}
|
)}
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
|
|||||||
196
components/media/ConversionPreview.tsx
Normal file
196
components/media/ConversionPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
494
components/media/FileConverter.tsx
Normal file
494
components/media/FileConverter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
components/media/FileUpload.tsx
Normal file
209
components/media/FileUpload.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { ColorInfo as ColorInfoType } from '@/lib/pastel/api/types';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
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 copyToClipboard = (value: string, label: string) => {
|
|
||||||
navigator.clipboard.writeText(value);
|
|
||||||
toast.success(`Copied ${label} to clipboard`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatRgb = (rgb: { r: number; g: number; b: number; a?: number }) => {
|
|
||||||
if (rgb.a !== undefined && rgb.a < 1) {
|
|
||||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${rgb.a})`;
|
|
||||||
}
|
|
||||||
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatHsl = (hsl: { h: number; s: number; l: number; a?: number }) => {
|
|
||||||
if (hsl.a !== undefined && hsl.a < 1) {
|
|
||||||
return `hsla(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%, ${hsl.a})`;
|
|
||||||
}
|
|
||||||
return `hsl(${Math.round(hsl.h)}°, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%)`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatLab = (lab: { l: number; a: number; b: number }) => {
|
|
||||||
return `lab(${lab.l.toFixed(1)}, ${lab.a.toFixed(1)}, ${lab.b.toFixed(1)})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formats = [
|
|
||||||
{ label: 'Hex', value: info.hex },
|
|
||||||
{ label: 'RGB', value: formatRgb(info.rgb) },
|
|
||||||
{ label: 'HSL', value: formatHsl(info.hsl) },
|
|
||||||
{ label: 'Lab', value: formatLab(info.lab) },
|
|
||||||
{ label: 'OkLab', value: formatLab(info.oklab) },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('space-y-4', className)}>
|
|
||||||
<div className="grid grid-cols-1 gap-3">
|
|
||||||
{formats.map((format) => (
|
|
||||||
<div
|
|
||||||
key={format.label}
|
|
||||||
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-xs text-muted-foreground mb-1">{format.label}</div>
|
|
||||||
<div className="font-mono text-sm">{format.value}</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => copyToClipboard(format.value, format.label)}
|
|
||||||
aria-label={`Copy ${format.label} value`}
|
|
||||||
>
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs text-muted-foreground">Brightness</div>
|
|
||||||
<div className="text-sm font-medium">{(info.brightness * 100).toFixed(1)}%</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs text-muted-foreground">Luminance</div>
|
|
||||||
<div className="text-sm font-medium">{(info.luminance * 100).toFixed(1)}%</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs text-muted-foreground">Type</div>
|
|
||||||
<div className="text-sm font-medium">{info.is_light ? 'Light' : 'Dark'}</div>
|
|
||||||
</div>
|
|
||||||
{info.name && typeof info.name === 'string' && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs text-muted-foreground">Named</div>
|
|
||||||
<div className="text-sm font-medium">{info.name}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { HexColorPicker } from 'react-colorful';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
|
|
||||||
interface ColorPickerProps {
|
|
||||||
color: string;
|
|
||||||
onChange: (color: string) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
// Allow partial input while typing
|
|
||||||
onChange(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('space-y-4', className)}>
|
|
||||||
<HexColorPicker color={color} onChange={onChange} className="w-full" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="color-input" className="text-sm font-medium">
|
|
||||||
Color Value
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="color-input"
|
|
||||||
type="text"
|
|
||||||
value={color}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
placeholder="#ff0099 or rgb(255, 0, 153)"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
'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 sizeClasses = {
|
|
||||||
sm: 'h-12 w-12',
|
|
||||||
md: 'h-16 w-16',
|
|
||||||
lg: 'h-24 w-24',
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigator.clipboard.writeText(color);
|
|
||||||
setCopied(true);
|
|
||||||
toast.success(`Copied ${color}`);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex flex-col items-center gap-2', className)}>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'relative rounded-lg ring-2 ring-border transition-all duration-200',
|
|
||||||
'hover:scale-110 hover:ring-primary hover:shadow-lg',
|
|
||||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
|
||||||
'group active:scale-95',
|
|
||||||
sizeClasses[size]
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
onClick={onClick || handleCopy}
|
|
||||||
aria-label={`Color ${color}`}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all duration-200 bg-black/30 rounded-lg backdrop-blur-sm">
|
|
||||||
{copied ? (
|
|
||||||
<Check className="h-5 w-5 text-white animate-scale-in" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-5 w-5 text-white" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{showLabel && (
|
|
||||||
<span className="text-xs font-mono text-muted-foreground">{color}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Github, Heart, ExternalLink } from 'lucide-react';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="border-t bg-card/50 backdrop-blur-sm">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 py-12">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
|
||||||
{/* About */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="font-semibold text-lg">Pastel UI</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
A modern, interactive web application for color manipulation, palette generation,
|
|
||||||
and accessibility analysis
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resources */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="font-semibold">Resources</h3>
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
<li>
|
|
||||||
<Link href="/pastel/playground" className="text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
Color Playground
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link href="/pastel/palettes" className="text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
Palette Generator
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link href="/pastel/accessibility" className="text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
Accessibility Tools
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link href="/pastel/batch" className="text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
Batch Operations
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Documentation */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="font-semibold">Documentation</h3>
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/valknarness/pastel-ui#readme"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
Getting Started
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/valknarness/pastel-api#readme"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
API Documentation
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/sharkdp/pastel"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
|
||||||
>
|
|
||||||
Pastel CLI
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Community */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="font-semibold">Community</h3>
|
|
||||||
<ul className="space-y-2 text-sm">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/valknarness/pastel-ui"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Github className="h-4 w-4" />
|
|
||||||
Pastel UI
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/valknarness/pastel-api"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Github className="h-4 w-4" />
|
|
||||||
Pastel API
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/valknarness/pastel-ui/issues"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Report an Issue
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://github.com/valknarness/pastel-ui/discussions"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Discussions
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Bar */}
|
|
||||||
<div className="mt-12 pt-8 border-t">
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
<p className="inline-flex items-center gap-1">
|
|
||||||
© {currentYear} Pastel UI. Built with
|
|
||||||
<Heart className="h-4 w-4 text-red-500 fill-red-500" />
|
|
||||||
using
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-foreground transition-colors underline"
|
|
||||||
>
|
|
||||||
Next.js
|
|
||||||
</a>
|
|
||||||
and
|
|
||||||
<a
|
|
||||||
href="https://tailwindcss.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-foreground transition-colors underline"
|
|
||||||
>
|
|
||||||
Tailwind CSS
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-6 text-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
Based on{' '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/sharkdp/pastel"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-foreground transition-colors underline"
|
|
||||||
>
|
|
||||||
Pastel
|
|
||||||
</a>
|
|
||||||
{' '}by{' '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/sharkdp"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-foreground transition-colors underline"
|
|
||||||
>
|
|
||||||
David Peter
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<span>•</span>
|
|
||||||
<a
|
|
||||||
href="https://github.com/valknarness/pastel-ui/blob/main/LICENSE"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
MIT License
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { ThemeToggle } from './ThemeToggle';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
import { Palette } from 'lucide-react';
|
|
||||||
|
|
||||||
const navigation = [
|
|
||||||
{ name: 'Home', href: '/pastel' },
|
|
||||||
{ name: 'Playground', href: '/pastel/playground' },
|
|
||||||
{ name: 'Palettes', href: '/pastel/palettes' },
|
|
||||||
{ name: 'Accessibility', href: '/pastel/accessibility' },
|
|
||||||
{ name: 'Named Colors', href: '/pastel/names' },
|
|
||||||
{ name: 'Batch', href: '/pastel/batch' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Navbar() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
|
||||||
<div className="max-w-7xl mx-auto px-8">
|
|
||||||
<div className="flex h-16 items-center justify-between">
|
|
||||||
{/* Logo */}
|
|
||||||
<Link href="/" className="flex items-center space-x-2 font-bold text-xl">
|
|
||||||
<Palette className="h-6 w-6 text-primary" />
|
|
||||||
<span className="bg-gradient-to-r from-pink-500 via-purple-500 to-blue-500 bg-clip-text text-transparent">
|
|
||||||
Pastel UI
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
|
||||||
<div className="hidden md:flex items-center space-x-1">
|
|
||||||
{navigation.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
'px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
|
||||||
pathname === item.href
|
|
||||||
? 'bg-accent text-accent-foreground'
|
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side */}
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile Navigation */}
|
|
||||||
<div className="md:hidden pb-3 space-y-1">
|
|
||||||
{navigation.map((item) => (
|
|
||||||
<Link
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
'block px-3 py-2 rounded-md text-sm font-medium transition-colors',
|
|
||||||
pathname === item.href
|
|
||||||
? 'bg-accent text-accent-foreground'
|
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Moon, Sun } from 'lucide-react';
|
|
||||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
|
|
||||||
export function ThemeToggle() {
|
|
||||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={toggleTheme}
|
|
||||||
aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
|
|
||||||
>
|
|
||||||
{resolvedTheme === 'dark' ? (
|
|
||||||
<Sun className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Moon className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { Toaster } from 'sonner';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { ThemeProvider } from './ThemeProvider';
|
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
|
||||||
const [queryClient] = useState(
|
|
||||||
() =>
|
|
||||||
new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
staleTime: 60 * 1000, // 1 minute
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
{children}
|
|
||||||
<Toaster position="top-right" richColors />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
|
||||||
|
|
||||||
interface ThemeContextType {
|
|
||||||
theme: Theme;
|
|
||||||
setTheme: (theme: Theme) => void;
|
|
||||||
resolvedTheme: 'light' | 'dark';
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [theme, setTheme] = useState<Theme>('system');
|
|
||||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Load theme from localStorage
|
|
||||||
const stored = localStorage.getItem('theme') as Theme | null;
|
|
||||||
if (stored) {
|
|
||||||
setTheme(stored);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const root = window.document.documentElement;
|
|
||||||
|
|
||||||
// Remove previous theme classes
|
|
||||||
root.classList.remove('light', 'dark');
|
|
||||||
|
|
||||||
if (theme === 'system') {
|
|
||||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
? 'dark'
|
|
||||||
: 'light';
|
|
||||||
root.classList.add(systemTheme);
|
|
||||||
setResolvedTheme(systemTheme);
|
|
||||||
} else {
|
|
||||||
root.classList.add(theme);
|
|
||||||
setResolvedTheme(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to localStorage
|
|
||||||
localStorage.setItem('theme', theme);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
// Listen for system theme changes
|
|
||||||
useEffect(() => {
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
||||||
|
|
||||||
const handleChange = () => {
|
|
||||||
if (theme === 'system') {
|
|
||||||
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
|
|
||||||
setResolvedTheme(systemTheme);
|
|
||||||
window.document.documentElement.classList.remove('light', 'dark');
|
|
||||||
window.document.documentElement.classList.add(systemTheme);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaQuery.addEventListener('change', handleChange);
|
|
||||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
||||||
}, [theme]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme() {
|
|
||||||
const context = useContext(ThemeContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useTheme must be used within a ThemeProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Select } from '@/components/ui/Select';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Download, Copy, Check } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import {
|
|
||||||
exportAsCSS,
|
|
||||||
exportAsSCSS,
|
|
||||||
exportAsTailwind,
|
|
||||||
exportAsJSON,
|
|
||||||
exportAsJavaScript,
|
|
||||||
downloadAsFile,
|
|
||||||
type ExportColor,
|
|
||||||
} from '@/lib/pastel/utils/export';
|
|
||||||
|
|
||||||
interface ExportMenuProps {
|
|
||||||
colors: string[];
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExportFormat = 'css' | 'scss' | 'tailwind' | 'json' | 'javascript';
|
|
||||||
|
|
||||||
export function ExportMenu({ colors, className }: ExportMenuProps) {
|
|
||||||
const [format, setFormat] = useState<ExportFormat>('css');
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const exportColors: ExportColor[] = colors.map((hex) => ({ hex }));
|
|
||||||
|
|
||||||
const getExportContent = (): 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 getFileExtension = (): string => {
|
|
||||||
switch (format) {
|
|
||||||
case 'css':
|
|
||||||
return 'css';
|
|
||||||
case 'scss':
|
|
||||||
return 'scss';
|
|
||||||
case 'tailwind':
|
|
||||||
return 'js';
|
|
||||||
case 'json':
|
|
||||||
return 'json';
|
|
||||||
case 'javascript':
|
|
||||||
return 'js';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
const content = getExportContent();
|
|
||||||
navigator.clipboard.writeText(content);
|
|
||||||
setCopied(true);
|
|
||||||
toast.success('Copied to clipboard!');
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
const content = getExportContent();
|
|
||||||
const extension = getFileExtension();
|
|
||||||
downloadAsFile(content, `palette.${extension}`, 'text/plain');
|
|
||||||
toast.success('Downloaded!');
|
|
||||||
};
|
|
||||||
|
|
||||||
if (colors.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium mb-2">Export Palette</h3>
|
|
||||||
<Select
|
|
||||||
value={format}
|
|
||||||
onChange={(e) => setFormat(e.target.value as ExportFormat)}
|
|
||||||
>
|
|
||||||
<option value="css">CSS Variables</option>
|
|
||||||
<option value="scss">SCSS Variables</option>
|
|
||||||
<option value="tailwind">Tailwind Config</option>
|
|
||||||
<option value="json">JSON</option>
|
|
||||||
<option value="javascript">JavaScript Array</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-muted rounded-lg">
|
|
||||||
<pre className="text-xs overflow-x-auto">
|
|
||||||
<code>{getExportContent()}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleCopy} variant="outline" className="flex-1">
|
|
||||||
{copied ? (
|
|
||||||
<>
|
|
||||||
<Check className="h-4 w-4 mr-2" />
|
|
||||||
Copied!
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy className="h-4 w-4 mr-2" />
|
|
||||||
Copy
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleDownload} variant="default" className="flex-1">
|
|
||||||
<Download className="h-4 w-4 mr-2" />
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Slider } from '@/components/ui/Slider';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import {
|
|
||||||
useLighten,
|
|
||||||
useDarken,
|
|
||||||
useSaturate,
|
|
||||||
useDesaturate,
|
|
||||||
useRotate,
|
|
||||||
useComplement
|
|
||||||
} from '@/lib/pastel/api/queries';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
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 handleLighten = async () => {
|
|
||||||
try {
|
|
||||||
const result = await lightenMutation.mutateAsync({
|
|
||||||
colors: [color],
|
|
||||||
amount: lightenAmount,
|
|
||||||
});
|
|
||||||
if (result.colors[0]) {
|
|
||||||
onColorChange(result.colors[0].output);
|
|
||||||
toast.success(`Lightened by ${(lightenAmount * 100).toFixed(0)}%`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to lighten color');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDarken = async () => {
|
|
||||||
try {
|
|
||||||
const result = await darkenMutation.mutateAsync({
|
|
||||||
colors: [color],
|
|
||||||
amount: darkenAmount,
|
|
||||||
});
|
|
||||||
if (result.colors[0]) {
|
|
||||||
onColorChange(result.colors[0].output);
|
|
||||||
toast.success(`Darkened by ${(darkenAmount * 100).toFixed(0)}%`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to darken color');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaturate = async () => {
|
|
||||||
try {
|
|
||||||
const result = await saturateMutation.mutateAsync({
|
|
||||||
colors: [color],
|
|
||||||
amount: saturateAmount,
|
|
||||||
});
|
|
||||||
if (result.colors[0]) {
|
|
||||||
onColorChange(result.colors[0].output);
|
|
||||||
toast.success(`Saturated by ${(saturateAmount * 100).toFixed(0)}%`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to saturate color');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDesaturate = async () => {
|
|
||||||
try {
|
|
||||||
const result = await desaturateMutation.mutateAsync({
|
|
||||||
colors: [color],
|
|
||||||
amount: desaturateAmount,
|
|
||||||
});
|
|
||||||
if (result.colors[0]) {
|
|
||||||
onColorChange(result.colors[0].output);
|
|
||||||
toast.success(`Desaturated by ${(desaturateAmount * 100).toFixed(0)}%`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to desaturate color');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRotate = async () => {
|
|
||||||
try {
|
|
||||||
const result = await rotateMutation.mutateAsync({
|
|
||||||
colors: [color],
|
|
||||||
amount: rotateAmount,
|
|
||||||
});
|
|
||||||
if (result.colors[0]) {
|
|
||||||
onColorChange(result.colors[0].output);
|
|
||||||
toast.success(`Rotated hue by ${rotateAmount}°`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to rotate hue');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleComplement = async () => {
|
|
||||||
try {
|
|
||||||
const result = await complementMutation.mutateAsync([color]);
|
|
||||||
if (result.colors[0]) {
|
|
||||||
onColorChange(result.colors[0].output);
|
|
||||||
toast.success('Generated complementary color');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to generate complement');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isLoading =
|
|
||||||
lightenMutation.isPending ||
|
|
||||||
darkenMutation.isPending ||
|
|
||||||
saturateMutation.isPending ||
|
|
||||||
desaturateMutation.isPending ||
|
|
||||||
rotateMutation.isPending ||
|
|
||||||
complementMutation.isPending;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Lighten */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Slider
|
|
||||||
label="Lighten"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
value={lightenAmount}
|
|
||||||
onChange={(e) => setLightenAmount(parseFloat(e.target.value))}
|
|
||||||
suffix="%"
|
|
||||||
showValue
|
|
||||||
/>
|
|
||||||
<Button onClick={handleLighten} disabled={isLoading} className="w-full">
|
|
||||||
Apply Lighten
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Darken */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Slider
|
|
||||||
label="Darken"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
value={darkenAmount}
|
|
||||||
onChange={(e) => setDarkenAmount(parseFloat(e.target.value))}
|
|
||||||
suffix="%"
|
|
||||||
showValue
|
|
||||||
/>
|
|
||||||
<Button onClick={handleDarken} disabled={isLoading} className="w-full">
|
|
||||||
Apply Darken
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Saturate */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Slider
|
|
||||||
label="Saturate"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
value={saturateAmount}
|
|
||||||
onChange={(e) => setSaturateAmount(parseFloat(e.target.value))}
|
|
||||||
suffix="%"
|
|
||||||
showValue
|
|
||||||
/>
|
|
||||||
<Button onClick={handleSaturate} disabled={isLoading} className="w-full">
|
|
||||||
Apply Saturate
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desaturate */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Slider
|
|
||||||
label="Desaturate"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
value={desaturateAmount}
|
|
||||||
onChange={(e) => setDesaturateAmount(parseFloat(e.target.value))}
|
|
||||||
suffix="%"
|
|
||||||
showValue
|
|
||||||
/>
|
|
||||||
<Button onClick={handleDesaturate} disabled={isLoading} className="w-full">
|
|
||||||
Apply Desaturate
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rotate Hue */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Slider
|
|
||||||
label="Rotate Hue"
|
|
||||||
min={-180}
|
|
||||||
max={180}
|
|
||||||
step={5}
|
|
||||||
value={rotateAmount}
|
|
||||||
onChange={(e) => setRotateAmount(parseInt(e.target.value))}
|
|
||||||
suffix="°"
|
|
||||||
showValue
|
|
||||||
/>
|
|
||||||
<Button onClick={handleRotate} disabled={isLoading} className="w-full">
|
|
||||||
Apply Rotation
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="pt-4 border-t space-y-2">
|
|
||||||
<h3 className="text-sm font-medium mb-3">Quick Actions</h3>
|
|
||||||
<Button
|
|
||||||
onClick={handleComplement}
|
|
||||||
disabled={isLoading}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Get Complementary Color
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ThemeProvider } from './ThemeProvider';
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
|
import { SWRegistration } from './SWRegistration';
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
@@ -19,11 +20,28 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<SWRegistration />
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="top-right" richColors />
|
</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>
|
</QueryClientProvider>
|
||||||
</ThemeProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
22
components/providers/SWRegistration.tsx
Normal file
22
components/providers/SWRegistration.tsx
Normal 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;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user