refactor: externalize tool definitions and polish app shell
- Create lib/tools.tsx as single source of truth for all tool metadata (title, shortTitle, navTitle, description, summary, icon, etc.) - Update AppSidebar to render nav from centralized tools list with descriptions, remove collapse footer button - Update AppHeader with sidebar collapse toggle, tool short title, and app logo; remove breadcrumbs - Update AppPage to auto-resolve tool icon from pathname - Update ToolsGrid/ToolCard to use shared tools data, remove per-card gradients for uniform styling - Add per-tool HTML title via metadata exports (title template in root layout) - Style landing page and 404 headings with primary theme color - Add Toolbox icon to hero CTA, GitFork icon link in footer - Remove footer from error page and "View on Dev" buttons - Extract ColorPage client component for RSC metadata compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,17 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import { ASCIIConverter } from '@/components/ascii/ASCIIConverter';
|
import { ASCIIConverter } from '@/components/ascii/ASCIIConverter';
|
||||||
import { AppPage } from '@/components/layout/AppPage';
|
import { AppPage } from '@/components/layout/AppPage';
|
||||||
|
import { getToolByHref } from '@/lib/tools';
|
||||||
|
|
||||||
|
const tool = getToolByHref('/ascii')!;
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tool.title };
|
||||||
|
|
||||||
export default function ASCIIPage() {
|
export default function ASCIIPage() {
|
||||||
return (
|
return (
|
||||||
<AppPage
|
<AppPage
|
||||||
title="ASCII Art Generator"
|
title={tool.title}
|
||||||
description="ASCII Art Text Generator with 373 Fonts"
|
description={tool.description}
|
||||||
>
|
>
|
||||||
<ASCIIConverter />
|
<ASCIIConverter />
|
||||||
</AppPage>
|
</AppPage>
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@plugin "@tailwindcss/typography";
|
|
||||||
|
|
||||||
@source "../components/color/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "../components/layout/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "../components/providers/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "../components/ui/*.{js,ts,jsx,tsx}";
|
|
||||||
@source "*.{js,ts,jsx,tsx}";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--radius: 0.5rem;
|
|
||||||
|
|
||||||
/* Light Mode Colors - Using OKLCH for better color precision */
|
|
||||||
--background: oklch(100% 0 0);
|
|
||||||
--foreground: oklch(9.8% 0.038 285.8);
|
|
||||||
--card: oklch(100% 0 0);
|
|
||||||
--card-foreground: oklch(9.8% 0.038 285.8);
|
|
||||||
--popover: oklch(100% 0 0);
|
|
||||||
--popover-foreground: oklch(9.8% 0.038 285.8);
|
|
||||||
--primary: oklch(22.4% 0.053 285.8);
|
|
||||||
--primary-foreground: oklch(98% 0.016 240);
|
|
||||||
--secondary: oklch(96.1% 0.016 240);
|
|
||||||
--secondary-foreground: oklch(22.4% 0.053 285.8);
|
|
||||||
--muted: oklch(96.1% 0.016 240);
|
|
||||||
--muted-foreground: oklch(46.9% 0.025 244.1);
|
|
||||||
--accent: oklch(96.1% 0.016 240);
|
|
||||||
--accent-foreground: oklch(22.4% 0.053 285.8);
|
|
||||||
--destructive: oklch(60.2% 0.168 29.2);
|
|
||||||
--destructive-foreground: oklch(98% 0.016 240);
|
|
||||||
--border: oklch(91.4% 0.026 243.1);
|
|
||||||
--input: oklch(91.4% 0.026 243.1);
|
|
||||||
--ring: oklch(9.8% 0.038 285.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
|
||||||
/* Tailwind v4 theme color definitions */
|
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
|
|
||||||
/* Custom Animations */
|
|
||||||
--animate-fade-in: fadeIn 0.3s ease-in-out;
|
|
||||||
--animate-slide-up: slideUp 0.4s ease-out;
|
|
||||||
--animate-slide-down: slideDown 0.4s ease-out;
|
|
||||||
--animate-slide-in-right: slideInRight 0.3s ease-out;
|
|
||||||
--animate-slide-in-left: slideInLeft 0.3s ease-out;
|
|
||||||
--animate-scale-in: scaleIn 0.2s ease-out;
|
|
||||||
--animate-bounce-gentle: bounceGentle 0.5s ease-in-out;
|
|
||||||
--animate-shimmer: shimmer 2s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: oklch(9.8% 0.038 285.8);
|
|
||||||
--foreground: oklch(98% 0.016 240);
|
|
||||||
--card: oklch(9.8% 0.038 285.8);
|
|
||||||
--card-foreground: oklch(98% 0.016 240);
|
|
||||||
--popover: oklch(9.8% 0.038 285.8);
|
|
||||||
--popover-foreground: oklch(98% 0.016 240);
|
|
||||||
--primary: oklch(98% 0.016 240);
|
|
||||||
--primary-foreground: oklch(22.4% 0.053 285.8);
|
|
||||||
--secondary: oklch(17.5% 0.036 242.3);
|
|
||||||
--secondary-foreground: oklch(98% 0.016 240);
|
|
||||||
--muted: oklch(17.5% 0.036 242.3);
|
|
||||||
--muted-foreground: oklch(65.1% 0.031 244);
|
|
||||||
--accent: oklch(17.5% 0.036 242.3);
|
|
||||||
--accent-foreground: oklch(98% 0.016 240);
|
|
||||||
--destructive: oklch(30.6% 0.125 29.2);
|
|
||||||
--destructive-foreground: oklch(98% 0.016 240);
|
|
||||||
--border: oklch(17.5% 0.036 242.3);
|
|
||||||
--input: oklch(17.5% 0.036 242.3);
|
|
||||||
--ring: oklch(83.9% 0.031 243.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border outline-ring/50;
|
|
||||||
transition-property: background-color, border-color, color, fill, stroke;
|
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
transition-duration: 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Disable transitions during theme switch to prevent flash */
|
|
||||||
.theme-transitioning * {
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
@apply bg-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
@apply bg-muted-foreground/20 rounded-lg hover:bg-muted-foreground/30;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Screen reader only */
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
border-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation Keyframes */
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from { transform: translateY(20px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideDown {
|
|
||||||
from { transform: translateY(-20px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideInRight {
|
|
||||||
from { transform: translateX(-20px); opacity: 0; }
|
|
||||||
to { transform: translateX(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideInLeft {
|
|
||||||
from { transform: translateX(20px); opacity: 0; }
|
|
||||||
to { transform: translateX(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes scaleIn {
|
|
||||||
from { transform: scale(0.95); opacity: 0; }
|
|
||||||
to { transform: scale(1); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounceGentle {
|
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
50% { transform: translateY(-5px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
from { background-position: -1000px 0; }
|
|
||||||
to { background-position: 1000px 0; }
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export default function ColorLayout({
|
|
||||||
children,
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,388 +1,11 @@
|
|||||||
'use client';
|
import type { Metadata } from 'next';
|
||||||
|
import { getToolByHref } from '@/lib/tools';
|
||||||
|
import { ColorPage } from '@/components/color/ColorPage';
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
const tool = getToolByHref('/color')!;
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
|
||||||
import { ColorPicker } from '@/components/color/ColorPicker';
|
|
||||||
import { ColorInfo } from '@/components/color/ColorInfo';
|
|
||||||
import { ManipulationPanel } from '@/components/color/ManipulationPanel';
|
|
||||||
import { PaletteGrid } from '@/components/color/PaletteGrid';
|
|
||||||
import { ExportMenu } from '@/components/color/ExportMenu';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { AppPage } from '@/components/layout/AppPage';
|
|
||||||
import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/color/api/queries';
|
|
||||||
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,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
type HarmonyType =
|
export const metadata: Metadata = { title: tool.title };
|
||||||
| 'monochromatic'
|
|
||||||
| 'analogous'
|
|
||||||
| 'complementary'
|
|
||||||
| 'triadic'
|
|
||||||
| 'tetradic';
|
|
||||||
|
|
||||||
function PlaygroundContent() {
|
export default function Page() {
|
||||||
const searchParams = useSearchParams();
|
return <ColorPage />;
|
||||||
const router = useRouter();
|
|
||||||
const [color, setColor] = useState(() => {
|
|
||||||
// Initialize from URL if available
|
|
||||||
const urlColor = searchParams.get('color');
|
|
||||||
return urlColor ? `#${urlColor.replace('#', '')}` : '#ff0099';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Harmony state
|
|
||||||
const [harmonyType, setHarmonyType] = useState<HarmonyType>('complementary');
|
|
||||||
const [palette, setPalette] = useState<string[]>([]);
|
|
||||||
const paletteMutation = useGeneratePalette();
|
|
||||||
|
|
||||||
// Gradient state
|
|
||||||
const [stops, setStops] = useState<string[]>(['#ff0099', '#0099ff']);
|
|
||||||
const [gradientCount, setGradientCount] = useState(10);
|
|
||||||
const [gradientResult, setGradientResult] = useState<string[]>([]);
|
|
||||||
const gradientMutation = useGenerateGradient();
|
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useColorInfo({
|
|
||||||
colors: [color],
|
|
||||||
});
|
|
||||||
|
|
||||||
const colorInfo = data?.colors[0];
|
|
||||||
|
|
||||||
// Update URL when color changes
|
|
||||||
useEffect(() => {
|
|
||||||
const hex = color.replace('#', '');
|
|
||||||
if (hex.length === 6 || hex.length === 3) {
|
|
||||||
router.push(`/color?color=${hex}`, { scroll: false });
|
|
||||||
}
|
|
||||||
}, [color, router]);
|
|
||||||
|
|
||||||
// Sync first gradient stop with active color
|
|
||||||
useEffect(() => {
|
|
||||||
const newStops = [...stops];
|
|
||||||
newStops[0] = color;
|
|
||||||
setStops(newStops);
|
|
||||||
}, [color]);
|
|
||||||
|
|
||||||
// Share color via URL
|
|
||||||
const handleShare = () => {
|
|
||||||
const url = `${window.location.origin}/color?color=${color.replace('#', '')}`;
|
|
||||||
navigator.clipboard.writeText(url);
|
|
||||||
toast.success('Link copied to clipboard!');
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateHarmony = async () => {
|
|
||||||
try {
|
|
||||||
const result = await paletteMutation.mutateAsync({
|
|
||||||
base: color,
|
|
||||||
scheme: harmonyType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const colors = [result.palette.primary, ...result.palette.secondary];
|
|
||||||
setPalette(colors);
|
|
||||||
toast.success(`Generated ${harmonyType} harmony palette`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to generate harmony palette');
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateGradient = async () => {
|
|
||||||
try {
|
|
||||||
const result = await gradientMutation.mutateAsync({
|
|
||||||
stops,
|
|
||||||
count: gradientCount,
|
|
||||||
});
|
|
||||||
setGradientResult(result.gradient);
|
|
||||||
toast.success(`Generated ${result.gradient.length} colors`);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to generate gradient');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addStop = () => {
|
|
||||||
setStops([...stops, '#000000']);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeStop = (index: number) => {
|
|
||||||
if (index === 0) return; // Prevent deleting the first stop (synchronized with picker)
|
|
||||||
if (stops.length > 2) {
|
|
||||||
setStops(stops.filter((_, i) => i !== index));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStop = (index: number, colorValue: string) => {
|
|
||||||
const newStops = [...stops];
|
|
||||||
newStops[index] = colorValue;
|
|
||||||
setStops(newStops);
|
|
||||||
if (index === 0) setColor(colorValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
const harmonyDescriptions: Record<HarmonyType, string> = {
|
|
||||||
monochromatic: 'Single color with variations',
|
|
||||||
analogous: 'Colors adjacent on the color wheel (±30°)',
|
|
||||||
complementary: 'Colors opposite on the color wheel (180°)',
|
|
||||||
triadic: 'Three colors evenly spaced on the color wheel (120°)',
|
|
||||||
tetradic: 'Four colors evenly spaced on the color wheel (90°)',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppPage
|
|
||||||
title="Color"
|
|
||||||
description="Interactive color manipulation and analysis tool"
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Row 1: Workspace */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
|
||||||
{/* Main Workspace: Color Picker and Information */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
|
||||||
<CardTitle>Color Picker</CardTitle>
|
|
||||||
<Button onClick={handleShare} variant="outline" size="xs">
|
|
||||||
<Share2 className="h-3 w-3 mr-1" />
|
|
||||||
Share
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-col md:flex-row gap-8">
|
|
||||||
<div className="flex-shrink-0 mx-auto md:mx-0">
|
|
||||||
<ColorPicker color={color} onChange={setColor} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
{isLoading && (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isError && (
|
|
||||||
<div className="p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
|
|
||||||
<p className="font-medium">Error loading color information</p>
|
|
||||||
<p className="mt-1">{error?.message || 'Unknown error'}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{colorInfo && <ColorInfo info={colorInfo} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar: Color Manipulation */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Adjustments</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ManipulationPanel color={color} onColorChange={setColor} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Harmony Generator */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
|
||||||
{/* Harmony Controls */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Harmony</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<Select
|
|
||||||
value={harmonyType}
|
|
||||||
onValueChange={(value) => setHarmonyType(value as HarmonyType)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Select harmony" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="monochromatic">Monochromatic</SelectItem>
|
|
||||||
<SelectItem value="analogous">Analogous</SelectItem>
|
|
||||||
<SelectItem value="complementary">Complementary</SelectItem>
|
|
||||||
<SelectItem value="triadic">Triadic</SelectItem>
|
|
||||||
<SelectItem value="tetradic">Tetradic (Square)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{harmonyDescriptions[harmonyType]}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={generateHarmony}
|
|
||||||
disabled={paletteMutation.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{paletteMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
||||||
Generating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Generate'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Harmony Results */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>
|
|
||||||
Palette {palette.length > 0 && <span className="text-muted-foreground font-normal text-sm ml-1">({palette.length})</span>}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{palette.length > 0 ? (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<PaletteGrid colors={palette} onColorClick={setColor} />
|
|
||||||
<div className="pt-3 border-t">
|
|
||||||
<ExportMenu colors={palette} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-8 text-center text-muted-foreground text-xs">
|
|
||||||
<Palette className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
|
||||||
<p>Generate a harmony palette from the current color</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 3: Gradient Generator */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
|
||||||
{/* Gradient Controls */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Gradient</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">Color Stops</Label>
|
|
||||||
{stops.map((stop, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="color"
|
|
||||||
value={stop}
|
|
||||||
onChange={(e) => updateStop(index, e.target.value)}
|
|
||||||
className="w-9 h-9 p-1 shrink-0 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={stop}
|
|
||||||
onChange={(e) => updateStop(index, e.target.value)}
|
|
||||||
className="font-mono text-xs flex-1"
|
|
||||||
/>
|
|
||||||
{index !== 0 && stops.length > 2 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
onClick={() => removeStop(index)}
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button onClick={addStop} variant="outline" className="w-full">
|
|
||||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Add Stop
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">Steps</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={2}
|
|
||||||
max={100}
|
|
||||||
value={gradientCount}
|
|
||||||
onChange={(e) => setGradientCount(parseInt(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={generateGradient}
|
|
||||||
disabled={gradientMutation.isPending}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{gradientMutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
||||||
Generating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Generate'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gradient Results */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<Card className="h-full">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>
|
|
||||||
Gradient {gradientResult.length > 0 && <span className="text-muted-foreground font-normal text-sm ml-1">({gradientResult.length})</span>}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{gradientResult.length > 0 ? (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div
|
|
||||||
className="h-16 w-full rounded-lg border"
|
|
||||||
style={{
|
|
||||||
background: `linear-gradient(to right, ${gradientResult.join(', ')})`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PaletteGrid colors={gradientResult} onColorClick={setColor} />
|
|
||||||
<div className="pt-3 border-t">
|
|
||||||
<ExportMenu colors={gradientResult} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AppPage>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlaygroundPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={
|
|
||||||
<div className="min-h-screen py-12">
|
|
||||||
<div className="max-w-7xl mx-auto px-8 flex items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<PlaygroundContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
'use client';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import { AppPage } from '@/components/layout/AppPage';
|
import { AppPage } from '@/components/layout/AppPage';
|
||||||
import { FaviconGenerator } from '@/components/favicon/FaviconGenerator';
|
import { FaviconGenerator } from '@/components/favicon/FaviconGenerator';
|
||||||
|
import { getToolByHref } from '@/lib/tools';
|
||||||
|
|
||||||
|
const tool = getToolByHref('/favicon')!;
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tool.title };
|
||||||
|
|
||||||
export default function FaviconPage() {
|
export default function FaviconPage() {
|
||||||
return (
|
return (
|
||||||
<AppPage
|
<AppPage
|
||||||
title="Favicon Generator"
|
title={tool.title}
|
||||||
description="Create a complete set of icons for your website including PWA manifest and HTML code."
|
description={tool.description}
|
||||||
>
|
>
|
||||||
<FaviconGenerator />
|
<FaviconGenerator />
|
||||||
</AppPage>
|
</AppPage>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import { FileConverter } from '@/components/media/FileConverter';
|
import { FileConverter } from '@/components/media/FileConverter';
|
||||||
import { AppPage } from '@/components/layout/AppPage';
|
import { AppPage } from '@/components/layout/AppPage';
|
||||||
|
import { getToolByHref } from '@/lib/tools';
|
||||||
|
|
||||||
|
const tool = getToolByHref('/media')!;
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tool.title };
|
||||||
|
|
||||||
export default function MediaPage() {
|
export default function MediaPage() {
|
||||||
return (
|
return (
|
||||||
<AppPage
|
<AppPage
|
||||||
title="Media Converter"
|
title={tool.title}
|
||||||
description="Professional browser-based media conversion for video, audio, and images"
|
description={tool.description}
|
||||||
>
|
>
|
||||||
<FileConverter />
|
<FileConverter />
|
||||||
</AppPage>
|
</AppPage>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
import MainConverter from '@/components/units/MainConverter';
|
import MainConverter from '@/components/units/MainConverter';
|
||||||
import { AppPage } from '@/components/layout/AppPage';
|
import { AppPage } from '@/components/layout/AppPage';
|
||||||
|
import { getToolByHref } from '@/lib/tools';
|
||||||
|
|
||||||
|
const tool = getToolByHref('/units')!;
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: tool.title };
|
||||||
|
|
||||||
export default function UnitsPage() {
|
export default function UnitsPage() {
|
||||||
return (
|
return (
|
||||||
<AppPage
|
<AppPage
|
||||||
title="Units Converter"
|
title={tool.title}
|
||||||
description="Smart unit converter with 187 units across 23 categories"
|
description={tool.description}
|
||||||
>
|
>
|
||||||
<MainConverter />
|
<MainConverter />
|
||||||
</AppPage>
|
</AppPage>
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import './globals.css';
|
|||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Kit - Your Creative Toolkit',
|
title: {
|
||||||
|
default: 'Kit - Your Creative Toolkit',
|
||||||
|
template: '%s | Kit',
|
||||||
|
},
|
||||||
description: 'A curated collection of creative and utility tools for developers and creators. Features file conversion, image editing, and color manipulation.',
|
description: 'A curated collection of creative and utility tools for developers and creators. Features file conversion, image editing, and color manipulation.',
|
||||||
keywords: ['tools', 'utilities', 'file converter', 'image editor', 'color palette', 'creative toolkit', 'convert', 'paint', 'color', 'open source'],
|
keywords: ['tools', 'utilities', 'file converter', 'image editor', 'color palette', 'creative toolkit', 'convert', 'paint', 'color', 'open source'],
|
||||||
metadataBase: new URL(siteUrl),
|
metadataBase: new URL(siteUrl),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import AnimatedBackground from '@/components/AnimatedBackground';
|
import AnimatedBackground from '@/components/AnimatedBackground';
|
||||||
import Footer from '@/components/Footer';
|
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Home } from 'lucide-react';
|
import { Home } from 'lucide-react';
|
||||||
@@ -28,7 +27,7 @@ export default function NotFound() {
|
|||||||
|
|
||||||
{/* 404 heading */}
|
{/* 404 heading */}
|
||||||
<motion.h1
|
<motion.h1
|
||||||
className="text-7xl md:text-9xl font-bold mb-6 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400"
|
className="text-7xl md:text-9xl font-bold mb-6 text-primary"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
@@ -71,8 +70,6 @@ export default function NotFound() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Footer />
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { GitFork } from 'lucide-react';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
@@ -15,36 +16,20 @@ export default function Footer() {
|
|||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.6 }}
|
transition={{ duration: 0.6 }}
|
||||||
>
|
>
|
||||||
{/* Brand Section */}
|
{/* Copyright */}
|
||||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full border border-primary/50 bg-primary/5">
|
|
||||||
<span className="text-base font-bold bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-cyan-400">Kit</span>
|
|
||||||
<span className="text-base text-muted-foreground/30">•</span>
|
|
||||||
<span className="text-base text-primary font-medium">Open Source</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Copyright - centered */}
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
© {currentYear} Kit. Built with Next.js 16 & Tailwind CSS 4
|
© {currentYear} Kit. Built with Next.js 16 & Tailwind CSS 4
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dev Link */}
|
{/* Source link */}
|
||||||
<a
|
<a
|
||||||
href="https://dev.pivoine.art/valknar/kit-ui"
|
href="https://dev.pivoine.art/valknar/kit-ui"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="group flex items-center gap-3 px-4 py-2 rounded-full border border-border hover:border-primary transition-all duration-300 bg-card/50"
|
title="View source"
|
||||||
|
className="text-muted-foreground hover:text-primary transition-colors duration-300"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
<GitFork className="h-5 w-5" />
|
||||||
<line x1="6" y1="3" x2="6" y2="15" strokeLinecap="round" />
|
|
||||||
<circle cx="18" cy="6" r="3" />
|
|
||||||
<circle cx="6" cy="18" r="3" />
|
|
||||||
<path d="M18 9a9 9 0 01-9 9" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm text-muted-foreground group-hover:text-primary transition-colors font-medium">
|
|
||||||
View on Dev
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import { Toolbox } from 'lucide-react';
|
||||||
import Logo from './Logo';
|
import Logo from './Logo';
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
@@ -29,7 +30,7 @@ export default function Hero() {
|
|||||||
|
|
||||||
{/* Main heading */}
|
{/* Main heading */}
|
||||||
<motion.h1
|
<motion.h1
|
||||||
className="text-6xl md:text-8xl font-bold mb-6 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 via-pink-400 to-cyan-400"
|
className="text-6xl md:text-8xl font-bold mb-6 text-primary"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 0.2 }}
|
transition={{ duration: 0.8, delay: 0.2 }}
|
||||||
@@ -71,7 +72,10 @@ export default function Hero() {
|
|||||||
whileHover={{ scale: 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<span className="relative z-10">Explore Tools</span>
|
<span className="relative z-10 inline-flex items-center gap-2">
|
||||||
|
<Toolbox className="h-5 w-5" />
|
||||||
|
Explore Tools
|
||||||
|
</span>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-0 bg-gradient-to-r from-purple-600 to-cyan-600"
|
className="absolute inset-0 bg-gradient-to-r from-purple-600 to-cyan-600"
|
||||||
initial={{ x: '100%' }}
|
initial={{ x: '100%' }}
|
||||||
@@ -80,22 +84,6 @@ export default function Hero() {
|
|||||||
/>
|
/>
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<motion.a
|
|
||||||
href="https://dev.pivoine.art/valknar/kit-ui"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="group px-8 py-4 rounded-full border-2 border-gray-600 text-gray-300 font-semibold hover:border-purple-400 hover:text-purple-400 transition-all duration-300 inline-flex items-center gap-2"
|
|
||||||
whileHover={{ scale: 1.05 }}
|
|
||||||
whileTap={{ scale: 0.95 }}
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
|
|
||||||
<line x1="6" y1="3" x2="6" y2="15" strokeLinecap="round" />
|
|
||||||
<circle cx="18" cy="6" r="3" />
|
|
||||||
<circle cx="6" cy="18" r="3" />
|
|
||||||
<path d="M18 9a9 9 0 01-9 9" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
View on Dev
|
|
||||||
</motion.a>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Scroll indicator */}
|
{/* Scroll indicator */}
|
||||||
|
|||||||
@@ -11,13 +11,11 @@ interface ToolCardProps {
|
|||||||
description: string;
|
description: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
url: string;
|
url: string;
|
||||||
gradient: string;
|
|
||||||
accentColor: string;
|
|
||||||
index: number;
|
index: number;
|
||||||
badges?: string[];
|
badges?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ToolCard({ title, description, icon, url, gradient, accentColor, index, badges }: ToolCardProps) {
|
export default function ToolCard({ title, description, icon, url, index, badges }: ToolCardProps) {
|
||||||
return (
|
return (
|
||||||
<MotionLink
|
<MotionLink
|
||||||
href={url}
|
href={url}
|
||||||
@@ -29,15 +27,8 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
|
|||||||
whileHover={{ y: -10 }}
|
whileHover={{ y: -10 }}
|
||||||
>
|
>
|
||||||
<div className="glass relative overflow-hidden rounded-2xl p-8 h-full transition-all duration-300 group-hover:shadow-2xl group-hover:bg-card/80">
|
<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 */}
|
{/* Subtle hover overlay */}
|
||||||
<div
|
<div className="absolute inset-0 opacity-0 group-hover:opacity-10 transition-opacity duration-300 bg-primary" />
|
||||||
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-30`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -45,15 +36,13 @@ export default function ToolCard({ title, description, icon, url, gradient, acce
|
|||||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||||
transition={{ type: 'spring', stiffness: 300 }}
|
transition={{ type: 'spring', stiffness: 300 }}
|
||||||
>
|
>
|
||||||
<div className={`p-4 rounded-xl ${gradient} shadow-lg shadow-black/10`}>
|
<div className="p-4 rounded-xl bg-primary/10 text-primary shadow-lg shadow-black/5">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<h3
|
<h3 className="text-2xl font-bold mb-3 text-foreground transition-all duration-300 group-hover:text-primary">
|
||||||
className="text-2xl font-bold mb-3 text-foreground transition-all duration-300 group-hover:text-primary"
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -2,55 +2,7 @@
|
|||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import ToolCard from './ToolCard';
|
import ToolCard from './ToolCard';
|
||||||
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon } from '@/components/AppIcons';
|
import { tools } from '@/lib/tools';
|
||||||
|
|
||||||
const tools = [
|
|
||||||
{
|
|
||||||
title: 'Color',
|
|
||||||
description: 'Modern color manipulation toolkit with palette generation, accessibility testing, and format conversion. Supports hex, RGB, HSL, Lab, and more.',
|
|
||||||
url: '/color',
|
|
||||||
gradient: 'gradient-indigo-purple',
|
|
||||||
accentColor: '#a855f7',
|
|
||||||
badges: ['Open Source', 'WCAG', 'Free'],
|
|
||||||
icon: <ColorIcon className="w-12 h-12 text-white" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Units',
|
|
||||||
description: 'Smart unit converter with 187 units across 23 categories. Real-time bidirectional conversion with fuzzy search.',
|
|
||||||
url: '/units',
|
|
||||||
gradient: 'gradient-cyan-purple',
|
|
||||||
accentColor: '#2dd4bf',
|
|
||||||
badges: ['Open Source', 'Real-time', 'Free'],
|
|
||||||
icon: <UnitsIcon className="w-12 h-12 text-white" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'ASCII',
|
|
||||||
description: 'ASCII art text generator with 373 fonts. Create stunning text banners, terminal art, and retro designs with live preview and multiple export formats.',
|
|
||||||
url: '/ascii',
|
|
||||||
gradient: 'gradient-yellow-amber',
|
|
||||||
accentColor: '#eab308',
|
|
||||||
badges: ['Open Source', 'ASCII Art', 'Free'],
|
|
||||||
icon: <ASCIIIcon className="w-12 h-12 text-white" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Media',
|
|
||||||
description: 'Modern browser-based file converter powered by WebAssembly. Convert videos, images, and audio locally without server uploads. Privacy-first with no file size limits.',
|
|
||||||
url: '/media',
|
|
||||||
gradient: 'gradient-green-teal',
|
|
||||||
accentColor: '#10b981',
|
|
||||||
badges: ['Open Source', 'Converter', 'Free'],
|
|
||||||
icon: <MediaIcon className="w-12 h-12 text-white" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Favicon',
|
|
||||||
description: 'Generate a complete set of favicons for your website. Includes PWA manifest and HTML embed code. All processing happens locally in your browser.',
|
|
||||||
url: '/favicon',
|
|
||||||
gradient: 'gradient-blue-cyan',
|
|
||||||
accentColor: '#3b82f6',
|
|
||||||
badges: ['Open Source', 'Generator', 'Free'],
|
|
||||||
icon: <FaviconIcon className="w-12 h-12 text-white" />,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ToolsGrid() {
|
export default function ToolsGrid() {
|
||||||
return (
|
return (
|
||||||
@@ -74,19 +26,20 @@ export default function ToolsGrid() {
|
|||||||
|
|
||||||
{/* Tools grid */}
|
{/* Tools grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{tools.map((tool, index) => (
|
{tools.map((tool, index) => {
|
||||||
|
const Icon = tool.icon;
|
||||||
|
return (
|
||||||
<ToolCard
|
<ToolCard
|
||||||
key={tool.title}
|
key={tool.href}
|
||||||
title={tool.title}
|
title={tool.title}
|
||||||
description={tool.description}
|
description={tool.summary}
|
||||||
icon={tool.icon}
|
icon={<Icon className="w-12 h-12" />}
|
||||||
url={tool.url}
|
url={tool.href}
|
||||||
gradient={tool.gradient}
|
|
||||||
accentColor={tool.accentColor}
|
|
||||||
badges={tool.badges}
|
badges={tool.badges}
|
||||||
index={index}
|
index={index}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
391
components/color/ColorPage.tsx
Normal file
391
components/color/ColorPage.tsx
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { ColorPicker } from '@/components/color/ColorPicker';
|
||||||
|
import { ColorInfo } from '@/components/color/ColorInfo';
|
||||||
|
import { ManipulationPanel } from '@/components/color/ManipulationPanel';
|
||||||
|
import { PaletteGrid } from '@/components/color/PaletteGrid';
|
||||||
|
import { ExportMenu } from '@/components/color/ExportMenu';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { AppPage } from '@/components/layout/AppPage';
|
||||||
|
import { useColorInfo, useGeneratePalette, useGenerateGradient } from '@/lib/color/api/queries';
|
||||||
|
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,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { getToolByHref } from '@/lib/tools';
|
||||||
|
|
||||||
|
const tool = getToolByHref('/color')!;
|
||||||
|
|
||||||
|
type HarmonyType =
|
||||||
|
| 'monochromatic'
|
||||||
|
| 'analogous'
|
||||||
|
| 'complementary'
|
||||||
|
| 'triadic'
|
||||||
|
| 'tetradic';
|
||||||
|
|
||||||
|
function PlaygroundContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [color, setColor] = useState(() => {
|
||||||
|
// Initialize from URL if available
|
||||||
|
const urlColor = searchParams.get('color');
|
||||||
|
return urlColor ? `#${urlColor.replace('#', '')}` : '#ff0099';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Harmony state
|
||||||
|
const [harmonyType, setHarmonyType] = useState<HarmonyType>('complementary');
|
||||||
|
const [palette, setPalette] = useState<string[]>([]);
|
||||||
|
const paletteMutation = useGeneratePalette();
|
||||||
|
|
||||||
|
// Gradient state
|
||||||
|
const [stops, setStops] = useState<string[]>(['#ff0099', '#0099ff']);
|
||||||
|
const [gradientCount, setGradientCount] = useState(10);
|
||||||
|
const [gradientResult, setGradientResult] = useState<string[]>([]);
|
||||||
|
const gradientMutation = useGenerateGradient();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, error } = useColorInfo({
|
||||||
|
colors: [color],
|
||||||
|
});
|
||||||
|
|
||||||
|
const colorInfo = data?.colors[0];
|
||||||
|
|
||||||
|
// Update URL when color changes
|
||||||
|
useEffect(() => {
|
||||||
|
const hex = color.replace('#', '');
|
||||||
|
if (hex.length === 6 || hex.length === 3) {
|
||||||
|
router.push(`/color?color=${hex}`, { scroll: false });
|
||||||
|
}
|
||||||
|
}, [color, router]);
|
||||||
|
|
||||||
|
// Sync first gradient stop with active color
|
||||||
|
useEffect(() => {
|
||||||
|
const newStops = [...stops];
|
||||||
|
newStops[0] = color;
|
||||||
|
setStops(newStops);
|
||||||
|
}, [color]);
|
||||||
|
|
||||||
|
// Share color via URL
|
||||||
|
const handleShare = () => {
|
||||||
|
const url = `${window.location.origin}/color?color=${color.replace('#', '')}`;
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
toast.success('Link copied to clipboard!');
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateHarmony = async () => {
|
||||||
|
try {
|
||||||
|
const result = await paletteMutation.mutateAsync({
|
||||||
|
base: color,
|
||||||
|
scheme: harmonyType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const colors = [result.palette.primary, ...result.palette.secondary];
|
||||||
|
setPalette(colors);
|
||||||
|
toast.success(`Generated ${harmonyType} harmony palette`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to generate harmony palette');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateGradient = async () => {
|
||||||
|
try {
|
||||||
|
const result = await gradientMutation.mutateAsync({
|
||||||
|
stops,
|
||||||
|
count: gradientCount,
|
||||||
|
});
|
||||||
|
setGradientResult(result.gradient);
|
||||||
|
toast.success(`Generated ${result.gradient.length} colors`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to generate gradient');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addStop = () => {
|
||||||
|
setStops([...stops, '#000000']);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStop = (index: number) => {
|
||||||
|
if (index === 0) return; // Prevent deleting the first stop (synchronized with picker)
|
||||||
|
if (stops.length > 2) {
|
||||||
|
setStops(stops.filter((_, i) => i !== index));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStop = (index: number, colorValue: string) => {
|
||||||
|
const newStops = [...stops];
|
||||||
|
newStops[index] = colorValue;
|
||||||
|
setStops(newStops);
|
||||||
|
if (index === 0) setColor(colorValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const harmonyDescriptions: Record<HarmonyType, string> = {
|
||||||
|
monochromatic: 'Single color with variations',
|
||||||
|
analogous: 'Colors adjacent on the color wheel (±30°)',
|
||||||
|
complementary: 'Colors opposite on the color wheel (180°)',
|
||||||
|
triadic: 'Three colors evenly spaced on the color wheel (120°)',
|
||||||
|
tetradic: 'Four colors evenly spaced on the color wheel (90°)',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppPage
|
||||||
|
title={tool.title}
|
||||||
|
description={tool.description}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Row 1: Workspace */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
||||||
|
{/* Main Workspace: Color Picker and Information */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<CardTitle>Color Picker</CardTitle>
|
||||||
|
<Button onClick={handleShare} variant="outline" size="xs">
|
||||||
|
<Share2 className="h-3 w-3 mr-1" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col md:flex-row gap-8">
|
||||||
|
<div className="flex-shrink-0 mx-auto md:mx-0">
|
||||||
|
<ColorPicker color={color} onChange={setColor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="p-3 bg-destructive/10 text-destructive rounded-lg text-sm">
|
||||||
|
<p className="font-medium">Error loading color information</p>
|
||||||
|
<p className="mt-1">{error?.message || 'Unknown error'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{colorInfo && <ColorInfo info={colorInfo} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar: Color Manipulation */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Adjustments</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ManipulationPanel color={color} onColorChange={setColor} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Harmony Generator */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
||||||
|
{/* Harmony Controls */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Harmony</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Select
|
||||||
|
value={harmonyType}
|
||||||
|
onValueChange={(value) => setHarmonyType(value as HarmonyType)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select harmony" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="monochromatic">Monochromatic</SelectItem>
|
||||||
|
<SelectItem value="analogous">Analogous</SelectItem>
|
||||||
|
<SelectItem value="complementary">Complementary</SelectItem>
|
||||||
|
<SelectItem value="triadic">Triadic</SelectItem>
|
||||||
|
<SelectItem value="tetradic">Tetradic (Square)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{harmonyDescriptions[harmonyType]}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={generateHarmony}
|
||||||
|
disabled={paletteMutation.isPending}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{paletteMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Generate'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Harmony Results */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Palette {palette.length > 0 && <span className="text-muted-foreground font-normal text-sm ml-1">({palette.length})</span>}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{palette.length > 0 ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<PaletteGrid colors={palette} onColorClick={setColor} />
|
||||||
|
<div className="pt-3 border-t">
|
||||||
|
<ExportMenu colors={palette} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-xs">
|
||||||
|
<Palette className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
||||||
|
<p>Generate a harmony palette from the current color</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Gradient Generator */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
||||||
|
{/* Gradient Controls */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Gradient</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Color Stops</Label>
|
||||||
|
{stops.map((stop, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
value={stop}
|
||||||
|
onChange={(e) => updateStop(index, e.target.value)}
|
||||||
|
className="w-9 h-9 p-1 shrink-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={stop}
|
||||||
|
onChange={(e) => updateStop(index, e.target.value)}
|
||||||
|
className="font-mono text-xs flex-1"
|
||||||
|
/>
|
||||||
|
{index !== 0 && stops.length > 2 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() => removeStop(index)}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button onClick={addStop} variant="outline" className="w-full">
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Add Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Steps</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={2}
|
||||||
|
max={100}
|
||||||
|
value={gradientCount}
|
||||||
|
onChange={(e) => setGradientCount(parseInt(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={generateGradient}
|
||||||
|
disabled={gradientMutation.isPending}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{gradientMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Generate'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gradient Results */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<Card className="h-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
Gradient {gradientResult.length > 0 && <span className="text-muted-foreground font-normal text-sm ml-1">({gradientResult.length})</span>}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{gradientResult.length > 0 ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div
|
||||||
|
className="h-16 w-full rounded-lg border"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, ${gradientResult.join(', ')})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PaletteGrid colors={gradientResult} onColorClick={setColor} />
|
||||||
|
<div className="pt-3 border-t">
|
||||||
|
<ExportMenu colors={gradientResult} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ColorPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen py-12">
|
||||||
|
<div className="max-w-7xl mx-auto px-8 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<PlaygroundContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,54 +1,34 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { Menu, X, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||||
import { Menu, ChevronRight, X } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
import { useSidebar } from './SidebarProvider';
|
import { useSidebar } from './SidebarProvider';
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
const pathname = usePathname();
|
const { toggle, isOpen, isCollapsed, toggleCollapse } = useSidebar();
|
||||||
const { toggle, isOpen } = useSidebar();
|
|
||||||
|
|
||||||
// Custom breadcrumb logic
|
|
||||||
const pathSegments = pathname.split('/').filter(Boolean);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="h-16 border-b border-border bg-background/10 backdrop-blur-xl sticky top-0 z-40 flex items-center justify-between pl-8 pr-5 md:pr-9 shadow-[0_1px_3px_0_rgb(0_0_0/0.05)]">
|
<header className="h-16 border-b border-border bg-background/10 backdrop-blur-xl sticky top-0 z-40 flex items-center justify-between px-6 shadow-[0_1px_3px_0_rgb(0_0_0/0.05)]">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2">
|
||||||
<nav className="flex items-center text-sm font-medium">
|
<Button
|
||||||
<Link href="/" className="flex items-center gap-2">
|
variant="ghost"
|
||||||
<Logo size={20} className="lg:hidden" />
|
size="icon"
|
||||||
<span className="font-medium transition-colors text-foreground/80 hover:text-foreground">
|
className="hidden lg:inline-flex text-muted-foreground hover:text-foreground"
|
||||||
Kit
|
onClick={toggleCollapse}
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
{pathSegments.map((segment, index) => {
|
|
||||||
const href = `/${pathSegments.slice(0, index + 1).join('/')}`;
|
|
||||||
const isLast = index === pathSegments.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={href}>
|
|
||||||
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground/50" />
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className={cn(
|
|
||||||
"capitalize transition-colors text-foreground/80 hover:text-foreground",
|
|
||||||
isLast ? "font-semibold" : "font-medium"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{segment.replace(/-/g, ' ')}
|
{isCollapsed ? (
|
||||||
|
<PanelLeftOpen className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<PanelLeftClose className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Link href="/" className="lg:hidden shrink-0 ml-2">
|
||||||
|
<Logo size={24} />
|
||||||
</Link>
|
</Link>
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -57,7 +37,6 @@ export function AppHeader() {
|
|||||||
>
|
>
|
||||||
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
{isOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getToolByHref } from '@/lib/tools';
|
||||||
|
|
||||||
interface AppPageProps {
|
interface AppPageProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,11 +13,19 @@ interface AppPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AppPage({ title, description, children, className }: AppPageProps) {
|
export function AppPage({ title, description, children, className }: AppPageProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const firstSegment = pathname.split('/').filter(Boolean)[0];
|
||||||
|
const tool = getToolByHref(`/${firstSegment ?? ''}`);
|
||||||
|
const Icon = tool?.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("min-h-screen py-8", className)}>
|
<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 className="max-w-7xl mx-auto px-8 space-y-6 animate-fade-in">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-1">{title}</h1>
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
{Icon && <Icon className="h-6 w-6 text-primary shrink-0" />}
|
||||||
|
<h1 className="text-2xl font-bold">{title}</h1>
|
||||||
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{description}
|
{description}
|
||||||
|
|||||||
@@ -1,67 +1,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import {
|
import { X } from 'lucide-react';
|
||||||
ChevronRight,
|
|
||||||
ChevronLeft,
|
|
||||||
X
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import Logo from '@/components/Logo';
|
import Logo from '@/components/Logo';
|
||||||
import { useSidebar } from './SidebarProvider';
|
import { useSidebar } from './SidebarProvider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon } from '@/components/AppIcons';
|
import { tools } from '@/lib/tools';
|
||||||
|
|
||||||
interface NavItem {
|
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
icon: React.ElementType | React.ReactNode;
|
|
||||||
items?: { title: string; href: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavGroup {
|
|
||||||
label: string;
|
|
||||||
items: NavItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigation: NavGroup[] = [
|
|
||||||
{
|
|
||||||
label: 'Toolkit',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: 'Units Converter',
|
|
||||||
href: '/units',
|
|
||||||
icon: <UnitsIcon className="h-4 w-4" />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'ASCII Art',
|
|
||||||
href: '/ascii',
|
|
||||||
icon: <ASCIIIcon className="h-4 w-4" />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Color Manipulation',
|
|
||||||
href: '/color',
|
|
||||||
icon: <ColorIcon className="h-4 w-4" />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Media Converter',
|
|
||||||
href: '/media',
|
|
||||||
icon: <MediaIcon className="h-4 w-4" />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Favicon Generator',
|
|
||||||
href: '/favicon',
|
|
||||||
icon: <FaviconIcon className="h-4 w-4" />
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isOpen, isCollapsed, close, toggleCollapse } = useSidebar();
|
const { isOpen, isCollapsed, close } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -76,20 +26,32 @@ export function AppSidebar() {
|
|||||||
<aside className={cn(
|
<aside className={cn(
|
||||||
"fixed inset-y-0 left-0 z-50 flex flex-col border-r border-border bg-background/20 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",
|
isOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
||||||
isCollapsed ? "lg:w-20" : "w-64"
|
isCollapsed ? "lg:w-14" : "w-64"
|
||||||
)}>
|
)}>
|
||||||
{/* Sidebar Header */}
|
{/* Sidebar Header */}
|
||||||
<div className="flex h-16 items-center justify-between px-6 shrink-0 border-b border-border">
|
<div className={cn(
|
||||||
<Link href="/" className="flex items-center gap-3 group overflow-hidden">
|
"flex h-16 items-center shrink-0 border-b border-border",
|
||||||
|
isCollapsed ? "justify-center px-2" : "justify-between px-5"
|
||||||
|
)}>
|
||||||
|
<Link href="/" className={cn(
|
||||||
|
"flex items-center group overflow-hidden",
|
||||||
|
isCollapsed ? "justify-center" : "gap-3"
|
||||||
|
)}>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<Logo size={isCollapsed ? 24 : 24} />
|
<Logo size={isCollapsed ? 20 : 28} />
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<span className="font-bold text-xl transition-colors text-foreground">
|
<div className="min-w-0">
|
||||||
|
<span className="font-bold text-lg leading-tight block text-foreground">
|
||||||
Kit
|
Kit
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-[10px] leading-tight text-muted-foreground block">
|
||||||
|
Browser-first toolkit
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
{!isCollapsed && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -98,93 +60,54 @@ export function AppSidebar() {
|
|||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 overflow-y-auto px-4 py-2 space-y-6 mt-4 overflow-x-hidden">
|
<nav className={cn(
|
||||||
{navigation.map((group) => (
|
"flex-1 overflow-y-auto py-2 space-y-6 mt-4 overflow-hidden scrollbar",
|
||||||
<div key={group.label}>
|
isCollapsed ? "px-2" : "px-4"
|
||||||
|
)}>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{group.items.map((item) => {
|
{tools.map((tool) => {
|
||||||
const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
|
const isActive = pathname === tool.href || (tool.href !== '/' && pathname.startsWith(tool.href));
|
||||||
|
const Icon = tool.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.href} className="space-y-1">
|
<div key={tool.href} className="space-y-1">
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={tool.href}
|
||||||
onClick={() => { if (window.innerWidth < 1024) close(); }}
|
onClick={() => { if (window.innerWidth < 1024) close(); }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-300 relative group/item",
|
"flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-300 relative group/item",
|
||||||
isActive
|
isActive
|
||||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||||
isCollapsed ? "justify-center" : "justify-between"
|
isCollapsed ? "justify-center" : "justify-between"
|
||||||
)}
|
)}
|
||||||
title={isCollapsed ? item.title : undefined}
|
title={isCollapsed ? tool.navTitle : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"transition-colors duration-300 shrink-0",
|
"transition-colors duration-300 shrink-0",
|
||||||
isActive ? "text-primary" : "text-foreground/80 group-hover/item:text-foreground"
|
isActive ? "text-primary" : "text-foreground/80 group-hover/item:text-foreground"
|
||||||
)}>
|
)}>
|
||||||
{React.isValidElement(item.icon) ? item.icon : null}
|
<Icon className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
{!isCollapsed && <span className="whitespace-nowrap">{item.title}</span>}
|
{!isCollapsed && (
|
||||||
</div>
|
<div className="min-w-0">
|
||||||
|
<span className="whitespace-nowrap block">{tool.navTitle}</span>
|
||||||
{!isCollapsed && item.items && (
|
<span className="text-[10px] text-muted-foreground leading-tight block line-clamp-2">{tool.description}</span>
|
||||||
<ChevronRight className={cn(
|
|
||||||
"h-3.5 w-3.5 transition-transform duration-300",
|
|
||||||
pathname.startsWith(item.href) && "rotate-90"
|
|
||||||
)} />
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{item.items && pathname.startsWith(item.href) && !isCollapsed && (
|
|
||||||
<div className="ml-9 space-y-1 border-l border-border pl-2 mt-1">
|
|
||||||
{item.items.map((subItem) => (
|
|
||||||
<Link
|
|
||||||
key={subItem.href}
|
|
||||||
href={subItem.href}
|
|
||||||
onClick={() => { if (window.innerWidth < 1024) close(); }}
|
|
||||||
className={cn(
|
|
||||||
"block px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200",
|
|
||||||
pathname === subItem.href
|
|
||||||
? "text-primary bg-primary/5 font-semibold"
|
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{subItem.title}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Sidebar Footer / Desktop Toggle */}
|
|
||||||
<div className="p-4 border-t border-border hidden lg:block">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full flex items-center justify-center gap-2 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={toggleCollapse}
|
|
||||||
>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider">Collapse</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
97
lib/tools.tsx
Normal file
97
lib/tools.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon } from '@/components/AppIcons';
|
||||||
|
|
||||||
|
export interface Tool {
|
||||||
|
/** Short display name (e.g. "Color") */
|
||||||
|
shortTitle: string;
|
||||||
|
/** Full display name (e.g. "Color Manipulation") */
|
||||||
|
title: string;
|
||||||
|
/** Sidebar / nav label (may differ from title) */
|
||||||
|
navTitle: string;
|
||||||
|
/** Route path, e.g. '/color' */
|
||||||
|
href: string;
|
||||||
|
/** One-liner shown in page header */
|
||||||
|
description: string;
|
||||||
|
/** Longer description for the landing-page card */
|
||||||
|
summary: string;
|
||||||
|
/** Icon component */
|
||||||
|
icon: React.ElementType;
|
||||||
|
/** Tailwind gradient utility class for the landing card */
|
||||||
|
gradient: string;
|
||||||
|
/** Hex accent color for the landing card */
|
||||||
|
accentColor: string;
|
||||||
|
/** Badge labels for the landing card */
|
||||||
|
badges: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools: Tool[] = [
|
||||||
|
{
|
||||||
|
shortTitle: 'Color',
|
||||||
|
title: 'Color Manipulation',
|
||||||
|
navTitle: 'Color Manipulation',
|
||||||
|
href: '/color',
|
||||||
|
description: 'Interactive color manipulation and analysis tool',
|
||||||
|
summary:
|
||||||
|
'Modern color manipulation toolkit with palette generation, accessibility testing, and format conversion. Supports hex, RGB, HSL, Lab, and more.',
|
||||||
|
icon: ColorIcon,
|
||||||
|
gradient: 'gradient-indigo-purple',
|
||||||
|
accentColor: '#a855f7',
|
||||||
|
badges: ['Open Source', 'WCAG', 'Free'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortTitle: 'Units',
|
||||||
|
title: 'Units Converter',
|
||||||
|
navTitle: 'Units Converter',
|
||||||
|
href: '/units',
|
||||||
|
description: 'Smart unit converter with 187 units across 23 categories',
|
||||||
|
summary:
|
||||||
|
'Smart unit converter with 187 units across 23 categories. Real-time bidirectional conversion with fuzzy search.',
|
||||||
|
icon: UnitsIcon,
|
||||||
|
gradient: 'gradient-cyan-purple',
|
||||||
|
accentColor: '#2dd4bf',
|
||||||
|
badges: ['Open Source', 'Real-time', 'Free'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortTitle: 'ASCII',
|
||||||
|
title: 'ASCII Art Generator',
|
||||||
|
navTitle: 'ASCII Art',
|
||||||
|
href: '/ascii',
|
||||||
|
description: 'ASCII Art Text Generator with 373 Fonts',
|
||||||
|
summary:
|
||||||
|
'ASCII art text generator with 373 fonts. Create stunning text banners, terminal art, and retro designs with live preview and multiple export formats.',
|
||||||
|
icon: ASCIIIcon,
|
||||||
|
gradient: 'gradient-yellow-amber',
|
||||||
|
accentColor: '#eab308',
|
||||||
|
badges: ['Open Source', 'ASCII Art', 'Free'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortTitle: 'Media',
|
||||||
|
title: 'Media Converter',
|
||||||
|
navTitle: 'Media Converter',
|
||||||
|
href: '/media',
|
||||||
|
description: 'Professional browser-based media conversion for video, audio, and images',
|
||||||
|
summary:
|
||||||
|
'Modern browser-based file converter powered by WebAssembly. Convert videos, images, and audio locally without server uploads. Privacy-first with no file size limits.',
|
||||||
|
icon: MediaIcon,
|
||||||
|
gradient: 'gradient-green-teal',
|
||||||
|
accentColor: '#10b981',
|
||||||
|
badges: ['Open Source', 'Converter', 'Free'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortTitle: 'Favicon',
|
||||||
|
title: 'Favicon Generator',
|
||||||
|
navTitle: 'Favicon Generator',
|
||||||
|
href: '/favicon',
|
||||||
|
description: 'Create a complete set of icons for your website including PWA manifest and HTML code.',
|
||||||
|
summary:
|
||||||
|
'Generate a complete set of favicons for your website. Includes PWA manifest and HTML embed code. All processing happens locally in your browser.',
|
||||||
|
icon: FaviconIcon,
|
||||||
|
gradient: 'gradient-blue-cyan',
|
||||||
|
accentColor: '#3b82f6',
|
||||||
|
badges: ['Open Source', 'Generator', 'Free'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Look up a tool by its href path */
|
||||||
|
export function getToolByHref(href: string): Tool | undefined {
|
||||||
|
return tools.find((t) => t.href === href);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user