From a400f694fe4871784cc929ccb50788d5046e1aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Fri, 27 Feb 2026 17:46:54 +0100 Subject: [PATCH] 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 --- app/(app)/ascii/page.tsx | 10 +- app/(app)/color/globals.css | 181 -------------- app/(app)/color/layout.tsx | 11 - app/(app)/color/page.tsx | 391 +------------------------------ app/(app)/favicon/page.tsx | 13 +- app/(app)/media/page.tsx | 10 +- app/(app)/units/page.tsx | 12 +- app/layout.tsx | 5 +- app/not-found.tsx | 5 +- components/Footer.tsx | 33 +-- components/Hero.tsx | 24 +- components/ToolCard.tsx | 21 +- components/ToolsGrid.tsx | 77 ++---- components/color/ColorPage.tsx | 391 +++++++++++++++++++++++++++++++ components/layout/AppHeader.tsx | 79 +++---- components/layout/AppPage.tsx | 12 +- components/layout/AppSidebar.tsx | 219 ++++++----------- lib/tools.tsx | 97 ++++++++ 18 files changed, 679 insertions(+), 912 deletions(-) delete mode 100644 app/(app)/color/globals.css delete mode 100644 app/(app)/color/layout.tsx create mode 100644 components/color/ColorPage.tsx create mode 100644 lib/tools.tsx diff --git a/app/(app)/ascii/page.tsx b/app/(app)/ascii/page.tsx index 114ad5d..987f0ce 100644 --- a/app/(app)/ascii/page.tsx +++ b/app/(app)/ascii/page.tsx @@ -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 ( diff --git a/app/(app)/color/globals.css b/app/(app)/color/globals.css deleted file mode 100644 index 6184809..0000000 --- a/app/(app)/color/globals.css +++ /dev/null @@ -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; } -} diff --git a/app/(app)/color/layout.tsx b/app/(app)/color/layout.tsx deleted file mode 100644 index 7980490..0000000 --- a/app/(app)/color/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export default function ColorLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - <> - {children} - - ); -} diff --git a/app/(app)/color/page.tsx b/app/(app)/color/page.tsx index 62a42d1..12bae77 100644 --- a/app/(app)/color/page.tsx +++ b/app/(app)/color/page.tsx @@ -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('complementary'); - const [palette, setPalette] = useState([]); - const paletteMutation = useGeneratePalette(); - - // Gradient state - const [stops, setStops] = useState(['#ff0099', '#0099ff']); - const [gradientCount, setGradientCount] = useState(10); - const [gradientResult, setGradientResult] = useState([]); - 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 = { - 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 ( - -
- {/* Row 1: Workspace */} -
- {/* Main Workspace: Color Picker and Information */} -
- - - Color Picker - - - -
-
- -
- -
- {isLoading && ( -
- -
- )} - - {isError && ( -
-

Error loading color information

-

{error?.message || 'Unknown error'}

-
- )} - - {colorInfo && } -
-
-
-
-
- - {/* Sidebar: Color Manipulation */} -
- - - Adjustments - - - - - -
-
- - {/* Row 2: Harmony Generator */} -
- {/* Harmony Controls */} -
- - - Harmony - - - - -

- {harmonyDescriptions[harmonyType]} -

- - -
-
-
- - {/* Harmony Results */} -
- - - - Palette {palette.length > 0 && ({palette.length})} - - - - {palette.length > 0 ? ( -
- -
- -
-
- ) : ( -
- -

Generate a harmony palette from the current color

-
- )} -
-
-
-
- - {/* Row 3: Gradient Generator */} -
- {/* Gradient Controls */} -
- - - Gradient - - -
- - {stops.map((stop, index) => ( -
- updateStop(index, e.target.value)} - className="w-9 h-9 p-1 shrink-0 cursor-pointer" - /> - updateStop(index, e.target.value)} - className="font-mono text-xs flex-1" - /> - {index !== 0 && stops.length > 2 && ( - - )} -
- ))} - -
- -
- - setGradientCount(parseInt(e.target.value))} - /> -
- - -
-
-
- - {/* Gradient Results */} -
- - - - Gradient {gradientResult.length > 0 && ({gradientResult.length})} - - - - {gradientResult.length > 0 ? ( -
-
- -
- -
-
- ) : ( -
- -

Add color stops and generate a smooth gradient

-
- )} - - -
-
-
- - ); -} - -export default function PlaygroundPage() { - return ( - -
- -
-
- }> - - - ); +export default function Page() { + return ; } diff --git a/app/(app)/favicon/page.tsx b/app/(app)/favicon/page.tsx index d4a2b7e..b5ecafe 100644 --- a/app/(app)/favicon/page.tsx +++ b/app/(app)/favicon/page.tsx @@ -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 ( diff --git a/app/(app)/media/page.tsx b/app/(app)/media/page.tsx index 4ae347a..fab46b9 100644 --- a/app/(app)/media/page.tsx +++ b/app/(app)/media/page.tsx @@ -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 ( diff --git a/app/(app)/units/page.tsx b/app/(app)/units/page.tsx index 87bddc6..6016759 100644 --- a/app/(app)/units/page.tsx +++ b/app/(app)/units/page.tsx @@ -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 ( - diff --git a/app/layout.tsx b/app/layout.tsx index 91b64ee..d5ad0a6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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), diff --git a/app/not-found.tsx b/app/not-found.tsx index 526602e..9bda4cd 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -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 */} - -