refactor: consolidate utilities, clean up components, and improve theme persistence
- Consolidate common utilities (cn, format, time) into lib/utils - Remove redundant utility files from pastel and units directories - Clean up unused components (Separator, KeyboardShortcutsHelp) - Relocate CommandPalette to components/units/ui/ - Force dark mode on landing page and improve theme persistence logic - Add FOUC prevention script to RootLayout - Fix sidebar height constraint in AppShell
This commit is contained in:
@@ -109,7 +109,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root, .dark {
|
||||||
/* CORPORATE DARK THEME (The Standard) */
|
/* CORPORATE DARK THEME (The Standard) */
|
||||||
--background: #0a0a0f;
|
--background: #0a0a0f;
|
||||||
--foreground: #ffffff;
|
--foreground: #ffffff;
|
||||||
|
|||||||
@@ -51,10 +51,32 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="preconnect" href="https://kit.pivoine.art" />
|
<link rel="preconnect" href="https://kit.pivoine.art" />
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
var theme = localStorage.getItem('theme');
|
||||||
|
var isLanding = window.location.pathname === '/';
|
||||||
|
if (isLanding) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
document.documentElement.classList.remove('light');
|
||||||
|
} else if (theme === 'light' || (!theme && window.matchMedia('(prefers-color-scheme: light)').matches)) {
|
||||||
|
document.documentElement.classList.add('light');
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
document.documentElement.classList.remove('light');
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body className="antialiased">
|
<body className="antialiased">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
11
app/page.tsx
11
app/page.tsx
@@ -1,3 +1,6 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
import AnimatedBackground from '@/components/AnimatedBackground';
|
import AnimatedBackground from '@/components/AnimatedBackground';
|
||||||
import Hero from '@/components/Hero';
|
import Hero from '@/components/Hero';
|
||||||
import Stats from '@/components/Stats';
|
import Stats from '@/components/Stats';
|
||||||
@@ -6,8 +9,14 @@ import Footer from '@/components/Footer';
|
|||||||
import BackToTop from '@/components/BackToTop';
|
import BackToTop from '@/components/BackToTop';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
useEffect(() => {
|
||||||
|
// Force dark mode on html element for the landing page
|
||||||
|
document.documentElement.classList.remove('light');
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative min-h-screen">
|
<main className="relative min-h-screen dark text-foreground">
|
||||||
<AnimatedBackground />
|
<AnimatedBackground />
|
||||||
<BackToTop />
|
<BackToTop />
|
||||||
<Hero />
|
<Hero />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface AppShellProps {
|
|||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<div className="flex min-h-screen bg-background text-foreground relative">
|
<div className="flex h-screen overflow-hidden bg-background text-foreground relative">
|
||||||
<AnimatedBackground />
|
<AnimatedBackground />
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<div className="flex-1 flex flex-col min-w-0 relative z-10">
|
<div className="flex-1 flex flex-col min-w-0 relative z-10">
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function AppSidebar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<aside className={cn(
|
<aside className={cn(
|
||||||
"fixed inset-y-0 left-0 z-50 flex flex-col border-r border-white/5 bg-background/40 backdrop-blur-2xl transition-all duration-300 ease-in-out lg:relative",
|
"fixed inset-y-0 left-0 z-50 flex flex-col border-r border-white/5 bg-background/40 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-20" : "w-64"
|
||||||
)}>
|
)}>
|
||||||
|
|||||||
@@ -15,16 +15,21 @@ const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|||||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [theme, setTheme] = useState<Theme>('dark');
|
const [theme, setTheme] = useState<Theme>('dark');
|
||||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('dark');
|
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('dark');
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// Load theme from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load theme from localStorage
|
|
||||||
const stored = localStorage.getItem('theme') as Theme | null;
|
const stored = localStorage.getItem('theme') as Theme | null;
|
||||||
if (stored) {
|
if (stored) {
|
||||||
setTheme(stored);
|
setTheme(stored);
|
||||||
}
|
}
|
||||||
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Apply theme to document element and save to localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
const root = window.document.documentElement;
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
// Remove previous theme classes
|
// Remove previous theme classes
|
||||||
@@ -43,10 +48,12 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
}, [theme]);
|
}, [theme, mounted]);
|
||||||
|
|
||||||
// Listen for system theme changes
|
// Listen for system theme changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
@@ -60,7 +67,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
mediaQuery.addEventListener('change', handleChange);
|
mediaQuery.addEventListener('change', handleChange);
|
||||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||||
}, [theme]);
|
}, [theme, mounted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
|
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import { Card } from './Card';
|
|
||||||
import { Button } from './Button';
|
|
||||||
import { X, Keyboard } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
|
|
||||||
export interface Shortcut {
|
|
||||||
key: string;
|
|
||||||
description: string;
|
|
||||||
modifier?: 'ctrl' | 'shift';
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortcuts: Shortcut[] = [
|
|
||||||
{ key: '/', description: 'Focus font search' },
|
|
||||||
{ key: 'Esc', description: 'Clear search / Close dialog' },
|
|
||||||
{ key: 'D', description: 'Toggle dark/light mode', modifier: 'ctrl' },
|
|
||||||
{ key: '?', description: 'Show this help dialog', modifier: 'shift' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function KeyboardShortcutsHelp() {
|
|
||||||
const [isOpen, setIsOpen] = React.useState(false);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === '?' && e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsOpen(true);
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape' && isOpen) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
title="Keyboard shortcuts (Shift + ?)"
|
|
||||||
className="fixed bottom-4 right-4"
|
|
||||||
>
|
|
||||||
<Keyboard className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
|
||||||
<Card className="max-w-md w-full">
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
||||||
<Keyboard className="h-5 w-5" />
|
|
||||||
Keyboard Shortcuts
|
|
||||||
</h2>
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => setIsOpen(false)}>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">Navigation</h3>
|
|
||||||
{shortcuts.map((shortcut, i) => (
|
|
||||||
<div key={i} className="flex items-center justify-between py-2 border-b last:border-0">
|
|
||||||
<span className="text-sm text-muted-foreground">{shortcut.description}</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{shortcut.modifier && (
|
|
||||||
<kbd className="px-2 py-1 text-xs font-semibold bg-muted rounded">
|
|
||||||
{shortcut.modifier === 'ctrl' ? '⌘/Ctrl' : 'Shift'}
|
|
||||||
</kbd>
|
|
||||||
)}
|
|
||||||
<kbd className="px-2 py-1 text-xs font-semibold bg-muted rounded">
|
|
||||||
{shortcut.key}
|
|
||||||
</kbd>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">Tips</h3>
|
|
||||||
<ul className="text-xs text-muted-foreground space-y-1">
|
|
||||||
<li>• Click the Shuffle button for random fonts</li>
|
|
||||||
<li>• Use the heart icon to favorite fonts</li>
|
|
||||||
<li>• Filter by All, Favorites, or Recent</li>
|
|
||||||
<li>• Text alignment and size controls in Preview</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
|
|
||||||
const Separator = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement> & {
|
|
||||||
orientation?: 'horizontal' | 'vertical';
|
|
||||||
decorative?: boolean;
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
|
||||||
ref
|
|
||||||
) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
role={decorative ? undefined : 'separator'}
|
|
||||||
aria-orientation={decorative ? undefined : orientation}
|
|
||||||
className={cn(
|
|
||||||
'shrink-0 bg-border',
|
|
||||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
Separator.displayName = 'Separator';
|
|
||||||
|
|
||||||
export { Separator };
|
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
clearHistory,
|
clearHistory,
|
||||||
type ConversionRecord,
|
type ConversionRecord,
|
||||||
} from '@/lib/units/storage';
|
} from '@/lib/units/storage';
|
||||||
import { getRelativeTime, formatNumber } from '@/lib/units/utils';
|
import { getRelativeTime, formatNumber } from '@/lib/utils';
|
||||||
import { formatMeasureName } from '@/lib/units/units';
|
import { formatMeasureName } from '@/lib/units/units';
|
||||||
|
|
||||||
interface ConversionHistoryProps {
|
interface ConversionHistoryProps {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import SearchUnits from './SearchUnits';
|
import SearchUnits from './SearchUnits';
|
||||||
import ConversionHistory from './ConversionHistory';
|
import ConversionHistory from './ConversionHistory';
|
||||||
import VisualComparison from './VisualComparison';
|
import VisualComparison from './VisualComparison';
|
||||||
import CommandPalette from '@/components/ui/CommandPalette';
|
import CommandPalette from '@/components/units/ui/CommandPalette';
|
||||||
import {
|
import {
|
||||||
getAllMeasures,
|
getAllMeasures,
|
||||||
getUnitsForMeasure,
|
getUnitsForMeasure,
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
type Measure,
|
type Measure,
|
||||||
type ConversionResult,
|
type ConversionResult,
|
||||||
} from '@/lib/units/units';
|
} from '@/lib/units/units';
|
||||||
import { parseNumberInput, formatNumber, cn } from '@/lib/units/utils';
|
import { parseNumberInput, formatNumber, cn } from '@/lib/utils';
|
||||||
import { saveToHistory, getFavorites, toggleFavorite } from '@/lib/units/storage';
|
import { saveToHistory, getFavorites, toggleFavorite } from '@/lib/units/storage';
|
||||||
|
|
||||||
export default function MainConverter() {
|
export default function MainConverter() {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
type Measure,
|
type Measure,
|
||||||
type UnitInfo,
|
type UnitInfo,
|
||||||
} from '@/lib/units/units';
|
} from '@/lib/units/units';
|
||||||
import { cn } from '@/lib/units/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
unitInfo: UnitInfo;
|
unitInfo: UnitInfo;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
|
import { useMemo, useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { type ConversionResult } from '@/lib/units/units';
|
import { type ConversionResult } from '@/lib/units/units';
|
||||||
import { formatNumber, cn } from '@/lib/units/utils';
|
import { formatNumber, cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface VisualComparisonProps {
|
interface VisualComparisonProps {
|
||||||
conversions: ConversionResult[];
|
conversions: ConversionResult[];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
type Measure,
|
type Measure,
|
||||||
} from '@/lib/units/units';
|
} from '@/lib/units/units';
|
||||||
import { getHistory, getFavorites } from '@/lib/units/storage';
|
import { getHistory, getFavorites } from '@/lib/units/storage';
|
||||||
import { cn } from '@/lib/units/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
onSelectMeasure: (measure: Measure) => void;
|
onSelectMeasure: (measure: Measure) => void;
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from 'clsx';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,3 @@
|
|||||||
/**
|
|
||||||
* Utility functions for the application
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type ClassValue, clsx } from 'clsx';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge Tailwind CSS classes with clsx
|
|
||||||
*/
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a number for display with proper precision
|
* Format a number for display with proper precision
|
||||||
*/
|
*/
|
||||||
@@ -53,26 +39,6 @@ export function formatNumber(
|
|||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Debounce function for input handling
|
|
||||||
*/
|
|
||||||
export function debounce<T extends (...args: any[]) => any>(
|
|
||||||
func: T,
|
|
||||||
wait: number
|
|
||||||
): (...args: Parameters<T>) => void {
|
|
||||||
let timeout: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
return function executedFunction(...args: Parameters<T>) {
|
|
||||||
const later = () => {
|
|
||||||
timeout = null;
|
|
||||||
func(...args);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (timeout) clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(later, wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a number input string
|
* Parse a number input string
|
||||||
*/
|
*/
|
||||||
@@ -86,21 +52,3 @@ export function parseNumberInput(input: string): number | null {
|
|||||||
|
|
||||||
return isNaN(parsed) ? null : parsed;
|
return isNaN(parsed) ? null : parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get relative time from timestamp
|
|
||||||
*/
|
|
||||||
export function getRelativeTime(timestamp: number): string {
|
|
||||||
const now = Date.now();
|
|
||||||
const diff = now - timestamp;
|
|
||||||
|
|
||||||
const seconds = Math.floor(diff / 1000);
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
|
|
||||||
if (days > 0) return `${days}d ago`;
|
|
||||||
if (hours > 0) return `${hours}h ago`;
|
|
||||||
if (minutes > 0) return `${minutes}m ago`;
|
|
||||||
return 'just now';
|
|
||||||
}
|
|
||||||
@@ -2,3 +2,5 @@ export * from './cn';
|
|||||||
export * from './debounce';
|
export * from './debounce';
|
||||||
export * from './urlSharing';
|
export * from './urlSharing';
|
||||||
export * from './animations';
|
export * from './animations';
|
||||||
|
export * from './format';
|
||||||
|
export * from './time';
|
||||||
|
|||||||
17
lib/utils/time.ts
Normal file
17
lib/utils/time.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Get relative time from timestamp
|
||||||
|
*/
|
||||||
|
export function getRelativeTime(timestamp: number): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ago`;
|
||||||
|
if (hours > 0) return `${hours}h ago`;
|
||||||
|
if (minutes > 0) return `${minutes}m ago`;
|
||||||
|
return 'just now';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user