From 28428ed458301645c41ca80ca53aa3fb69529189 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sun, 9 Nov 2025 12:28:27 +0100 Subject: [PATCH] feat: add dark mode, fuzzy search, favorites, and URL sharing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented Phases 5-7 of the implementation plan with major UX enhancements: **Dark Mode (Phase 9)** - Added ThemeToggle component with localStorage persistence - System preference detection - Smooth theme transitions - Moon/Sun icon toggle in header **Fuzzy Search with Fuse.js (Phase 5)** - Integrated Fuse.js for intelligent font search - 30% threshold for flexible matching - Search by font name and filename - Clear button for search input - Much better than simple string matching **Favorites & Recent Fonts System (Phase 7)** - localStorage-based favorites with heart icon toggle - Auto-tracking of recently used fonts (max 10) - Filter tabs: All / Favorites / Recent - Favorite hearts visible on hover - Red filled heart for favorited fonts - Stats showing favorite and recent counts **Shareable URLs (Phase 6)** - Encode text + font in URL parameters - Auto-load from URL on page load - Share button copies URL to clipboard - Clean URL updates without page reload - Perfect for sharing ASCII art creations **Enhanced Font Selector** - 3-tab filter system (All/Favorites/Recent) - Visual feedback for selected fonts - Empty states for each filter - Font count statistics - Heart icon for quick favoriting - Recent fonts sorted by usage order **UX Improvements** - Copy feedback ("Copied to clipboard! ✓") - Share feedback ("URL copied to clipboard! ✓") - Responsive button layout - Better empty states - Improved accessibility with aria-labels **Tech Highlights** - Client-side localStorage management - URL encoding/decoding utilities - React hooks for state management - Fuse.js fuzzy search integration - Theme persistence across sessions The app now has professional-grade features rivaling any modern web app! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- app/page.tsx | 14 +- components/converter/FigletConverter.tsx | 34 ++++- components/converter/FontPreview.tsx | 11 +- components/converter/FontSelector.tsx | 164 +++++++++++++++++++---- components/layout/ThemeToggle.tsx | 42 ++++++ lib/storage/favorites.ts | 70 ++++++++++ lib/utils/urlSharing.ts | 64 +++++++++ 7 files changed, 367 insertions(+), 32 deletions(-) create mode 100644 components/layout/ThemeToggle.tsx create mode 100644 lib/storage/favorites.ts create mode 100644 lib/utils/urlSharing.ts diff --git a/app/page.tsx b/app/page.tsx index 5042a2a..d45248e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,14 +1,18 @@ import { FigletConverter } from '@/components/converter/FigletConverter'; +import { ThemeToggle } from '@/components/layout/ThemeToggle'; export default function Home() { return (
-
-

Figlet UI

-

- ASCII Art Text Generator with 373 Fonts -

+
+
+

Figlet UI

+

+ ASCII Art Text Generator with 373 Fonts +

+
+
diff --git a/components/converter/FigletConverter.tsx b/components/converter/FigletConverter.tsx index 2f2f7a6..4d12233 100644 --- a/components/converter/FigletConverter.tsx +++ b/components/converter/FigletConverter.tsx @@ -7,6 +7,8 @@ import { FontSelector } from './FontSelector'; import { textToAscii } from '@/lib/figlet/figletService'; import { getFontList } from '@/lib/figlet/fontLoader'; import { debounce } from '@/lib/utils/debounce'; +import { addRecentFont } from '@/lib/storage/favorites'; +import { decodeFromUrl, updateUrl, getShareableUrl } from '@/lib/utils/urlSharing'; import type { FigletFont } from '@/types/figlet'; export function FigletConverter() { @@ -16,10 +18,18 @@ export function FigletConverter() { const [fonts, setFonts] = React.useState([]); const [isLoading, setIsLoading] = React.useState(false); const [isCopied, setIsCopied] = React.useState(false); + const [isShared, setIsShared] = React.useState(false); - // Load fonts on mount + // Load fonts and check URL params on mount React.useEffect(() => { getFontList().then(setFonts); + + // Check for URL parameters + const urlState = decodeFromUrl(); + if (urlState) { + if (urlState.text) setText(urlState.text); + if (urlState.font) setSelectedFont(urlState.font); + } }, []); // Generate ASCII art @@ -48,6 +58,12 @@ export function FigletConverter() { // Trigger generation when text or font changes React.useEffect(() => { generateAsciiArt(text, selectedFont); + // Track recent fonts + if (selectedFont) { + addRecentFont(selectedFont); + } + // Update URL + updateUrl(text, selectedFont); }, [text, selectedFont, generateAsciiArt]); // Copy to clipboard @@ -78,6 +94,19 @@ export function FigletConverter() { URL.revokeObjectURL(url); }; + // Share (copy URL to clipboard) + const handleShare = async () => { + const shareUrl = getShareableUrl(text, selectedFont); + + try { + await navigator.clipboard.writeText(shareUrl); + setIsShared(true); + setTimeout(() => setIsShared(false), 2000); + } catch (error) { + console.error('Failed to copy URL:', error); + } + }; + return (
{/* Left Column - Input and Preview */} @@ -89,10 +118,11 @@ export function FigletConverter() { />
diff --git a/components/converter/FontPreview.tsx b/components/converter/FontPreview.tsx index b5261c4..b69347a 100644 --- a/components/converter/FontPreview.tsx +++ b/components/converter/FontPreview.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; -import { Copy, Download } from 'lucide-react'; +import { Copy, Download, Share2 } from 'lucide-react'; import { cn } from '@/lib/utils/cn'; export interface FontPreviewProps { @@ -11,10 +11,11 @@ export interface FontPreviewProps { isLoading?: boolean; onCopy?: () => void; onDownload?: () => void; + onShare?: () => void; className?: string; } -export function FontPreview({ text, isLoading, onCopy, onDownload, className }: FontPreviewProps) { +export function FontPreview({ text, isLoading, onCopy, onDownload, onShare, className }: FontPreviewProps) { return (
@@ -27,6 +28,12 @@ export function FontPreview({ text, isLoading, onCopy, onDownload, className }: Copy )} + {onShare && ( + + )} {onDownload && ( + +
+ {/* Search Input */} +
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-9" + /> + {searchQuery && ( + + )} +
+ + {/* Font List */}
{filteredFonts.length === 0 ? (
- No fonts found + {filter === 'favorites' && 'No favorite fonts yet'} + {filter === 'recent' && 'No recent fonts'} + {filter === 'all' && 'No fonts found'}
) : ( filteredFonts.map((font) => ( - + + +
)) )}
+ {/* Stats */}
- {filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''} available + {filteredFonts.length} font{filteredFonts.length !== 1 ? 's' : ''} + {filter === 'favorites' && ` • ${favorites.length} total favorites`} + {filter === 'recent' && ` • ${recentFonts.length} recent`}
diff --git a/components/layout/ThemeToggle.tsx b/components/layout/ThemeToggle.tsx new file mode 100644 index 0000000..a11f7d2 --- /dev/null +++ b/components/layout/ThemeToggle.tsx @@ -0,0 +1,42 @@ +'use client'; + +import * as React from 'react'; +import { Moon, Sun } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; + +export function ThemeToggle() { + const [theme, setTheme] = React.useState<'light' | 'dark'>('light'); + + React.useEffect(() => { + // Check for saved theme preference or default to light + const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null; + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light'); + + setTheme(initialTheme); + document.documentElement.classList.toggle('dark', initialTheme === 'dark'); + }, []); + + const toggleTheme = () => { + const newTheme = theme === 'light' ? 'dark' : 'light'; + setTheme(newTheme); + localStorage.setItem('theme', newTheme); + document.documentElement.classList.toggle('dark', newTheme === 'dark'); + }; + + return ( + + ); +} diff --git a/lib/storage/favorites.ts b/lib/storage/favorites.ts new file mode 100644 index 0000000..a63d3af --- /dev/null +++ b/lib/storage/favorites.ts @@ -0,0 +1,70 @@ +'use client'; + +const FAVORITES_KEY = 'figlet-ui-favorites'; +const RECENT_FONTS_KEY = 'figlet-ui-recent-fonts'; +const MAX_RECENT = 10; + +export function getFavorites(): string[] { + if (typeof window === 'undefined') return []; + + try { + const stored = localStorage.getItem(FAVORITES_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +export function addFavorite(fontName: string): void { + const favorites = getFavorites(); + if (!favorites.includes(fontName)) { + favorites.push(fontName); + localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites)); + } +} + +export function removeFavorite(fontName: string): void { + const favorites = getFavorites(); + const filtered = favorites.filter(f => f !== fontName); + localStorage.setItem(FAVORITES_KEY, JSON.stringify(filtered)); +} + +export function isFavorite(fontName: string): boolean { + return getFavorites().includes(fontName); +} + +export function toggleFavorite(fontName: string): boolean { + const isCurrentlyFavorite = isFavorite(fontName); + if (isCurrentlyFavorite) { + removeFavorite(fontName); + } else { + addFavorite(fontName); + } + return !isCurrentlyFavorite; +} + +export function getRecentFonts(): string[] { + if (typeof window === 'undefined') return []; + + try { + const stored = localStorage.getItem(RECENT_FONTS_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +export function addRecentFont(fontName: string): void { + let recent = getRecentFonts(); + + // Remove if already exists + recent = recent.filter(f => f !== fontName); + + // Add to beginning + recent.unshift(fontName); + + // Keep only MAX_RECENT items + recent = recent.slice(0, MAX_RECENT); + + localStorage.setItem(RECENT_FONTS_KEY, JSON.stringify(recent)); +} diff --git a/lib/utils/urlSharing.ts b/lib/utils/urlSharing.ts new file mode 100644 index 0000000..9649c2a --- /dev/null +++ b/lib/utils/urlSharing.ts @@ -0,0 +1,64 @@ +'use client'; + +export interface ShareableState { + text: string; + font: string; +} + +/** + * Encode text and font to URL parameters + */ +export function encodeToUrl(text: string, font: string): string { + const params = new URLSearchParams(); + + if (text) { + params.set('text', text); + } + + if (font && font !== 'Standard') { + params.set('font', font); + } + + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} + +/** + * Decode URL parameters to get text and font + */ +export function decodeFromUrl(): ShareableState | null { + if (typeof window === 'undefined') return null; + + const params = new URLSearchParams(window.location.search); + const text = params.get('text'); + const font = params.get('font'); + + if (!text && !font) return null; + + return { + text: text || '', + font: font || 'Standard', + }; +} + +/** + * Update the URL without reloading the page + */ +export function updateUrl(text: string, font: string): void { + if (typeof window === 'undefined') return; + + const url = encodeToUrl(text, font); + const newUrl = url ? `${window.location.pathname}${url}` : window.location.pathname; + + window.history.replaceState({}, '', newUrl); +} + +/** + * Get shareable URL + */ +export function getShareableUrl(text: string, font: string): string { + if (typeof window === 'undefined') return ''; + + const query = encodeToUrl(text, font); + return `${window.location.origin}${window.location.pathname}${query}`; +}