refactor: streamline, refine and polish

This commit is contained in:
2026-02-27 12:35:02 +01:00
parent efe3c81576
commit ee7e5ec06c
21 changed files with 606 additions and 735 deletions

View File

@@ -13,6 +13,7 @@ import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/col
import { Loader2, Share2, Palette, Plus, X, Layers } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
@@ -137,37 +138,36 @@ function PlaygroundContent() {
title="Color"
description="Interactive color manipulation and analysis tool"
>
<div className="space-y-8">
<div className="space-y-6">
{/* Row 1: Workspace */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-stretch">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
{/* Main Workspace: Color Picker and Information */}
<div className="lg:col-span-2">
<Card className="h-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle>Color Picker</CardTitle>
<Button onClick={handleShare} variant="outline" size="sm">
<Share2 className="h-4 w-4 mr-2" />
<Button onClick={handleShare} variant="outline" size="xs">
<Share2 className="h-3 w-3 mr-1" />
Share
</Button>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row gap-12">
<div className="flex flex-col md:flex-row gap-8">
<div className="flex-shrink-0 mx-auto md:mx-0">
<ColorPicker color={color} onChange={setColor} />
</div>
<div className="flex-1">
<h3 className="text-sm font-medium mb-4 text-muted-foreground uppercase tracking-wider">Color Information</h3>
<div className="flex-1 min-w-0">
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{isError && (
<div className="p-4 bg-destructive/10 text-destructive rounded-lg">
<div className="p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
<p className="font-medium">Error loading color information</p>
<p className="text-sm mt-1">{error?.message || 'Unknown error'}</p>
<p className="mt-1">{error?.message || 'Unknown error'}</p>
</div>
)}
@@ -182,7 +182,7 @@ function PlaygroundContent() {
<div className="lg:col-span-1">
<Card className="h-full">
<CardHeader>
<CardTitle>Color Manipulation</CardTitle>
<CardTitle>Adjustments</CardTitle>
</CardHeader>
<CardContent>
<ManipulationPanel color={color} onColorChange={setColor} />
@@ -192,15 +192,12 @@ function PlaygroundContent() {
</div>
{/* Row 2: Harmony Generator */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-stretch">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
{/* Harmony Controls */}
<div className="lg:col-span-1">
<Card className="h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Harmony Type
</CardTitle>
<CardTitle>Harmony</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Select
@@ -219,7 +216,7 @@ function PlaygroundContent() {
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
<p className="text-xs text-muted-foreground">
{harmonyDescriptions[harmonyType]}
</p>
@@ -230,14 +227,11 @@ function PlaygroundContent() {
>
{paletteMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating..
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Generating...
</>
) : (
<>
<Palette className="mr-2 h-4 w-4" />
Generate Harmony
</>
'Generate'
)}
</Button>
</CardContent>
@@ -249,21 +243,21 @@ function PlaygroundContent() {
<Card className="h-full">
<CardHeader>
<CardTitle>
Generated Palette {palette.length > 0 && `(${palette.length} colors)`}
Palette {palette.length > 0 && <span className="text-muted-foreground font-normal text-sm ml-1">({palette.length})</span>}
</CardTitle>
</CardHeader>
<CardContent>
{palette.length > 0 ? (
<div className="space-y-6">
<div className="space-y-5">
<PaletteGrid colors={palette} onColorClick={setColor} />
<div className="pt-4 border-t">
<div className="pt-3 border-t">
<ExportMenu colors={palette} />
</div>
</div>
) : (
<div className="p-12 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 based on the current color</p>
<div className="py-8 text-center text-muted-foreground text-xs">
<Palette className="h-8 w-8 mx-auto mb-2 opacity-20" />
<p>Generate a harmony palette from the current color</p>
</div>
)}
</CardContent>
@@ -272,74 +266,72 @@ function PlaygroundContent() {
</div>
{/* Row 3: Gradient Generator */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-stretch">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
{/* Gradient Controls */}
<div className="lg:col-span-1">
<Card className="h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="h-5 w-5" />
Gradient Controls
</CardTitle>
<CardTitle>Gradient</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Color Stops</h4>
{stops.map((stop, index) => (
<div key={index} className="flex items-center gap-2">
<div className="flex-1">
<Input
type="color"
value={stop}
onChange={(e) => updateStop(index, e.target.value)}
className="h-10 w-full cursor-pointer p-1"
/>
</div>
{index !== 0 && stops.length > 2 && (
<Button
variant="ghost"
size="icon"
onClick={() => removeStop(index)}
className="shrink-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button onClick={addStop} variant="outline" size="sm" className="w-full">
<Plus className="h-4 w-4 mr-2" />
Add Stop
</Button>
</div>
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">Steps</h4>
<Input
type="number"
min={2}
max={100}
value={gradientCount}
onChange={(e) => setGradientCount(parseInt(e.target.value))}
/>
</div>
<Button
onClick={generateGradient}
disabled={gradientMutation.isPending}
className="w-full"
>
{gradientMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating..
</>
) : (
'Generate Gradient'
)}
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-xs">Color Stops</Label>
{stops.map((stop, index) => (
<div key={index} className="flex items-center gap-2">
<Input
type="color"
value={stop}
onChange={(e) => updateStop(index, e.target.value)}
className="w-9 h-9 p-1 shrink-0 cursor-pointer"
/>
<Input
type="text"
value={stop}
onChange={(e) => updateStop(index, e.target.value)}
className="font-mono text-xs flex-1"
/>
{index !== 0 && stops.length > 2 && (
<Button
variant="ghost"
size="icon-xs"
onClick={() => removeStop(index)}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
<Button onClick={addStop} variant="outline" size="sm" className="w-full">
<Plus className="h-3.5 w-3.5 mr-1.5" />
Add Stop
</Button>
</div>
<div className="space-y-2">
<Label className="text-xs">Steps</Label>
<Input
type="number"
min={2}
max={100}
value={gradientCount}
onChange={(e) => setGradientCount(parseInt(e.target.value))}
/>
</div>
<Button
onClick={generateGradient}
disabled={gradientMutation.isPending}
className="w-full"
>
{gradientMutation.isPending ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Generating...
</>
) : (
'Generate'
)}
</Button>
</CardContent>
</Card>
</div>
@@ -349,27 +341,27 @@ function PlaygroundContent() {
<Card className="h-full">
<CardHeader>
<CardTitle>
Generated Gradient {gradientResult.length > 0 && `(${gradientResult.length} colors)`}
Gradient {gradientResult.length > 0 && <span className="text-muted-foreground font-normal text-sm ml-1">({gradientResult.length})</span>}
</CardTitle>
</CardHeader>
<CardContent>
{gradientResult.length > 0 ? (
<div className="space-y-6">
<div className="space-y-5">
<div
className="h-24 w-full rounded-lg border shadow-inner"
className="h-16 w-full rounded-lg border"
style={{
background: `linear-gradient(to right, ${gradientResult.join(', ')})`,
}}
/>
<PaletteGrid colors={gradientResult} onColorClick={setColor} />
<div className="pt-4 border-t">
<div className="pt-3 border-t">
<ExportMenu colors={gradientResult} />
</div>
</div>
) : (
<div className="p-12 text-center text-muted-foreground">
<Layers className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Add color stops and click Generate to create your smooth gradient</p>
<div className="py-8 text-center text-muted-foreground text-xs">
<Layers className="h-8 w-8 mx-auto mb-2 opacity-20" />
<p>Add color stops and generate a smooth gradient</p>
</div>
)}
</CardContent>

View File

@@ -3,7 +3,6 @@
import * as React from 'react';
import { AppPage } from '@/components/layout/AppPage';
import { FaviconGenerator } from '@/components/favicon/FaviconGenerator';
import { Globe, Shield, Zap } from 'lucide-react';
export default function FaviconPage() {
return (
@@ -11,9 +10,7 @@ export default function FaviconPage() {
title="Favicon Generator"
description="Create a complete set of icons for your website including PWA manifest and HTML code."
>
<div className="w-full max-w-7xl mx-auto space-y-8 pb-12">
<FaviconGenerator />
</div>
<FaviconGenerator />
</AppPage>
);
}

View File

@@ -9,7 +9,7 @@ import BackToTop from '@/components/BackToTop';
export default function Home() {
return (
<main className="relative min-h-screen dark text-foreground">
<main className="relative min-h-screen text-foreground">
<AnimatedBackground />
<BackToTop />
<Hero />

View File

@@ -11,7 +11,6 @@ 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 { Car } from 'lucide-react';
import { Card, CardContent } from '../ui/card';
export function ASCIIConverter() {

View File

@@ -61,111 +61,95 @@ export function FontPreview({ text, font, isLoading, onCopy, onDownload, onShare
<div className="flex items-center gap-2">
<CardTitle>Preview</CardTitle>
{font && (
<span className="text-xs px-2 py-0.5 bg-primary/10 text-primary rounded-md font-mono">
<span className="text-[10px] px-1.5 py-0.5 bg-primary/10 text-primary rounded font-mono">
{font}
</span>
)}
</div>
<div className="flex gap-2 flex-wrap">
<div className="flex gap-1.5 flex-wrap">
{onCopy && (
<Button variant="outline" size="sm" onClick={onCopy}>
<Copy className="h-3 w-3 mr-2" />
<Button variant="outline" size="xs" onClick={onCopy}>
<Copy className="h-3 w-3 mr-1" />
Copy
</Button>
)}
{onShare && (
<Button variant="outline" size="sm" onClick={onShare} title="Copy shareable URL">
<Share2 className="h-3 w-3 mr-2" />
<Button variant="outline" size="xs" onClick={onShare} title="Copy shareable URL">
<Share2 className="h-3 w-3 mr-1" />
Share
</Button>
)}
<Button variant="outline" size="sm" onClick={handleExportPNG} title="Export as PNG">
<ImageIcon className="h-3 w-3 mr-2" />
<Button variant="outline" size="xs" onClick={handleExportPNG} title="Export as PNG">
<ImageIcon className="h-3 w-3 mr-1" />
PNG
</Button>
{onDownload && (
<Button variant="outline" size="sm" onClick={onDownload}>
<Download className="h-3 w-3 mr-2" />
<Button variant="outline" size="xs" onClick={onDownload}>
<Download className="h-3 w-3 mr-1" />
TXT
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3">
{/* Controls */}
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1 border rounded-md p-1">
<div className="flex items-center border rounded-md p-0.5">
<button
onClick={() => setTextAlign('left')}
className={cn(
'p-1.5 rounded transition-colors',
'p-1 rounded transition-colors',
textAlign === 'left' ? 'bg-accent' : 'hover:bg-accent/50'
)}
title="Align left"
>
<AlignLeft className="h-3.5 w-3.5" />
<AlignLeft className="h-3 w-3" />
</button>
<button
onClick={() => setTextAlign('center')}
className={cn(
'p-1.5 rounded transition-colors',
'p-1 rounded transition-colors',
textAlign === 'center' ? 'bg-accent' : 'hover:bg-accent/50'
)}
title="Align center"
>
<AlignCenter className="h-3.5 w-3.5" />
<AlignCenter className="h-3 w-3" />
</button>
<button
onClick={() => setTextAlign('right')}
className={cn(
'p-1.5 rounded transition-colors',
'p-1 rounded transition-colors',
textAlign === 'right' ? 'bg-accent' : 'hover:bg-accent/50'
)}
title="Align right"
>
<AlignRight className="h-3.5 w-3.5" />
<AlignRight className="h-3 w-3" />
</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 className="flex items-center border rounded-md p-0.5">
{(['xs', 'sm', 'base'] as const).map((s) => (
<button
key={s}
onClick={() => setFontSize(s)}
className={cn(
'px-1.5 py-0.5 text-[10px] rounded transition-colors uppercase',
fontSize === s ? 'bg-accent' : 'hover:bg-accent/50'
)}
>
{s === 'base' ? 'md' : s}
</button>
))}
</div>
{!isLoading && text && (
<div className="flex gap-2 text-[10px] text-muted-foreground ml-auto">
<span>{lineCount} lines</span>
<span>{charCount} chars</span>
</div>
)}
</div>
{!isLoading && text && (
<div className="flex gap-4 text-xs text-muted-foreground">
<span>{lineCount} lines</span>
<span></span>
<span>{charCount} chars</span>
</div>
)}
<div
ref={previewRef}
className={cn(

View File

@@ -95,15 +95,15 @@ export function FontSelector({
return (
<Card className={cn("flex flex-col min-h-0 overflow-hidden", className)}>
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2 space-y-0">
<CardTitle>Select Font</CardTitle>
<CardTitle>Fonts</CardTitle>
{onRandomFont && (
<Button
variant="outline"
size="sm"
size="xs"
onClick={onRandomFont}
title="Random font"
>
<Shuffle className="h-3 w-3 mr-2" />
<Shuffle className="h-3 w-3 mr-1" />
Random
</Button>
)}
@@ -112,34 +112,34 @@ export function FontSelector({
<Tabs
value={filter}
onValueChange={(v) => setFilter(v as FilterType)}
className="mb-4 shrink-0"
className="mb-3 shrink-0"
>
<TabsList className="w-full">
<TabsTrigger value="all" className="flex-1">
<List className="h-3.5 w-3.5" />
<List className="h-3 w-3" />
All
</TabsTrigger>
<TabsTrigger value="favorites" className="flex-1">
<Heart className="h-3.5 w-3.5" />
Favorites
<Heart className="h-3 w-3" />
Fav
</TabsTrigger>
<TabsTrigger value="recent" className="flex-1">
<Clock className="h-3.5 w-3.5" />
<Clock className="h-3 w-3" />
Recent
</TabsTrigger>
</TabsList>
</Tabs>
{/* 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" />
<div className="relative mb-3 shrink-0">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="text"
placeholder="Search fonts..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-9"
className="pl-8 pr-8 h-8 text-sm"
/>
{searchQuery && (
<button
@@ -147,13 +147,13 @@ export function FontSelector({
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" />
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Font List */}
<div className="flex-1 overflow-y-auto space-y-1 pr-2 scrollbar">
<div className="flex-1 overflow-y-auto space-y-0.5 pr-1 scrollbar">
{filteredFonts.length === 0 ? (
<Empty>
<EmptyHeader>
@@ -185,26 +185,26 @@ export function FontSelector({
<div
key={font.name}
className={cn(
'group flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors',
'group flex items-center gap-1 px-2 py-1.5 rounded text-xs 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"
className="flex-1 text-left truncate"
>
{font.name}
</button>
<button
onClick={(e) => handleToggleFavorite(font.name, e)}
className="p-1"
className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
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'
'h-3 w-3 transition-colors',
isFavorite(font.name) ? 'fill-red-500 text-red-500 !opacity-100' : 'text-muted-foreground/50 hover:text-red-500/50'
)}
/>
</button>
@@ -214,10 +214,10 @@ export function FontSelector({
</div>
{/* Stats */}
<div className="mt-4 pt-4 border-t text-xs text-muted-foreground shrink-0">
<div className="mt-3 pt-3 border-t text-[10px] text-muted-foreground shrink-0">
{filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''}
{filter === 'favorites' && ` ${favorites.length} total favorites`}
{filter === 'recent' && ` ${recentFonts.length} recent`}
{filter === 'favorites' && ` · ${favorites.length} favorites`}
{filter === 'recent' && ` · ${recentFonts.length} recent`}
</div>
</CardContent>
</Card>

View File

@@ -48,48 +48,43 @@ export function ColorInfo({ info, className }: ColorInfoProps) {
];
return (
<div className={cn('space-y-4', className)}>
<div className="grid grid-cols-1 gap-3">
<div className={cn('space-y-3', className)}>
<div className="grid grid-cols-1 gap-1.5">
{formats.map((format) => (
<div
key={format.label}
className="flex items-center justify-between p-3 bg-muted rounded-lg"
className="flex items-center justify-between px-3 py-2 bg-muted/50 rounded-md group"
>
<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 className="flex items-baseline gap-2 min-w-0 flex-1">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground w-10 shrink-0">{format.label}</span>
<span className="font-mono text-xs truncate">{format.value}</span>
</div>
<Button
size="icon"
size="icon-xs"
variant="ghost"
onClick={() => copyToClipboard(format.value, format.label)}
aria-label={`Copy ${format.label} value`}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<Copy className="h-4 w-4" />
<Copy className="h-3 w-3" />
</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 className="grid grid-cols-3 gap-3 pt-2 border-t text-xs">
<div>
<div className="text-muted-foreground mb-0.5">Brightness</div>
<div className="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="text-muted-foreground mb-0.5">Luminance</div>
<div className="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>
<div className="text-muted-foreground mb-0.5">{info.name && typeof info.name === 'string' ? 'Name' : 'Type'}</div>
<div className="font-medium">{info.name && typeof info.name === 'string' ? info.name : (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>
);

View File

@@ -2,6 +2,7 @@
import { HexColorPicker } from 'react-colorful';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils/cn';
import { hexToRgb } from '@/lib/color/utils/color';
@@ -29,20 +30,20 @@ export function ColorPicker({ color, onChange, className }: ColorPickerProps) {
const textColor = getContrastColor(color);
return (
<div className={cn('flex flex-col items-center justify-center space-y-4', className)}>
<div className="w-full max-w-[200px] space-y-4">
<div className={cn('flex flex-col items-center justify-center space-y-3', className)}>
<div className="w-full max-w-[200px] space-y-3">
<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>
<div className="space-y-1.5">
<Label htmlFor="color-input" className="text-xs">
Hex Value
</Label>
<Input
id="color-input"
type="text"
value={color}
onChange={handleInputChange}
placeholder="#ff0099 or rgb(255, 0, 153)"
className="font-mono transition-colors duration-200"
placeholder="#ff0099"
className="font-mono text-xs transition-colors duration-200"
style={{
backgroundColor: color,
color: textColor,

View File

@@ -118,76 +118,70 @@ export function ExportMenu({ colors, className }: ExportMenuProps) {
return (
<div className={className}>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<h3 className="text-sm font-medium">Export Format</h3>
<Select
value={format}
onValueChange={(value) => setFormat(value as ExportFormat)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="css">CSS Variables</SelectItem>
<SelectItem value="scss">SCSS Variables</SelectItem>
<SelectItem value="tailwind">Tailwind Config</SelectItem>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="javascript">JavaScript Array</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="flex gap-3">
<Select
value={format}
onValueChange={(value) => setFormat(value as ExportFormat)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="css">CSS Variables</SelectItem>
<SelectItem value="scss">SCSS Variables</SelectItem>
<SelectItem value="tailwind">Tailwind Config</SelectItem>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="javascript">JavaScript Array</SelectItem>
</SelectContent>
</Select>
<div className="space-y-2">
<h3 className="text-sm font-medium">Color Space</h3>
<Select
value={colorSpace}
onValueChange={(value) => setColorSpace(value as ColorSpace)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select space" />
</SelectTrigger>
<SelectContent>
<SelectItem value="hex">Hex</SelectItem>
<SelectItem value="rgb">RGB</SelectItem>
<SelectItem value="hsl">HSL</SelectItem>
<SelectItem value="lab">Lab</SelectItem>
<SelectItem value="oklab">OkLab</SelectItem>
<SelectItem value="lch">LCH</SelectItem>
<SelectItem value="oklch">OkLCH</SelectItem>
</SelectContent>
</Select>
</div>
<Select
value={colorSpace}
onValueChange={(value) => setColorSpace(value as ColorSpace)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Space" />
</SelectTrigger>
<SelectContent>
<SelectItem value="hex">Hex</SelectItem>
<SelectItem value="rgb">RGB</SelectItem>
<SelectItem value="hsl">HSL</SelectItem>
<SelectItem value="lab">Lab</SelectItem>
<SelectItem value="oklab">OkLab</SelectItem>
<SelectItem value="lch">LCH</SelectItem>
<SelectItem value="oklch">OkLCH</SelectItem>
</SelectContent>
</Select>
</div>
<div className="p-4 bg-muted rounded-lg relative min-h-[100px]">
<div className="p-3 bg-muted/50 rounded-lg relative min-h-[80px]">
{isConverting ? (
<div className="absolute inset-0 flex items-center justify-center bg-muted/50 backdrop-blur-sm rounded-lg z-10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : null}
<pre className="text-xs overflow-x-auto">
<pre className="text-[11px] overflow-x-auto leading-relaxed">
<code>{getExportContent()}</code>
</pre>
</div>
<div className="flex gap-2 flex-col md:flex-row">
<Button onClick={handleCopy} variant="outline" className="w-full md:flex-1" disabled={isConverting}>
<div className="flex gap-2">
<Button onClick={handleCopy} variant="outline" size="sm" className="flex-1" disabled={isConverting}>
{copied ? (
<>
<Check className="h-4 w-4 mr-2" />
Copied!
<Check className="h-3.5 w-3.5 mr-1.5" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
<Copy className="h-3.5 w-3.5 mr-1.5" />
Copy
</>
)}
</Button>
<Button onClick={handleDownload} variant="default" className="w-full md:flex-1" disabled={isConverting}>
<Download className="h-4 w-4 mr-2" />
<Button onClick={handleDownload} variant="default" size="sm" className="flex-1" disabled={isConverting}>
<Download className="h-3.5 w-3.5 mr-1.5" />
Download
</Button>
</div>

View File

@@ -12,12 +12,25 @@ import {
useComplement
} from '@/lib/color/api/queries';
import { toast } from 'sonner';
import { Sun, Moon, Droplets, Droplet, RotateCcw, ArrowLeftRight } from 'lucide-react';
interface ManipulationPanelProps {
color: string;
onColorChange: (color: string) => void;
}
interface ManipulationRow {
label: string;
icon: React.ReactNode;
value: number;
setValue: (v: number) => void;
format: (v: number) => string;
min: number;
max: number;
step: number;
onApply: () => Promise<void>;
}
export function ManipulationPanel({ color, onColorChange }: ManipulationPanelProps) {
const [lightenAmount, setLightenAmount] = useState(0.2);
const [darkenAmount, setDarkenAmount] = useState(0.2);
@@ -32,93 +45,6 @@ export function ManipulationPanel({ color, onColorChange }: ManipulationPanelPro
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 ||
@@ -127,108 +53,151 @@ export function ManipulationPanel({ color, onColorChange }: ManipulationPanelPro
rotateMutation.isPending ||
complementMutation.isPending;
const handleMutation = async (
mutationFn: (params: any) => Promise<any>,
params: any,
successMsg: string,
errorMsg: string
) => {
try {
const result = await mutationFn(params);
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success(successMsg);
}
} catch {
toast.error(errorMsg);
}
};
const rows: ManipulationRow[] = [
{
label: 'Lighten',
icon: <Sun className="h-3.5 w-3.5" />,
value: lightenAmount,
setValue: setLightenAmount,
format: (v) => `${(v * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05,
onApply: () => handleMutation(
lightenMutation.mutateAsync,
{ colors: [color], amount: lightenAmount },
`Lightened by ${(lightenAmount * 100).toFixed(0)}%`,
'Failed to lighten color'
),
},
{
label: 'Darken',
icon: <Moon className="h-3.5 w-3.5" />,
value: darkenAmount,
setValue: setDarkenAmount,
format: (v) => `${(v * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05,
onApply: () => handleMutation(
darkenMutation.mutateAsync,
{ colors: [color], amount: darkenAmount },
`Darkened by ${(darkenAmount * 100).toFixed(0)}%`,
'Failed to darken color'
),
},
{
label: 'Saturate',
icon: <Droplets className="h-3.5 w-3.5" />,
value: saturateAmount,
setValue: setSaturateAmount,
format: (v) => `${(v * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05,
onApply: () => handleMutation(
saturateMutation.mutateAsync,
{ colors: [color], amount: saturateAmount },
`Saturated by ${(saturateAmount * 100).toFixed(0)}%`,
'Failed to saturate color'
),
},
{
label: 'Desaturate',
icon: <Droplet className="h-3.5 w-3.5" />,
value: desaturateAmount,
setValue: setDesaturateAmount,
format: (v) => `${(v * 100).toFixed(0)}%`,
min: 0, max: 1, step: 0.05,
onApply: () => handleMutation(
desaturateMutation.mutateAsync,
{ colors: [color], amount: desaturateAmount },
`Desaturated by ${(desaturateAmount * 100).toFixed(0)}%`,
'Failed to desaturate color'
),
},
{
label: 'Rotate',
icon: <RotateCcw className="h-3.5 w-3.5" />,
value: rotateAmount,
setValue: setRotateAmount,
format: (v) => `${v}°`,
min: -180, max: 180, step: 5,
onApply: () => handleMutation(
rotateMutation.mutateAsync,
{ colors: [color], amount: rotateAmount },
`Rotated hue by ${rotateAmount}°`,
'Failed to rotate hue'
),
},
];
const handleComplement = async () => {
try {
const result = await complementMutation.mutateAsync([color]);
if (result.colors[0]) {
onColorChange(result.colors[0].output);
toast.success('Generated complementary color');
}
} catch {
toast.error('Failed to generate complement');
}
};
return (
<div className="space-y-6">
{/* Lighten */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Lighten</label>
<span className="text-xs text-muted-foreground">{(lightenAmount * 100).toFixed(0)}%</span>
<div className="space-y-4">
{rows.map((row) => (
<div key={row.label} className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs font-medium">
{row.icon}
<span>{row.label}</span>
</div>
<span className="text-[10px] text-muted-foreground tabular-nums">{row.format(row.value)}</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}
size="sm"
variant="outline"
className="shrink-0 w-16"
>
Apply
</Button>
</div>
</div>
<Slider
min={0}
max={1}
step={0.05}
value={[lightenAmount]}
onValueChange={(vals) => setLightenAmount(vals[0])}
/>
<Button onClick={handleLighten} disabled={isLoading} className="w-full">
Apply Lighten
</Button>
</div>
))}
{/* Darken */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Darken</label>
<span className="text-xs text-muted-foreground">{(darkenAmount * 100).toFixed(0)}%</span>
</div>
<Slider
min={0}
max={1}
step={0.05}
value={[darkenAmount]}
onValueChange={(vals) => setDarkenAmount(vals[0])}
/>
<Button onClick={handleDarken} disabled={isLoading} className="w-full">
Apply Darken
</Button>
</div>
{/* Saturate */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Saturate</label>
<span className="text-xs text-muted-foreground">{(saturateAmount * 100).toFixed(0)}%</span>
</div>
<Slider
min={0}
max={1}
step={0.05}
value={[saturateAmount]}
onValueChange={(vals) => setSaturateAmount(vals[0])}
/>
<Button onClick={handleSaturate} disabled={isLoading} className="w-full">
Apply Saturate
</Button>
</div>
{/* Desaturate */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Desaturate</label>
<span className="text-xs text-muted-foreground">{(desaturateAmount * 100).toFixed(0)}%</span>
</div>
<Slider
min={0}
max={1}
step={0.05}
value={[desaturateAmount]}
onValueChange={(vals) => setDesaturateAmount(vals[0])}
/>
<Button onClick={handleDesaturate} disabled={isLoading} className="w-full">
Apply Desaturate
</Button>
</div>
{/* Rotate Hue */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Rotate Hue</label>
<span className="text-xs text-muted-foreground">{rotateAmount}°</span>
</div>
<Slider
min={-180}
max={180}
step={5}
value={[rotateAmount]}
onValueChange={(vals) => setRotateAmount(vals[0])}
/>
<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>
<div className="pt-3 border-t">
<Button
onClick={handleComplement}
disabled={isLoading}
variant="outline"
className="w-full"
size="sm"
>
Get Complementary Color
<ArrowLeftRight className="h-3.5 w-3.5 mr-1.5" />
Complementary Color
</Button>
</div>
</div>

View File

@@ -88,14 +88,14 @@ export function FaviconFileUpload({
/>
{selectedFile ? (
<div className="border border-border rounded-lg p-4 bg-card/50 backdrop-blur-sm">
<div className="flex items-start gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<FileImage className="h-6 w-6 text-primary" />
<div className="border border-border rounded-xl p-4 bg-card/50 backdrop-blur-sm">
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg shrink-0">
<FileImage className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-foreground truncate" title={selectedFile.name}>
<p className="text-sm font-medium text-foreground truncate" title={selectedFile.name}>
{selectedFile.name}
</p>
<Button
@@ -103,18 +103,18 @@ export function FaviconFileUpload({
size="icon-xs"
onClick={onFileRemove}
disabled={disabled}
className="rounded-full hover:bg-destructive/10 hover:text-destructive"
className="rounded-full hover:bg-destructive/10 hover:text-destructive shrink-0"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="mt-2 flex gap-4 text-[10px] text-muted-foreground uppercase tracking-wider font-bold">
<div className="flex items-center gap-1.5">
<div className="mt-1.5 flex gap-3 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
<span>{(selectedFile.size / 1024).toFixed(1)} KB</span>
</div>
{dimensions && (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1">
<FileImage className="h-3 w-3" />
<span>{dimensions}</span>
</div>
@@ -131,23 +131,23 @@ export function FaviconFileUpload({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
'hover:border-primary/50 hover:bg-primary/5',
'border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200',
'hover:border-primary/40 hover:bg-primary/5',
{
'border-primary bg-primary/10 scale-[0.98]': isDragging,
'border-border bg-muted/30': !isDragging,
'border-border/50': !isDragging,
'opacity-50 cursor-not-allowed': disabled,
}
)}
>
<div className="bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Upload className="h-8 w-8 text-primary" />
<div className="bg-primary/10 w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3">
<Upload className="h-6 w-6 text-primary" />
</div>
<p className="text-sm font-semibold text-foreground mb-1">
<p className="text-sm font-medium text-foreground mb-0.5">
Drop icon source here
</p>
<p className="text-xs text-muted-foreground">
Recommended: 512x512 PNG or SVG
<p className="text-[10px] text-muted-foreground">
512x512 PNG or SVG recommended
</p>
</div>
)}

View File

@@ -1,9 +1,9 @@
'use client';
import * as React from 'react';
import { Download, Loader2, RefreshCw, Code2, Globe, Layout, Palette } from 'lucide-react';
import { Download, Loader2, Code2, Globe, Layout } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
@@ -76,22 +76,16 @@ export function FaviconGenerator() {
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Settings Column */}
<div className="lg:col-span-4 space-y-6">
<Card className="glass">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layout className="h-5 w-5 text-primary" />
App Details
</CardTitle>
<CardDescription>
Configure how your app appears on devices
</CardDescription>
<CardTitle>App Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="app-name">Application Name</Label>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="app-name" className="text-xs">Application Name</Label>
<Input
id="app-name"
value={options.name}
@@ -99,8 +93,8 @@ export function FaviconGenerator() {
placeholder="e.g. My Awesome Website"
/>
</div>
<div className="space-y-2">
<Label htmlFor="short-name">Short Name</Label>
<div className="space-y-1.5">
<Label htmlFor="short-name" className="text-xs">Short Name</Label>
<Input
id="short-name"
value={options.shortName}
@@ -112,22 +106,19 @@ export function FaviconGenerator() {
</CardContent>
</Card>
<Card className="glass">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5 text-primary" />
Theme Colors
</CardTitle>
<CardTitle>Theme Colors</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="bg-color">Background</Label>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="bg-color" className="text-xs">Background</Label>
<div className="flex gap-2">
<Input
id="bg-color"
type="color"
className="w-10 p-1 h-10 shrink-0"
className="w-9 p-1 h-9 shrink-0"
value={options.backgroundColor}
onChange={(e) => setOptions({ ...options, backgroundColor: e.target.value })}
/>
@@ -138,13 +129,13 @@ export function FaviconGenerator() {
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="theme-color">Theme</Label>
<div className="space-y-1.5">
<Label htmlFor="theme-color" className="text-xs">Theme</Label>
<div className="flex gap-2">
<Input
id="theme-color"
type="color"
className="w-10 p-1 h-10 shrink-0"
className="w-9 p-1 h-9 shrink-0"
value={options.themeColor}
onChange={(e) => setOptions({ ...options, themeColor: e.target.value })}
/>
@@ -159,8 +150,8 @@ export function FaviconGenerator() {
</CardContent>
</Card>
<Card className="glass overflow-hidden">
<CardContent className="p-6">
<Card className="overflow-hidden">
<CardContent>
<FaviconFileUpload
selectedFile={sourceFile}
onFileSelect={setSourceFile}
@@ -168,21 +159,17 @@ export function FaviconGenerator() {
disabled={isGenerating}
/>
<Button
className="w-full mt-6"
size="lg"
className="w-full mt-4"
disabled={!sourceFile || isGenerating}
onClick={handleGenerate}
>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
Generating... {progress}%
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Generate Favicons
</>
'Generate Favicons'
)}
</Button>
{result && (
@@ -200,58 +187,51 @@ export function FaviconGenerator() {
{/* Results Column */}
<div className="lg:col-span-8 space-y-6">
{!result && !isGenerating ? (
<Card className="glass h-full flex flex-col items-center justify-center p-12 text-center text-muted-foreground border-dashed border-2">
<Globe className="h-12 w-12 mb-4 opacity-20" />
<h3 className="text-lg font-semibold text-foreground">Ready to generate</h3>
<p className="max-w-xs text-sm">Upload a square image (PNG or SVG recommended) and configure your app details to get started.</p>
</Card>
) : isGenerating ? (
<Card className="glass h-full flex flex-col items-center justify-center p-12 space-y-6">
<div className="relative">
<div className="h-24 w-24 rounded-full border-4 border-primary/20 animate-pulse" />
<Loader2 className="h-12 w-12 text-primary animate-spin absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
</div>
<div className="w-full max-w-sm space-y-2">
<div className="flex justify-between text-xs font-bold uppercase tracking-widest text-muted-foreground">
<span>Processing Icons</span>
<span>{progress}%</span>
{isGenerating ? (
<Card className="h-full flex flex-col items-center justify-center p-10 space-y-4">
<Loader2 className="h-6 w-6 text-primary animate-spin" />
<div className="w-full max-w-xs space-y-2">
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="font-medium">Processing...</span>
</div>
<span className="tabular-nums">{progress}%</span>
</div>
<Progress value={progress} className="h-1.5" />
<Progress value={progress} className="h-1" />
</div>
</Card>
) : (
<div className="space-y-6 animate-fadeIn">
) : result ? (
<div className="space-y-5 animate-fade-in">
<div className="flex items-center justify-between gap-4">
<h2 className="text-2xl font-bold tracking-tight">Generated Assets</h2>
<Button onClick={handleDownloadAll} variant="default" className="shadow-lg shadow-primary/20">
<Download className="mr-2 h-4 w-4" />
Download All (ZIP)
<h2 className="text-lg font-bold">Generated Assets</h2>
<Button onClick={handleDownloadAll}>
<Download className="mr-1.5 h-3.5 w-3.5" />
Download ZIP
</Button>
</div>
<Tabs defaultValue="icons" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="icons" className="flex items-center gap-2">
<Layout className="h-4 w-4" />
<TabsTrigger value="icons" className="flex items-center gap-1.5">
<Layout className="h-3.5 w-3.5" />
Icons
</TabsTrigger>
<TabsTrigger value="html" className="flex items-center gap-2">
<Code2 className="h-4 w-4" />
HTML Code
<TabsTrigger value="html" className="flex items-center gap-1.5">
<Code2 className="h-3.5 w-3.5" />
HTML
</TabsTrigger>
<TabsTrigger value="manifest" className="flex items-center gap-2">
<Globe className="h-4 w-4" />
<TabsTrigger value="manifest" className="flex items-center gap-1.5">
<Globe className="h-3.5 w-3.5" />
Manifest
</TabsTrigger>
</TabsList>
<TabsContent value="icons" className="mt-6">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<TabsContent value="icons" className="mt-4">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{result?.icons.map((icon) => (
<Card key={icon.name} className="glass group overflow-hidden">
<div className="p-4 flex flex-col items-center text-center space-y-3">
<div className="relative h-20 w-20 flex items-center justify-center bg-black/20 rounded-lg p-2 border border-border group-hover:scale-110 transition-transform duration-300">
<Card key={icon.name} className="group overflow-hidden">
<div className="p-3 flex flex-col items-center text-center space-y-2">
<div className="relative h-16 w-16 flex items-center justify-center bg-muted/50 rounded-lg p-1.5 border border-border/50 group-hover:scale-105 transition-transform duration-200">
{icon.previewUrl && (
<img
src={icon.previewUrl}
@@ -260,12 +240,12 @@ export function FaviconGenerator() {
/>
)}
</div>
<div className="space-y-1 w-full">
<p className="text-[10px] font-bold text-foreground truncate uppercase tracking-tighter" title={icon.name}>
<div className="space-y-0.5 w-full">
<p className="text-[10px] font-medium text-foreground truncate" title={icon.name}>
{icon.name}
</p>
<p className="text-[10px] text-muted-foreground">
{icon.width}x{icon.height} {(icon.size / 1024).toFixed(1)} KB
{icon.width}x{icon.height} · {(icon.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
@@ -274,27 +254,27 @@ export function FaviconGenerator() {
</div>
</TabsContent>
<TabsContent value="html" className="mt-6 space-y-4">
<div className="space-y-2">
<Label>Embed in your &lt;head&gt;</Label>
<TabsContent value="html" className="mt-4 space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Embed in your &lt;head&gt;</Label>
{result && <CodeSnippet code={result.htmlCode} />}
</div>
<div className="p-4 rounded-lg bg-info/5 border border-info/20">
<p className="text-xs text-info leading-relaxed">
<strong>Note:</strong> Make sure to place the generated files in your website&apos;s root directory or update the <code>href</code> paths accordingly.
<div className="p-3 rounded-lg bg-primary/5 border border-primary/10">
<p className="text-[11px] text-muted-foreground leading-relaxed">
Place generated files in your site root or update the <code className="text-primary">href</code> paths.
</p>
</div>
</TabsContent>
<TabsContent value="manifest" className="mt-6">
<div className="space-y-2">
<Label>site.webmanifest</Label>
<TabsContent value="manifest" className="mt-4">
<div className="space-y-1.5">
<Label className="text-xs">site.webmanifest</Label>
{result && <CodeSnippet code={result.manifest} />}
</div>
</TabsContent>
</Tabs>
</div>
)}
) : null}
</div>
</div>
);

View File

@@ -3,7 +3,7 @@
import * as React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Menu, Search, Bell, ChevronRight, Moon, Sun, X } from 'lucide-react';
import { Menu, ChevronRight, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils/cn';
import { useSidebar } from './SidebarProvider';
@@ -17,7 +17,7 @@ export function AppHeader() {
const pathSegments = pathname.split('/').filter(Boolean);
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 pl-8 pr-5 md:pr-9">
<header className="h-16 border-b border-border bg-background/10 backdrop-blur-xl sticky top-0 z-40 flex items-center justify-between pl-8 pr-5 md:pr-9 shadow-[0_1px_3px_0_rgb(0_0_0/0.05)]">
<div className="flex items-center gap-4">
<nav className="flex items-center text-sm font-medium">
<Link href="/" className="flex items-center gap-2">
@@ -32,7 +32,7 @@ export function AppHeader() {
return (
<React.Fragment key={href}>
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground/30" />
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground/50" />
<Link
href={href}
className={cn(

View File

@@ -12,12 +12,12 @@ interface AppPageProps {
export function AppPage({ title, description, children, className }: AppPageProps) {
return (
<div className={cn("min-h-screen py-12", className)}>
<div className="max-w-7xl mx-auto px-8 space-y-8">
<div className={cn("min-h-screen py-8", className)}>
<div className="max-w-7xl mx-auto px-8 space-y-6 animate-fade-in">
<div>
<h1 className="text-4xl font-bold mb-2">{title}</h1>
<h1 className="text-2xl font-bold mb-1">{title}</h1>
{description && (
<p className="text-muted-foreground">
<p className="text-sm text-muted-foreground">
{description}
</p>
)}

View File

@@ -74,7 +74,7 @@ export function AppSidebar() {
)}
<aside className={cn(
"fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border bg-background/10 backdrop-blur-2xl transition-all duration-300 ease-in-out lg:relative lg:h-full",
"fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border bg-background/20 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"
)}>
@@ -101,15 +101,10 @@ export function AppSidebar() {
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto px-4 py-2 space-y-8 mt-4 overflow-x-hidden">
<nav className="flex-1 overflow-y-auto px-4 py-2 space-y-6 mt-4 overflow-x-hidden">
{navigation.map((group) => (
<div key={group.label} className="space-y-2">
{!isCollapsed && (
<h4 className="px-3 text-xs font-semibold text-muted-foreground/50 uppercase tracking-wider">
{group.label}
</h4>
)}
<div className="space-y-1">
<div key={group.label}>
<div className="space-y-0.5">
{group.items.map((item) => {
const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
@@ -119,9 +114,9 @@ export function AppSidebar() {
href={item.href}
onClick={() => { if (window.innerWidth < 1024) close(); }}
className={cn(
"flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-300 relative group/item",
"flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-300 relative group/item",
isActive
? "bg-primary/10 text-primary ring-1 ring-primary/20"
? "bg-primary/10 text-primary border-l-2 border-primary"
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
isCollapsed ? "justify-center" : "justify-between"
)}
@@ -185,7 +180,7 @@ export function AppSidebar() {
) : (
<>
<ChevronLeft className="h-4 w-4" />
<span className="text-xs font-semibold uppercase tracking-wider">Collapse Sidebar</span>
<span className="text-xs font-semibold uppercase tracking-wider">Collapse</span>
</>
)}
</Button>

View File

@@ -3,6 +3,7 @@
import * as React from 'react';
import { Slider } from '@/components/ui/slider';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
@@ -37,7 +38,7 @@ export function ConversionOptionsPanel({
<div className="space-y-4">
{/* Video Codec */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Video Codec</label>
<Label>Video Codec</Label>
<Select
value={options.videoCodec || 'default'}
onValueChange={(value) => handleOptionChange('videoCodec', value === 'default' ? undefined : value)}
@@ -59,7 +60,7 @@ export function ConversionOptionsPanel({
{/* Video Bitrate */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Video Bitrate</label>
<Label>Video Bitrate</Label>
<span className="text-xs text-muted-foreground">{options.videoBitrate || '2M'}</span>
</div>
<Slider
@@ -75,7 +76,7 @@ export function ConversionOptionsPanel({
{/* Resolution */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Resolution</label>
<Label>Resolution</Label>
<Select
value={options.videoResolution || 'original'}
onValueChange={(value) => handleOptionChange('videoResolution', value === 'original' ? undefined : value)}
@@ -96,7 +97,7 @@ export function ConversionOptionsPanel({
{/* FPS */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Frame Rate (FPS)</label>
<Label>Frame Rate (FPS)</Label>
<Select
value={options.videoFps?.toString() || 'original'}
onValueChange={(value) => handleOptionChange('videoFps', value === 'original' ? undefined : parseInt(value))}
@@ -118,7 +119,7 @@ export function ConversionOptionsPanel({
{/* Audio Bitrate */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Audio Bitrate</label>
<Label>Audio Bitrate</Label>
<span className="text-xs text-muted-foreground">{options.audioBitrate || '128k'}</span>
</div>
<Slider
@@ -137,7 +138,7 @@ export function ConversionOptionsPanel({
<div className="space-y-4">
{/* Audio Codec */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Audio Codec</label>
<Label>Audio Codec</Label>
<Select
value={options.audioCodec || 'default'}
onValueChange={(value) => handleOptionChange('audioCodec', value === 'default' ? undefined : value)}
@@ -160,7 +161,7 @@ export function ConversionOptionsPanel({
{/* Bitrate */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Bitrate</label>
<Label>Bitrate</Label>
<span className="text-xs text-muted-foreground">{options.audioBitrate || '192k'}</span>
</div>
<Slider
@@ -175,7 +176,7 @@ export function ConversionOptionsPanel({
{/* Sample Rate */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Sample Rate</label>
<Label>Sample Rate</Label>
<Select
value={options.audioSampleRate?.toString() || 'original'}
onValueChange={(value) => handleOptionChange('audioSampleRate', value === 'original' ? undefined : parseInt(value))}
@@ -195,7 +196,7 @@ export function ConversionOptionsPanel({
{/* Channels */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Channels</label>
<Label>Channels</Label>
<Select
value={options.audioChannels?.toString() || 'original'}
onValueChange={(value) => handleOptionChange('audioChannels', value === 'original' ? undefined : parseInt(value))}
@@ -219,7 +220,7 @@ export function ConversionOptionsPanel({
{/* Quality */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Quality</label>
<Label>Quality</Label>
<span className="text-xs text-muted-foreground">{options.imageQuality || 85}%</span>
</div>
<Slider
@@ -234,7 +235,7 @@ export function ConversionOptionsPanel({
{/* Width */}
<div>
<label className="text-sm font-medium text-foreground mb-2 block">Width (px)</label>
<Label className="mb-2">Width (px)</Label>
<Input
type="number"
value={options.imageWidth || ''}
@@ -247,7 +248,7 @@ export function ConversionOptionsPanel({
{/* Height */}
<div>
<label className="text-sm font-medium text-foreground mb-2 block">Height (px)</label>
<Label className="mb-2">Height (px)</Label>
<Input
type="number"
value={options.imageHeight || ''}

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, FileCheck2, ArrowRight, RefreshCw } from 'lucide-react';
import { Download, CheckCircle, XCircle, Loader2, Clock, TrendingUp, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -85,7 +85,7 @@ export function ConversionPreview({ job, onDownload, onRetry }: ConversionPrevie
switch (category) {
case 'image':
return (
<div className="mt-4 rounded-lg overflow-hidden bg-muted/30 flex items-center justify-center p-4">
<div className="mt-3 rounded-lg overflow-hidden bg-muted/30 flex items-center justify-center p-4">
<img
src={previewUrl}
alt="Converted image preview"
@@ -107,7 +107,7 @@ export function ConversionPreview({ job, onDownload, onRetry }: ConversionPrevie
case 'video':
return (
<div className="mt-4 rounded-lg overflow-hidden bg-muted/30">
<div className="mt-3 rounded-lg overflow-hidden bg-muted/30">
<video src={previewUrl} controls className="w-full max-h-64">
Your browser does not support video playback.
</video>
@@ -140,38 +140,38 @@ export function ConversionPreview({ job, onDownload, onRetry }: ConversionPrevie
switch (job.status) {
case 'loading':
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-info">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm font-medium">Loading WASM converter...</span>
<div className="space-y-2">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span className="text-xs font-medium">Loading converter...</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
<span>Elapsed: {formatTime(elapsedTime)}</span>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{formatTime(elapsedTime)}</span>
</div>
</div>
);
case 'processing':
return (
<div className="space-y-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-info">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm font-medium">Converting...</span>
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span className="text-xs font-medium">Converting...</span>
</div>
<span className="text-xs text-muted-foreground">{job.progress}%</span>
<span className="text-[10px] text-muted-foreground tabular-nums">{job.progress}%</span>
</div>
<Progress value={job.progress} />
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5" />
<span>Elapsed: {formatTime(elapsedTime)}</span>
<Progress value={job.progress} className="h-1" />
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3" />
<span>{formatTime(elapsedTime)}</span>
</div>
{estimatedTimeRemaining && (
<div className="flex items-center gap-2">
<TrendingUp className="h-3.5 w-3.5" />
<span>~{formatTime(estimatedTimeRemaining)} remaining</span>
<div className="flex items-center gap-1.5">
<TrendingUp className="h-3 w-3" />
<span>~{formatTime(estimatedTimeRemaining)} left</span>
</div>
)}
</div>
@@ -184,39 +184,27 @@ export function ConversionPreview({ job, onDownload, onRetry }: ConversionPrevie
const sizeReduction = inputSize > 0 ? ((inputSize - outputSize) / inputSize) * 100 : 0;
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-success">
<CheckCircle className="h-5 w-5" />
<span className="text-sm font-medium">Conversion complete!</span>
<div className="space-y-2">
<div className="flex items-center gap-2">
<CheckCircle className="h-3.5 w-3.5 text-primary" />
<span className="text-xs font-medium">Complete</span>
</div>
{/* File size comparison */}
<div className="bg-muted/50 rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<FileCheck2 className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Input:</span>
</div>
<span className="font-medium">{formatFileSize(inputSize)}</span>
<div className="bg-muted/50 rounded-lg p-2.5 space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Input</span>
<span className="font-medium tabular-nums">{formatFileSize(inputSize)}</span>
</div>
<div className="flex items-center justify-center py-1">
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<FileCheck2 className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Output:</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">{formatFileSize(outputSize)}</span>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Output</span>
<div className="flex items-center gap-1.5">
<span className="font-medium tabular-nums">{formatFileSize(outputSize)}</span>
{Math.abs(sizeReduction) > 1 && (
<span className={cn(
"text-xs px-2 py-0.5 rounded-full",
"text-[10px] px-1.5 py-0.5 rounded-full",
sizeReduction > 0
? "bg-success/10 text-success"
: "bg-info/10 text-info"
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground"
)}>
{sizeReduction > 0 ? '-' : '+'}{Math.abs(sizeReduction).toFixed(0)}%
</span>
@@ -230,8 +218,8 @@ export function ConversionPreview({ job, onDownload, onRetry }: ConversionPrevie
case 'error':
return (
<div className="flex items-center gap-2 text-destructive">
<XCircle className="h-5 w-5" />
<span className="text-sm font-medium">Conversion failed</span>
<XCircle className="h-3.5 w-3.5" />
<span className="text-xs font-medium">Conversion failed</span>
</div>
);
@@ -245,48 +233,41 @@ export function ConversionPreview({ job, onDownload, onRetry }: ConversionPrevie
}
return (
<Card className="animate-fadeIn">
<Card className="animate-fade-in">
<CardHeader>
<CardTitle className="text-lg">Conversion Status</CardTitle>
<CardTitle className="text-sm">Conversion</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Status */}
<div className="space-y-3">
{renderStatus()}
{/* Error message */}
{job.error && (
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-3">
<p className="text-sm text-destructive">{job.error}</p>
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-2.5">
<p className="text-xs text-destructive">{job.error}</p>
</div>
)}
{/* Retry button */}
{job.status === 'error' && onRetry && (
<Button onClick={onRetry} variant="outline" className="w-full gap-2">
<RefreshCw className="h-4 w-4" />
Retry Conversion
<Button onClick={onRetry} variant="outline" className="w-full">
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Retry
</Button>
)}
{/* Preview */}
{job.status === 'completed' && renderPreview()}
{/* Download button */}
{job.status === 'completed' && job.result && (
<Button onClick={handleDownload} className="w-full" variant="default" size="lg">
<Download className="h-4 w-4 shrink-0" />
<Button onClick={handleDownload} className="w-full">
<Download className="h-3.5 w-3.5 shrink-0 mr-1.5" />
<span className="truncate min-w-0">
Download{' '}
{generateOutputFilename(job.inputFile.name, job.outputFormat.extension)}
</span>
</Button>
)}
{/* Duration */}
{job.status === 'completed' && job.startTime && job.endTime && (
<p className="text-xs text-muted-foreground text-center">
Completed in {((job.endTime - job.startTime) / 1000).toFixed(2)}s
<p className="text-[10px] text-muted-foreground text-center">
{((job.endTime - job.startTime) / 1000).toFixed(1)}s
</p>
)}
</div>

View File

@@ -3,6 +3,7 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Slider } from '@/components/ui/slider';
import {
@@ -366,13 +367,12 @@ export function FileConverter() {
const completedCount = conversionJobs.filter(job => job.status === 'completed').length;
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 w-full">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
{/* Left Column: Upload and Conversion Options */}
<div className="space-y-6">
{/* Upload Card */}
<Card className="glass">
<CardContent className="p-6">
{/* File upload */}
<Card>
<CardContent>
<FileUpload
onFileSelect={handleFileSelect}
onFileRemove={handleFileRemove}
@@ -386,14 +386,14 @@ export function FileConverter() {
{/* Conversion Options Card */}
{inputFormat && compatibleFormats.length > 0 && (
<Card className="glass">
<Card>
<CardHeader>
<CardTitle>Conversion Options</CardTitle>
<CardTitle>Options</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<CardContent className="space-y-4">
{/* Output Format Select */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Output Format</label>
<div className="space-y-1.5">
<Label className="text-xs">Output Format</Label>
<Select
value={outputFormat?.id || ''}
onValueChange={(formatId) => {
@@ -422,7 +422,7 @@ export function FileConverter() {
<div className="space-y-4 pt-4 border-t border-border">
{/* Video Codec */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Video Codec</label>
<Label className="text-xs">Video Codec</Label>
<Select
value={conversionOptions.videoCodec || 'default'}
onValueChange={(value) => setConversionOptions({ ...conversionOptions, videoCodec: value === 'default' ? undefined : value })}
@@ -444,7 +444,7 @@ export function FileConverter() {
{/* Video Bitrate */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Video Bitrate</label>
<Label className="text-xs">Video Bitrate</Label>
<span className="text-xs text-muted-foreground">{conversionOptions.videoBitrate || '2M'}</span>
</div>
<Slider
@@ -460,7 +460,7 @@ export function FileConverter() {
{/* Resolution */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Resolution</label>
<Label className="text-xs">Resolution</Label>
<Select
value={conversionOptions.videoResolution || 'original'}
onValueChange={(value) => setConversionOptions({ ...conversionOptions, videoResolution: value === 'original' ? undefined : value })}
@@ -481,7 +481,7 @@ export function FileConverter() {
{/* FPS */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Frame Rate (FPS)</label>
<Label className="text-xs">Frame Rate (FPS)</Label>
<Select
value={conversionOptions.videoFps?.toString() || 'original'}
onValueChange={(value) => setConversionOptions({ ...conversionOptions, videoFps: value === 'original' ? undefined : parseInt(value) })}
@@ -503,7 +503,7 @@ export function FileConverter() {
{/* Audio Bitrate */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Audio Bitrate</label>
<Label className="text-xs">Audio Bitrate</Label>
<span className="text-xs text-muted-foreground">{conversionOptions.audioBitrate || '128k'}</span>
</div>
<Slider
@@ -522,7 +522,7 @@ export function FileConverter() {
<div className="space-y-4 pt-4 border-t border-border">
{/* Audio Codec */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Audio Codec</label>
<Label className="text-xs">Audio Codec</Label>
<Select
value={conversionOptions.audioCodec || 'default'}
onValueChange={(value) => setConversionOptions({ ...conversionOptions, audioCodec: value === 'default' ? undefined : value })}
@@ -545,7 +545,7 @@ export function FileConverter() {
{/* Bitrate */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Bitrate</label>
<Label className="text-xs">Bitrate</Label>
<span className="text-xs text-muted-foreground">{conversionOptions.audioBitrate || '192k'}</span>
</div>
<Slider
@@ -560,7 +560,7 @@ export function FileConverter() {
{/* Sample Rate */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Sample Rate</label>
<Label className="text-xs">Sample Rate</Label>
<Select
value={conversionOptions.audioSampleRate?.toString() || 'original'}
onValueChange={(value) => setConversionOptions({ ...conversionOptions, audioSampleRate: value === 'original' ? undefined : parseInt(value) })}
@@ -580,7 +580,7 @@ export function FileConverter() {
{/* Channels */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground block">Channels</label>
<Label className="text-xs">Channels</Label>
<Select
value={conversionOptions.audioChannels?.toString() || 'original'}
onValueChange={(value) => setConversionOptions({ ...conversionOptions, audioChannels: value === 'original' ? undefined : parseInt(value) })}
@@ -604,7 +604,7 @@ export function FileConverter() {
{/* Quality */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">Quality</label>
<Label className="text-xs">Quality</Label>
<span className="text-xs text-muted-foreground">{conversionOptions.imageQuality || 85}%</span>
</div>
<Slider
@@ -619,7 +619,7 @@ export function FileConverter() {
{/* Width */}
<div>
<label className="text-sm font-medium text-foreground mb-2 block">Width (px)</label>
<Label className="text-xs mb-1.5">Width (px)</Label>
<Input
type="number"
value={conversionOptions.imageWidth || ''}
@@ -632,7 +632,7 @@ export function FileConverter() {
{/* Height */}
<div>
<label className="text-sm font-medium text-foreground mb-2 block">Height (px)</label>
<Label className="text-xs mb-1.5">Height (px)</Label>
<Input
type="number"
value={conversionOptions.imageHeight || ''}
@@ -652,13 +652,12 @@ export function FileConverter() {
onClick={handleConvert}
disabled={isConvertDisabled}
className="w-full"
size="lg"
>
{isConverting
? 'Converting...'
: `Convert ${selectedFiles.length} File${selectedFiles.length > 1 ? 's' : ''}`}
</Button>
<Button onClick={handleReset} variant="outline" size="lg" className="w-full">
<Button onClick={handleReset} variant="outline" className="w-full">
Reset
</Button>
</CardContent>
@@ -670,13 +669,11 @@ export function FileConverter() {
<div className="space-y-6">
{/* Download All Button */}
{completedCount > 0 && (
<Card className="glass">
<CardContent className="p-6">
<Card>
<CardContent>
<Button
onClick={handleDownloadAll}
className="w-full"
size="lg"
variant="default"
>
Download All ({completedCount} file{completedCount > 1 ? 's' : ''})
{completedCount > 1 && ' as ZIP'}

View File

@@ -246,13 +246,13 @@ export function FileUpload({
const metadata = fileMetadata[index];
return (
<div key={`${file.name}-${index}`} className="border border-border rounded-xl p-4 bg-card/50 backdrop-blur-sm">
<div className="flex items-start gap-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg shrink-0">
{getCategoryIcon()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold text-foreground truncate" title={file.name}>
<p className="text-sm font-medium text-foreground truncate" title={file.name}>
{file.name}
</p>
<Button
@@ -267,22 +267,22 @@ export function FileUpload({
</Button>
</div>
{metadata && (
<div className="mt-2 flex flex-wrap gap-4 text-[10px] text-muted-foreground uppercase tracking-wider font-bold">
<div className="mt-1.5 flex flex-wrap gap-3 text-[10px] text-muted-foreground">
{/* File Size */}
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
<span>{metadata.size}</span>
</div>
{/* Type */}
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1">
<File className="h-3 w-3" />
<span>{metadata.type}</span>
</div>
{/* Duration (for video/audio) */}
{metadata.duration && (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{metadata.duration}</span>
</div>
@@ -290,7 +290,7 @@ export function FileUpload({
{/* Dimensions */}
{metadata.dimensions && (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1">
{inputFormat?.category === 'video' ? (
<Film className="h-3 w-3" />
) : (
@@ -312,10 +312,9 @@ export function FileUpload({
variant="outline"
onClick={handleClick}
disabled={disabled}
className="w-full rounded-xl"
size="lg"
className="w-full"
>
<Upload className="h-4 w-4 mr-2" />
<Upload className="h-3.5 w-3.5 mr-1.5" />
Add More Files
</Button>
</div>
@@ -327,23 +326,23 @@ export function FileUpload({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all duration-300',
'hover:border-primary/50 hover:bg-primary/5',
'border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200',
'hover:border-primary/40 hover:bg-primary/5',
{
'border-primary bg-primary/10 scale-[0.98]': isDragging,
'border-border bg-muted/30': !isDragging,
'border-border/50': !isDragging,
'opacity-50 cursor-not-allowed': disabled,
}
)}
>
<div className="bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Upload className="h-8 w-8 text-primary" />
<div className="bg-primary/10 w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-3">
<Upload className="h-6 w-6 text-primary" />
</div>
<p className="text-sm font-semibold text-foreground mb-1">
Drop your files here or click to browse
<p className="text-sm font-medium text-foreground mb-0.5">
Drop files here or click to browse
</p>
<p className="text-xs text-muted-foreground">
Maximum file size: {maxSizeMB}MB per file
<p className="text-[10px] text-muted-foreground">
Max {maxSizeMB}MB per file
</p>
</div>
)}

View File

@@ -5,6 +5,7 @@ import Fuse from 'fuse.js';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
import type { ConversionFormat } from '@/types/media';
@@ -62,7 +63,7 @@ export function FormatSelector({
return (
<div className="w-full">
<label className="text-sm font-medium text-foreground mb-2 block">{label}</label>
<Label className="mb-2">{label}</Label>
{/* Search input */}
<div className="relative mb-3">

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
import { ArrowLeftRight, BarChart3 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select,
@@ -90,16 +91,15 @@ export default function MainConverter() {
}, [selectedUnit]);
return (
<div className="w-full space-y-8">
<div className="w-full space-y-6">
{/* Quick Access Row */}
<Card>
<CardContent className="flex flex-col md:flex-row md:items-center gap-4 justify-between">
<CardContent className="flex flex-col md:flex-row md:items-center gap-3 justify-between">
<div className="flex-1">
<SearchUnits onSelectUnit={handleSearchSelect} />
</div>
<div className="w-full md:w-64 shrink-0">
<div className="w-full md:w-56 shrink-0">
<Select
value={selectedMeasure}
onValueChange={(value) => setSelectedMeasure(value as Measure)}
@@ -124,12 +124,10 @@ export default function MainConverter() {
<CardHeader>
<CardTitle>Convert {formatMeasureName(selectedMeasure)}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Input row, stacks vertically on mobile, horizontal on desktop */}
<div className="flex flex-col gap-4 md:flex-row md:items-end md:gap-2">
{/* Value Input */}
<CardContent className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:gap-2">
<div className="flex-1 w-full">
<label className="text-sm font-medium mb-2 block">Value</label>
<Label className="text-xs mb-1.5">Value</Label>
<Input
type="text"
inputMode="decimal"
@@ -139,9 +137,8 @@ export default function MainConverter() {
className={cn("text-lg", "w-full", "max-w-full")}
/>
</div>
{/* From Unit Select */}
<div className="w-full md:w-40">
<label className="text-sm font-medium mb-2 block">From</label>
<div className="w-full md:w-36">
<Label className="text-xs mb-1.5">From</Label>
<Select
value={selectedUnit}
onValueChange={(value) => setSelectedUnit(value)}
@@ -158,19 +155,17 @@ export default function MainConverter() {
</SelectContent>
</Select>
</div>
{/* Swap Button */}
<Button
variant="outline"
size="icon"
size="icon-xs"
onClick={handleSwapUnits}
className="flex-shrink-0 w-full md:w-40" // Make button full width on mobile too
className="shrink-0 w-full md:w-7"
title="Swap units"
>
<ArrowLeftRight className="h-4 w-4" />
<ArrowLeftRight className="h-3.5 w-3.5" />
</Button>
{/* To Unit Select */}
<div className="w-full md:w-40">
<label className="text-sm font-medium mb-2 block">To</label>
<div className="w-full md:w-36">
<Label className="text-xs mb-1.5">To</Label>
<Select
value={targetUnit}
onValueChange={(value) => setTargetUnit(value)}
@@ -189,12 +184,11 @@ export default function MainConverter() {
</div>
</div>
{/* Quick result */}
{parseNumberInput(inputValue) !== null && (
<div className="p-4 rounded-lg bg-accent/50 border-l-4 border-primary">
<div className="text-sm text-muted-foreground">Result</div>
<div className="text-3xl font-bold mt-1 text-primary">
{formatNumber(convertUnit(parseNumberInput(inputValue)!, selectedUnit, targetUnit))} {targetUnit}
<div className="p-3 rounded-lg bg-primary/5 border border-primary/15">
<div className="text-xs text-muted-foreground mb-0.5">Result</div>
<div className="text-2xl font-bold text-primary tabular-nums">
{formatNumber(convertUnit(parseNumberInput(inputValue)!, selectedUnit, targetUnit))} <span className="text-base font-medium text-muted-foreground">{targetUnit}</span>
</div>
</div>
)}
@@ -208,11 +202,11 @@ export default function MainConverter() {
<CardTitle>All Conversions</CardTitle>
<Button
variant="outline"
size="sm"
size="xs"
onClick={() => setShowVisualComparison(!showVisualComparison)}
>
<BarChart3 className="h-4 w-4 mr-2" />
{showVisualComparison ? 'Grid View' : 'Chart View'}
<BarChart3 className="h-3 w-3 mr-1" />
{showVisualComparison ? 'Grid' : 'Chart'}
</Button>
</div>
</CardHeader>
@@ -223,25 +217,17 @@ export default function MainConverter() {
onValueChange={handleValueChange}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{conversions.map((conversion) => {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{conversions.map((conversion) => (
<div
key={conversion.unit}
className="group relative p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors border-l-4 border-l-primary/30"
className="p-3 rounded-lg border border-border/50 hover:border-primary/30 transition-colors"
>
<div className="text-sm text-muted-foreground mb-1">
{conversion.unitInfo.plural}
</div>
<div className="text-2xl font-bold">
{formatNumber(conversion.value)}
</div>
<div className="text-sm text-muted-foreground mt-1">
{conversion.unit}
</div>
<div className="text-xs text-muted-foreground">{conversion.unitInfo.plural}</div>
<div className="text-lg font-bold tabular-nums mt-0.5">{formatNumber(conversion.value)}</div>
<div className="text-xs text-muted-foreground">{conversion.unit}</div>
</div>
);
})}
))}
</div>
)}
</CardContent>