feat: refactor theme, add tailwind-scrollbar, and improve UI components
- Removed manual theme switching logic and ThemeProvider - Installed and configured tailwind-scrollbar plugin - Updated FileConverter and ConversionOptions to use shadcn Input - Refactored FontSelector to use shadcn Tabs - Simplified global styles and adjusted glassmorphic effects
This commit is contained in:
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`.
|
||||
@@ -1,3 +1,4 @@
|
||||
import AnimatedBackground from '@/components/AnimatedBackground';
|
||||
import { AppShell } from '@/components/layout/AppShell';
|
||||
import { Providers } from '@/components/providers/Providers';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwind-scrollbar";
|
||||
|
||||
@source "../components/*.{js,ts,jsx,tsx}";
|
||||
@source "../components/ui/*.{js,ts,jsx,tsx}";
|
||||
@@ -85,14 +86,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
:root, .dark {
|
||||
color-scheme: dark;
|
||||
:root {
|
||||
/* CORPORATE DARK THEME (The Standard) */
|
||||
--background: #0a0a0f;
|
||||
--foreground: #ffffff;
|
||||
--card: rgba(255, 255, 255, 0.03);
|
||||
--card-foreground: #ffffff;
|
||||
--popover: #0f0f15;
|
||||
--popover: #5D429C;
|
||||
--popover-foreground: #ffffff;
|
||||
--primary: #8b5cf6;
|
||||
--primary-foreground: #ffffff;
|
||||
@@ -110,36 +110,12 @@
|
||||
--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.2);
|
||||
--input: rgba(0, 0, 0, 0.08);
|
||||
--ring: rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
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);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import { Providers } from '@/components/providers/Providers';
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||
|
||||
@@ -37,7 +36,7 @@ export default function RootLayout({
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
@@ -49,28 +48,6 @@ export default function RootLayout({
|
||||
{isProd && umamiScript && umamiId && (
|
||||
<script defer src={umamiScript} data-website-id={umamiId}></script>
|
||||
)}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
try {
|
||||
var theme = localStorage.getItem('theme');
|
||||
var isLanding = window.location.pathname === '/';
|
||||
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>
|
||||
<body className="antialiased">
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import AnimatedBackground from '@/components/AnimatedBackground';
|
||||
@@ -10,11 +9,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Home } from 'lucide-react';
|
||||
|
||||
export default function NotFound() {
|
||||
React.useEffect(() => {
|
||||
// Force dark mode on html element for the 404 page
|
||||
document.documentElement.classList.remove('light');
|
||||
document.documentElement.classList.add('dark');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="relative min-h-screen dark text-foreground flex flex-col">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import AnimatedBackground from '@/components/AnimatedBackground';
|
||||
import Hero from '@/components/Hero';
|
||||
import Stats from '@/components/Stats';
|
||||
@@ -9,12 +8,6 @@ import Footer from '@/components/Footer';
|
||||
import BackToTop from '@/components/BackToTop';
|
||||
|
||||
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 (
|
||||
<main className="relative min-h-screen dark text-foreground">
|
||||
<AnimatedBackground />
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function AnimatedBackground() {
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden bg-background transition-colors duration-500">
|
||||
{/* Animated gradient background */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.08] dark:opacity-50"
|
||||
className="absolute inset-0 opacity-50"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 25%, #f093fb 50%, #4facfe 75%, #667eea 100%)',
|
||||
backgroundSize: '400% 400%',
|
||||
@@ -15,7 +15,7 @@ export default function AnimatedBackground() {
|
||||
|
||||
{/* Signature Grid pattern overlay - Original landing page specification */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.05] dark:opacity-10"
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(255, 255, 255, 0.1) 1px, transparent 1px),
|
||||
@@ -26,9 +26,9 @@ export default function AnimatedBackground() {
|
||||
/>
|
||||
|
||||
{/* Floating orbs */}
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply dark:mix-blend-normal filter blur-3xl opacity-[0.03] dark: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 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 top-1/4 left-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-normal filter blur-3xl opacity-20 animate-float" />
|
||||
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-normal filter blur-3xl opacity-20 animate-float" style={{ animationDelay: '2s' }} />
|
||||
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-normal filter blur-3xl opacity-20 animate-float" style={{ animationDelay: '4s' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,12 +31,12 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
|
||||
<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">
|
||||
{/* Gradient overlay on hover */}
|
||||
<div
|
||||
className={`absolute inset-0 opacity-0 group-hover:opacity-10 dark:group-hover:opacity-15 transition-opacity duration-300 ${gradient}`}
|
||||
className={`absolute inset-0 opacity-0 group-hover:opacity-15 transition-opacity duration-300 ${gradient}`}
|
||||
/>
|
||||
|
||||
{/* Glow effect */}
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 blur-xl -z-10">
|
||||
<div className={`w-full h-full ${gradient} opacity-20 dark:opacity-30`} />
|
||||
<div className={`w-full h-full ${gradient} opacity-30`} />
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { Search, X, Heart, Clock, List, Shuffle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { ASCIIFont } from '@/types/ascii';
|
||||
import { getFavorites, getRecentFonts, toggleFavorite, isFavorite } from '@/lib/storage/favorites';
|
||||
|
||||
@@ -108,39 +109,26 @@ export function FontSelector({
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col flex-1 min-h-0 pt-0">
|
||||
{/* 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 flex items-center justify-center 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="h-3 w-3 mr-1.5" />
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('favorites')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center 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="h-3 w-3 mr-1.5" />
|
||||
Favorites
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('recent')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center 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="h-3 w-3 mr-1.5" />
|
||||
Recent
|
||||
</button>
|
||||
</div>
|
||||
<Tabs
|
||||
value={filter}
|
||||
onValueChange={(v) => setFilter(v as FilterType)}
|
||||
className="mb-4 shrink-0"
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="all" className="flex-1">
|
||||
<List className="h-3.5 w-3.5" />
|
||||
All
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="favorites" className="flex-1">
|
||||
<Heart className="h-3.5 w-3.5" />
|
||||
Favorites
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recent" className="flex-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Recent
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative mb-4 shrink-0">
|
||||
@@ -165,7 +153,7 @@ export function FontSelector({
|
||||
</div>
|
||||
|
||||
{/* Font List */}
|
||||
<div className="flex-1 overflow-y-auto space-y-1 pr-2">
|
||||
<div className="flex-1 overflow-y-auto space-y-1 pr-2 scrollbar">
|
||||
{filteredFonts.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
|
||||
@@ -5,7 +5,6 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Menu, Search, Bell, ChevronRight, Moon, Sun, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTheme } from '@/components/providers/ThemeProvider';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { useSidebar } from './SidebarProvider';
|
||||
import Logo from '@/components/Logo';
|
||||
@@ -23,7 +22,7 @@ export function AppHeader() {
|
||||
<nav className="flex items-center text-sm font-medium">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Logo size={20} className="lg:hidden" />
|
||||
<span className="font-medium transition-colors text-foreground">
|
||||
<span className="font-medium transition-colors text-muted-foreground">
|
||||
Kit
|
||||
</span>
|
||||
</Link>
|
||||
@@ -37,7 +36,7 @@ export function AppHeader() {
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"capitalize transition-colors text-foreground",
|
||||
"capitalize transition-colors text-muted-foreground",
|
||||
isLast ? "font-semibold" : "font-medium"
|
||||
)}
|
||||
>
|
||||
@@ -50,7 +49,6 @@ export function AppHeader() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggleComponent />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -62,24 +60,4 @@ export function AppHeader() {
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,12 @@ interface AppShellProps {
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
return (
|
||||
<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 />
|
||||
<AppSidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0 relative z-10">
|
||||
<AppHeader />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<main className="flex-1 overflow-y-auto scrollbar">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,6 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
ChevronRight,
|
||||
MousePointer2,
|
||||
Palette,
|
||||
Eye,
|
||||
Languages,
|
||||
Layers,
|
||||
ChevronLeft,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
@@ -73,13 +68,13 @@ export function AppSidebar() {
|
||||
{/* Mobile Overlay Backdrop */}
|
||||
{isOpen && (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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 bg-background/10 backdrop-blur-2xl transition-all duration-300 ease-in-out lg:relative lg:h-full",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
||||
isCollapsed ? "lg:w-20" : "w-64"
|
||||
)}>
|
||||
@@ -106,7 +101,7 @@ export function AppSidebar() {
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-2 space-y-8 mt-4 scrollbar-hide">
|
||||
<nav className="flex-1 overflow-y-auto px-4 py-2 space-y-8 mt-4 overflow-x-hidden">
|
||||
{navigation.map((group) => (
|
||||
<div key={group.label} className="space-y-2">
|
||||
{!isCollapsed && (
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -237,12 +235,11 @@ export function ConversionOptionsPanel({
|
||||
{/* Width */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">Width (px)</label>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
value={options.imageWidth || ''}
|
||||
onChange={(e) => handleOptionChange('imageWidth', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="Original"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Leave empty to keep original</p>
|
||||
@@ -251,12 +248,11 @@ export function ConversionOptionsPanel({
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">Height (px)</label>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
value={options.imageHeight || ''}
|
||||
onChange={(e) => handleOptionChange('imageHeight', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
placeholder="Original"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Leave empty to maintain aspect ratio</p>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
@@ -619,12 +620,11 @@ export function FileConverter() {
|
||||
{/* Width */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">Width (px)</label>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
value={conversionOptions.imageWidth || ''}
|
||||
onChange={(e) => setConversionOptions({ ...conversionOptions, imageWidth: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
placeholder="Original"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
disabled={isConverting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Leave empty to keep original</p>
|
||||
@@ -633,12 +633,11 @@ export function FileConverter() {
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">Height (px)</label>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
value={conversionOptions.imageHeight || ''}
|
||||
onChange={(e) => setConversionOptions({ ...conversionOptions, imageHeight: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
placeholder="Original"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
disabled={isConverting}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Leave empty to maintain aspect ratio</p>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Toaster } from 'sonner';
|
||||
import { useState } from 'react';
|
||||
import { ThemeProvider } from './ThemeProvider';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { SWRegistration } from './SWRegistration';
|
||||
|
||||
@@ -21,14 +20,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<SWRegistration />
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
<Toaster position="top-right" richColors />
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<SWRegistration />
|
||||
{children}
|
||||
</TooltipProvider>
|
||||
<Toaster position="top-right" richColors />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,85 +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>('dark');
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('dark');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Load theme from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('theme') as Theme | null;
|
||||
if (stored) {
|
||||
setTheme(stored);
|
||||
}
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Apply theme to document element and save to localStorage
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
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, mounted]);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
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, mounted]);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export default function SearchUnits({ onSelectUnit, className }: SearchUnitsProp
|
||||
|
||||
{/* Results dropdown */}
|
||||
{isOpen && results.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-lg shadow-lg max-h-80 overflow-y-auto">
|
||||
<div className="absolute z-50 w-full mt-2 bg-popover border rounded-lg shadow-lg max-h-80 overflow-y-auto scrollbar">
|
||||
{results.map((result, index) => (
|
||||
<button
|
||||
key={`${result.measure}-${result.unitInfo.abbr}`}
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"eslint-config-next": "^15.1.7",
|
||||
"postcss": "^8.5.6",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
32
pnpm-lock.yaml
generated
32
pnpm-lock.yaml
generated
@@ -111,6 +111,9 @@ importers:
|
||||
shadcn:
|
||||
specifier: ^3.8.5
|
||||
version: 3.8.5(@types/node@25.3.0)(typescript@5.9.3)
|
||||
tailwind-scrollbar:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(react@19.2.4)(tailwindcss@4.2.0)
|
||||
tailwindcss:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
@@ -1514,6 +1517,9 @@ packages:
|
||||
'@types/node@25.3.0':
|
||||
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
|
||||
|
||||
'@types/prismjs@1.26.6':
|
||||
resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==}
|
||||
|
||||
'@types/react-dom@19.2.3':
|
||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||
peerDependencies:
|
||||
@@ -3315,6 +3321,11 @@ packages:
|
||||
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
prism-react-renderer@2.4.1:
|
||||
resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
|
||||
peerDependencies:
|
||||
react: '>=16.0.0'
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
@@ -3691,6 +3702,12 @@ packages:
|
||||
tailwind-merge@3.5.0:
|
||||
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
|
||||
|
||||
tailwind-scrollbar@4.0.2:
|
||||
resolution: {integrity: sha512-wAQiIxAPqk0MNTPptVe/xoyWi27y+NRGnTwvn4PQnbvB9kp8QUBiGl/wsfoVBHnQxTmhXJSNt9NHTmcz9EivFA==}
|
||||
engines: {node: '>=12.13.0'}
|
||||
peerDependencies:
|
||||
tailwindcss: 4.x
|
||||
|
||||
tailwindcss@4.2.0:
|
||||
resolution: {integrity: sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==}
|
||||
|
||||
@@ -5391,6 +5408,8 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@types/prismjs@1.26.6': {}
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
@@ -7312,6 +7331,12 @@ snapshots:
|
||||
dependencies:
|
||||
parse-ms: 4.0.0
|
||||
|
||||
prism-react-renderer@2.4.1(react@19.2.4):
|
||||
dependencies:
|
||||
'@types/prismjs': 1.26.6
|
||||
clsx: 2.1.1
|
||||
react: 19.2.4
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
@@ -7860,6 +7885,13 @@ snapshots:
|
||||
|
||||
tailwind-merge@3.5.0: {}
|
||||
|
||||
tailwind-scrollbar@4.0.2(react@19.2.4)(tailwindcss@4.2.0):
|
||||
dependencies:
|
||||
prism-react-renderer: 2.4.1(react@19.2.4)
|
||||
tailwindcss: 4.2.0
|
||||
transitivePeerDependencies:
|
||||
- react
|
||||
|
||||
tailwindcss@4.2.0: {}
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
Reference in New Issue
Block a user