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:
2026-02-27 17:46:54 +01:00
parent 5a0d1863ec
commit a400f694fe
18 changed files with 679 additions and 912 deletions

View File

@@ -1,11 +1,17 @@
import type { Metadata } from 'next';
import { ASCIIConverter } from '@/components/ascii/ASCIIConverter';
import { AppPage } from '@/components/layout/AppPage';
import { getToolByHref } from '@/lib/tools';
const tool = getToolByHref('/ascii')!;
export const metadata: Metadata = { title: tool.title };
export default function ASCIIPage() {
return (
<AppPage
title="ASCII Art Generator"
description="ASCII Art Text Generator with 373 Fonts"
title={tool.title}
description={tool.description}
>
<ASCIIConverter />
</AppPage>

View File

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

View File

@@ -1,11 +0,0 @@
export default function ColorLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<>
{children}
</>
);
}

View File

@@ -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';
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';
const tool = getToolByHref('/color')!;
type HarmonyType =
| 'monochromatic'
| 'analogous'
| 'complementary'
| 'triadic'
| 'tetradic';
export const metadata: Metadata = { title: tool.title };
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="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>
);
export default function Page() {
return <ColorPage />;
}

View File

@@ -1,14 +1,17 @@
'use client';
import * as React from 'react';
import type { Metadata } from 'next';
import { AppPage } from '@/components/layout/AppPage';
import { FaviconGenerator } from '@/components/favicon/FaviconGenerator';
import { getToolByHref } from '@/lib/tools';
const tool = getToolByHref('/favicon')!;
export const metadata: Metadata = { title: tool.title };
export default function FaviconPage() {
return (
<AppPage
title="Favicon Generator"
description="Create a complete set of icons for your website including PWA manifest and HTML code."
title={tool.title}
description={tool.description}
>
<FaviconGenerator />
</AppPage>

View File

@@ -1,11 +1,17 @@
import type { Metadata } from 'next';
import { FileConverter } from '@/components/media/FileConverter';
import { AppPage } from '@/components/layout/AppPage';
import { getToolByHref } from '@/lib/tools';
const tool = getToolByHref('/media')!;
export const metadata: Metadata = { title: tool.title };
export default function MediaPage() {
return (
<AppPage
title="Media Converter"
description="Professional browser-based media conversion for video, audio, and images"
title={tool.title}
description={tool.description}
>
<FileConverter />
</AppPage>

View File

@@ -1,11 +1,17 @@
import type { Metadata } from 'next';
import MainConverter from '@/components/units/MainConverter';
import { AppPage } from '@/components/layout/AppPage';
import { getToolByHref } from '@/lib/tools';
const tool = getToolByHref('/units')!;
export const metadata: Metadata = { title: tool.title };
export default function UnitsPage() {
return (
<AppPage
title="Units Converter"
description="Smart unit converter with 187 units across 23 categories"
<AppPage
title={tool.title}
description={tool.description}
>
<MainConverter />
</AppPage>

View File

@@ -4,7 +4,10 @@ import './globals.css';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
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.',
keywords: ['tools', 'utilities', 'file converter', 'image editor', 'color palette', 'creative toolkit', 'convert', 'paint', 'color', 'open source'],
metadataBase: new URL(siteUrl),

View File

@@ -3,7 +3,6 @@
import Link from 'next/link';
import { motion } from 'framer-motion';
import AnimatedBackground from '@/components/AnimatedBackground';
import Footer from '@/components/Footer';
import Logo from '@/components/Logo';
import { Button } from '@/components/ui/button';
import { Home } from 'lucide-react';
@@ -28,7 +27,7 @@ export default function NotFound() {
{/* 404 heading */}
<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 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
@@ -71,8 +70,6 @@ export default function NotFound() {
</motion.div>
</div>
</div>
<Footer />
</main>
);
}