From 18e818103f2504ceb76476f98cf30205c341ce8d Mon Sep 17 00:00:00 2001 From: DrakeNguyen Date: Thu, 30 Apr 2026 23:40:01 -0500 Subject: [PATCH 1/8] chore: bump version to 0.2.1 Made-with: Cursor --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 07c9e89..d4ec7d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devpockit-frontend", - "version": "0.2.0", + "version": "0.2.1", "description": "DevPockit Frontend - Developer Tools Web App", "license": "MIT", "repository": { From 7e5d7f41519069a17b92caf049694b48250dbd7f Mon Sep 17 00:00:00 2001 From: DrakeNguyen Date: Fri, 1 May 2026 00:26:43 -0500 Subject: [PATCH 2/8] feat(ui): improve UX with recents, pins, toasts, and trust cues Add localStorage-backed recent/pinned tools surfaced on the welcome page and command palette (with star toggle). Introduce a lightweight toast when copy succeeds from CodePanel, JWT local-processing notices, a dismissible mobile banner for desktop-oriented tools, reduced-motion-friendly loading, and unit tests for activity storage helpers. Made-with: Cursor --- src/app/layout.tsx | 5 +- src/components/layout/AppLayout.tsx | 43 +- src/components/layout/CommandPalette.tsx | 407 +++++++++++------- .../layout/DesktopRecommendedBanner.tsx | 67 +++ src/components/pages/WelcomePage.tsx | 52 +++ src/components/providers/AppToastProvider.tsx | 70 +++ .../providers/ToolActivityProvider.tsx | 95 ++++ src/components/tools/JwtDecoder.tsx | 2 + src/components/tools/JwtEncoder.tsx | 2 + .../tools/LocalProcessingNotice.tsx | 15 + src/components/ui/code-panel.tsx | 8 +- .../__tests__/tool-activity-storage.test.ts | 31 ++ src/libs/monaco-utils.ts | 3 +- src/libs/tool-activity-storage.ts | 68 +++ src/libs/tools-data.ts | 3 + src/types/tools.ts | 2 + 16 files changed, 706 insertions(+), 167 deletions(-) create mode 100644 src/components/layout/DesktopRecommendedBanner.tsx create mode 100644 src/components/providers/AppToastProvider.tsx create mode 100644 src/components/providers/ToolActivityProvider.tsx create mode 100644 src/components/tools/LocalProcessingNotice.tsx create mode 100644 src/libs/__tests__/tool-activity-storage.test.ts create mode 100644 src/libs/tool-activity-storage.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c07cf6e..828031e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import { AppLayout } from '@/components/layout/AppLayout' +import { AppToastProvider } from '@/components/providers/AppToastProvider' import { ThemeProvider } from '@/components/providers/ThemeProvider' import type { Metadata, Viewport } from 'next' import { DM_Serif_Text, Geist, Geist_Mono } from 'next/font/google' @@ -124,7 +125,9 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - {children} + + {children} + diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 33fe4db..5e82bd4 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -1,5 +1,6 @@ 'use client'; +import { ToolActivityProvider, useToolActivity } from '@/components/providers/ToolActivityProvider'; import { ToolStateProvider, useToolStateContext } from '@/components/providers/ToolStateProvider'; import { AlertDialog } from '@/components/ui/alert-dialog'; import { SidebarInset, SidebarProvider, useSidebar } from '@/components/ui/sidebar'; @@ -13,6 +14,7 @@ import { AppSidebar } from '../AppSidebar'; import { AboutPage } from '../pages/AboutPage'; import { WelcomePage } from '../pages/WelcomePage'; import { CommandPalette } from './CommandPalette'; +import { DesktopRecommendedBanner } from './DesktopRecommendedBanner'; import { MobileTopBar } from './MobileTopBar'; import { TopNavTabs, type ActiveTab } from './TopNavTabs'; @@ -56,7 +58,7 @@ function DynamicToolRenderer({ toolId, instanceId }: { toolId: string; instanceI return (
-
+

Loading tool...

@@ -98,6 +100,7 @@ function isAboutPage(pathname: string): boolean { // Inner component that has access to ToolStateContext function AppLayoutInner({ children }: AppLayoutProps) { const { clearToolState, clearAllToolStates } = useToolStateContext(); + const { recordToolOpen } = useToolActivity(); const { isMobile } = useSidebar(); const [activeTabs, setActiveTabs] = useState([]); const [selectedTool, setSelectedTool] = useState(); @@ -245,6 +248,14 @@ function AppLayoutInner({ children }: AppLayoutProps) { // Note: activeTabs removed from deps - using ref instead to prevent recreating tabs during close }, [pathname, router, isMobile, clearToolSelection]); + useEffect(() => { + if (!isValidToolUrl(pathname)) return; + const parsed = parseToolUrl(pathname); + if (parsed?.toolId && parsed.toolId !== ABOUT_TOOL_ID) { + recordToolOpen(parsed.toolId); + } + }, [pathname, recordToolOpen]); + // Handle keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -438,7 +449,23 @@ function AppLayoutInner({ children }: AppLayoutProps) {
) : selectedTool && selectedInstanceId ? ( - +
+ {(() => { + const activeTool = getToolById(selectedTool); + return activeTool?.desktopRecommended && isMobile ? ( + + ) : null; + })()} + +
) : null} ) : ( @@ -473,10 +500,12 @@ function AppLayoutInner({ children }: AppLayoutProps) { export function AppLayout({ children }: AppLayoutProps) { return ( - - - {children} - - + + + + {children} + + + ); } diff --git a/src/components/layout/CommandPalette.tsx b/src/components/layout/CommandPalette.tsx index cb157d0..6463bc6 100644 --- a/src/components/layout/CommandPalette.tsx +++ b/src/components/layout/CommandPalette.tsx @@ -8,12 +8,13 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; +import { useToolActivity } from '@/components/providers/ToolActivityProvider'; import { useKeyboardShortcut } from '@/hooks/useKeyboardShortcut'; import { getCategoryById, searchTools, toolIcons } from '@/libs/tools-data'; import { cn } from '@/libs/utils'; import { type Tool } from '@/types/tools'; -import { Search } from 'lucide-react'; -import { startTransition, useEffect, useRef, useState } from 'react'; +import { Search, Star } from 'lucide-react'; +import { startTransition, useEffect, useMemo, useRef, useState } from 'react'; interface CommandPaletteProps { open: boolean; @@ -27,9 +28,21 @@ interface CommandPaletteResultProps { isSelected?: boolean; index: number; onHover?: (index: number) => void; + showPin?: boolean; + pinned?: boolean; + onTogglePin?: (toolId: string) => void; } -const CommandPaletteResult = ({ tool, onSelect, isSelected = false, index, onHover }: CommandPaletteResultProps) => { +const CommandPaletteResult = ({ + tool, + onSelect, + isSelected = false, + index, + onHover, + showPin = false, + pinned = false, + onTogglePin, +}: CommandPaletteResultProps) => { const category = getCategoryById(tool.category); const IconComponent = toolIcons[tool.id]; const [isHovered, setIsHovered] = useState(false); @@ -44,79 +57,111 @@ const CommandPaletteResult = ({ tool, onSelect, isSelected = false, index, onHov }; return ( -
setIsHovered(false)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleSelect(); - } - }} - > - {/* Icon Section */} +
setIsHovered(false)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSelect(); + } + }} > - {IconComponent ? ( - + {IconComponent ? ( + + ) : ( + {tool.icon} + )} +
+ +
+
- ) : ( - {tool.icon} - )} -
- - {/* Content Section */} -
-
- {tool.name} -
-
- {tool.description} -
- {category && ( -
-
-

- {category.name} -

-
+ > + {tool.name}
- )} +
+ {tool.description} +
+ {category && ( +
+
+

+ {category.name} +

+
+
+ )} +
+ + {showPin && onTogglePin ? ( + + ) : null}
); }; export function CommandPalette({ open, onOpenChange, onToolSelect }: CommandPaletteProps) { + const { pinnedTools, recentTools, togglePinnedTool, isPinned } = useToolActivity(); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); @@ -124,17 +169,35 @@ export function CommandPalette({ open, onOpenChange, onToolSelect }: CommandPale const inputRef = useRef(null); const resultsRef = useRef(null); - // Auto-focus input when dialog opens + const shortcutRows = useMemo(() => { + const pinnedSet = new Set(pinnedTools.map(t => t.id)); + const out: { tool: Tool; index: number }[] = []; + let i = 0; + for (const tool of pinnedTools) { + out.push({ tool, index: i++ }); + } + for (const tool of recentTools) { + if (!pinnedSet.has(tool.id)) { + out.push({ tool, index: i++ }); + } + } + return out; + }, [pinnedTools, recentTools]); + + const hasQuery = query.trim().length > 0; + const hasResults = results.length > 0; + const showShortcuts = !hasQuery && shortcutRows.length > 0; + const activeCount = hasQuery ? results.length : shortcutRows.length; + useEffect(() => { if (open && inputRef.current) { - // Small delay to ensure dialog is fully rendered - setTimeout(() => { + const t = window.setTimeout(() => { inputRef.current?.focus(); }, 100); + return () => clearTimeout(t); } }, [open]); - // Clear query when dialog closes useEffect(() => { if (!open) { startTransition(() => { @@ -145,13 +208,11 @@ export function CommandPalette({ open, onOpenChange, onToolSelect }: CommandPale } }, [open]); - // Search logic useEffect(() => { if (query.trim().length > 0) { const searchResults = searchTools(query); startTransition(() => { setResults(searchResults); - // Reset to first result when query changes, but ensure it's within bounds setSelectedIndex(0); }); } else { @@ -162,33 +223,39 @@ export function CommandPalette({ open, onOpenChange, onToolSelect }: CommandPale } }, [query]); - // Ensure selectedIndex is within bounds when results change useEffect(() => { - if (results.length > 0 && selectedIndex >= results.length) { + if (activeCount > 0 && selectedIndex >= activeCount) { startTransition(() => { setSelectedIndex(0); }); } - }, [results.length, selectedIndex]); + }, [activeCount, selectedIndex]); - // Scroll selected item into view useEffect(() => { - if (resultsRef.current && results.length > 0 && selectedIndex >= 0) { - // Find the actual result element (it's wrapped in a div with id) - const resultsContainer = resultsRef.current.querySelector('[role="group"]'); - if (resultsContainer) { - const selectedElement = resultsContainer.children[selectedIndex]?.querySelector('[role="option"]') as HTMLElement; - if (selectedElement) { - selectedElement.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - }); - } - } + if (!resultsRef.current || activeCount <= 0 || selectedIndex < 0) return; + + const container = resultsRef.current.querySelector('[role="group"]'); + if (!container) return; + + const selectedWrap = container.children[selectedIndex] as HTMLElement | undefined; + const selectedElement = selectedWrap?.querySelector('[role="option"]') as HTMLElement | undefined; + if (selectedElement) { + selectedElement.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); } - }, [selectedIndex, results.length]); + }, [selectedIndex, activeCount, hasQuery, showShortcuts]); + + const handleClose = () => { + onOpenChange(false); + }; + + const handlePaletteToolSelect = (toolId: string) => { + onToolSelect(toolId); + handleClose(); + }; - // Keyboard navigation handler const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); @@ -196,84 +263,70 @@ export function CommandPalette({ open, onOpenChange, onToolSelect }: CommandPale return; } - if (e.key === 'Enter' && results.length > 0 && selectedIndex >= 0 && selectedIndex < results.length) { + const list = hasQuery ? results : shortcutRows.map(r => r.tool); + if ( + e.key === 'Enter' && + list.length > 0 && + selectedIndex >= 0 && + selectedIndex < list.length + ) { e.preventDefault(); - const toolToSelect = results[selectedIndex]; + const toolToSelect = list[selectedIndex]; if (toolToSelect) { - handleToolSelect(toolToSelect.id); + handlePaletteToolSelect(toolToSelect.id); } return; } - if (e.key === 'ArrowDown' && results.length > 0) { + if (e.key === 'ArrowDown' && list.length > 0) { e.preventDefault(); - setSelectedIndex((prev) => { - const nextIndex = (prev + 1) % results.length; - return nextIndex; - }); + setSelectedIndex(prev => (prev + 1) % list.length); return; } - if (e.key === 'ArrowUp' && results.length > 0) { + if (e.key === 'ArrowUp' && list.length > 0) { e.preventDefault(); - setSelectedIndex((prev) => { - const prevIndex = (prev - 1 + results.length) % results.length; - return prevIndex; - }); + setSelectedIndex(prev => (prev - 1 + list.length) % list.length); return; } - // Home key - go to first result - if (e.key === 'Home' && results.length > 0) { + if (e.key === 'Home' && list.length > 0) { e.preventDefault(); setSelectedIndex(0); return; } - // End key - go to last result - if (e.key === 'End' && results.length > 0) { + if (e.key === 'End' && list.length > 0) { e.preventDefault(); - setSelectedIndex(results.length - 1); + setSelectedIndex(list.length - 1); return; } - - // Tab key should work normally for accessibility - if (e.key === 'Tab') { - // Allow default Tab behavior - return; - } - }; - - const handleClose = () => { - onOpenChange(false); - }; - - const handleToolSelect = (toolId: string) => { - onToolSelect(toolId); - handleClose(); }; - const hasQuery = query.trim().length > 0; - const hasResults = results.length > 0; + const listboxActive = hasQuery ? hasResults : showShortcuts; return ( e.preventDefault()} > Search Tools - Search for developer tools by name or description. Use arrow keys to navigate and Enter to select. + Search for developer tools by name or description. Use arrow keys to navigate and Enter to + select. -
- {/* Search Input */} +
-
-
); @@ -306,7 +317,8 @@ export function CommandPalette({ open, onOpenChange, onToolSelect }: CommandPale const listboxActive = hasQuery ? hasResults : showShortcuts; return ( - + + +

+ Tip: use the star on each row to pin tools—pinned items appear first below and on the home page. +

+
{!hasQuery && !showShortcuts ? (
@@ -465,5 +481,6 @@ export function CommandPalette({ open, onOpenChange, onToolSelect }: CommandPale
+
); } diff --git a/src/components/pages/AboutPage.tsx b/src/components/pages/AboutPage.tsx index af2b1e8..309f1fc 100644 --- a/src/components/pages/AboutPage.tsx +++ b/src/components/pages/AboutPage.tsx @@ -1,8 +1,41 @@ 'use client'; -import { Coffee, Github, Lightbulb } from 'lucide-react'; +import { useKeyboardShortcut } from '@/hooks/useKeyboardShortcut'; +import { Coffee, Github, Keyboard, Lightbulb } from 'lucide-react'; import Link from 'next/link'; +function KeyboardShortcutsPanel() { + const paletteShortcut = useKeyboardShortcut(); + const openPalette = paletteShortcut ?? '⌘K / Ctrl+K'; + + return ( +
+
+ + Keyboard shortcuts +
+
+
+
Open command palette
+
{openPalette}
+
+
+
Close palette
+
Esc
+
+
+
Back to welcome (with a tool open)
+
Esc
+
+
+
Navigate palette results
+
↑ ↓ Enter
+
+
+
+ ); +} + export function AboutPage() { return (
@@ -30,6 +63,7 @@ export function AboutPage() { and ideas for what we should build next.

Happy Coding!

+
{/* Cards */} diff --git a/src/components/pages/WelcomePage.tsx b/src/components/pages/WelcomePage.tsx index cbe3380..4b457e2 100644 --- a/src/components/pages/WelcomePage.tsx +++ b/src/components/pages/WelcomePage.tsx @@ -2,9 +2,12 @@ import { useToolActivity } from '@/components/providers/ToolActivityProvider'; import { ToolCard } from '@/components/ui/tool-card'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { getAllTools, getCategoryById, toolIcons } from '@/libs/tools-data'; import type { Tool } from '@/types/tools'; +import { Star } from 'lucide-react'; import { useMemo } from 'react'; +import { cn } from '@/libs/utils'; interface WelcomePageProps { onToolSelect: (toolId: string) => void; @@ -13,7 +16,30 @@ interface WelcomePageProps { export function WelcomePage({ onToolSelect, activeToolIds = [] }: WelcomePageProps) { const allTools = getAllTools(); - const { pinnedTools, recentTools, hydrated } = useToolActivity(); + const { pinnedTools, recentTools, hydrated, togglePinnedTool, isPinned } = useToolActivity(); + + const orderedTools = useMemo(() => { + const seen = new Set(); + const order: Tool[] = []; + for (const t of pinnedTools) { + if (!seen.has(t.id)) { + seen.add(t.id); + order.push(t); + } + } + for (const t of recentTools) { + if (!seen.has(t.id)) { + seen.add(t.id); + order.push(t); + } + } + for (const t of allTools) { + if (!seen.has(t.id)) { + order.push(t); + } + } + return order; + }, [pinnedTools, recentTools, allTools]); const quickAccessTools = useMemo(() => { const seen = new Set(); @@ -34,70 +60,113 @@ export function WelcomePage({ onToolSelect, activeToolIds = [] }: WelcomePagePro }, [pinnedTools, recentTools]); return ( -
-
- {/* Hero Section */} -
-

- Your essential dev tools at your fingertips -

-

- Everything run locally in your browser for optimal performance and privacy -

+ +
+
+ {/* Hero Section */} +
+

+ Your{' '} + + essential + {' '} + dev tools at your fingertips +

+

+ Everything run locally in your browser for optimal performance and privacy +

+
-
- {hydrated && quickAccessTools.length > 0 ? ( -
-

- Pinned & recent -

-
- {quickAccessTools.map(tool => { - const IconComponent = toolIcons[tool.id]; - return ( - - ); - })} -
-
- ) : null} + {hydrated && quickAccessTools.length > 0 ? ( +
+

+ Pinned & recent +

+
+ {quickAccessTools.map(tool => { + const IconComponent = toolIcons[tool.id]; + const pinned = isPinned(tool.id); + return ( +
+ + + + + + + {pinned ? 'Unpin from Jump back in and home' : 'Pin — same as star in ⌘K palette'} + + +
+ ); + })} +
+
+ ) : null} - {/* Tools Grid */} -
- {allTools.map((tool) => { - const category = getCategoryById(tool.category); - const IconComponent = toolIcons[tool.id]; - const isActive = activeToolIds.includes(tool.id); + {/* Tools Grid */} +
+ {orderedTools.map(tool => { + const category = getCategoryById(tool.category); + const IconComponent = toolIcons[tool.id]; + const isActive = activeToolIds.includes(tool.id); - return ( - } - name={tool.name} - category={category?.name || tool.category} - isActive={isActive} - supportsDesktop={tool.supportsDesktop} - supportsMobile={tool.supportsMobile} - onClick={() => onToolSelect(tool.id)} - /> - ); - })} + return ( + } + name={tool.name} + category={category?.name || tool.category} + isActive={isActive} + supportsDesktop={tool.supportsDesktop} + supportsMobile={tool.supportsMobile} + onClick={() => onToolSelect(tool.id)} + /> + ); + })} +
-
+ ); } diff --git a/src/components/tools/BaseEncoder.tsx b/src/components/tools/BaseEncoder.tsx index 04ec7c6..e112ef1 100644 --- a/src/components/tools/BaseEncoder.tsx +++ b/src/components/tools/BaseEncoder.tsx @@ -2,6 +2,7 @@ import { useToolState } from '@/components/providers/ToolStateProvider'; import { Button } from '@/components/ui/button'; +import { LocalProcessingNotice } from '@/components/tools/LocalProcessingNotice'; import { CodePanel } from '@/components/ui/code-panel'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -169,6 +170,7 @@ export function BaseEncoder({ className, instanceId }: BaseEncoderProps) {

Encode and decode text using Base64, Base32, Base16 (hex), Base85, and other base encodings

+
{/* Body Section */} diff --git a/src/components/tools/HashGenerator.tsx b/src/components/tools/HashGenerator.tsx index b8b3073..f0d0977 100644 --- a/src/components/tools/HashGenerator.tsx +++ b/src/components/tools/HashGenerator.tsx @@ -2,6 +2,7 @@ import { useToolState } from '@/components/providers/ToolStateProvider'; import { Button } from '@/components/ui/button'; +import { LocalProcessingNotice } from '@/components/tools/LocalProcessingNotice'; import { CodePanel } from '@/components/ui/code-panel'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { LabeledInput } from '@/components/ui/labeled-input'; @@ -150,6 +151,7 @@ export function HashGenerator({ className, instanceId }: HashGeneratorProps) {

Generate cryptographic hashes using SHA-1, SHA-256, SHA-512, and SHA-3 algorithms

+
{/* Body Section */} diff --git a/src/components/tools/JsonFormatter.tsx b/src/components/tools/JsonFormatter.tsx index adc829a..c9ba385 100644 --- a/src/components/tools/JsonFormatter.tsx +++ b/src/components/tools/JsonFormatter.tsx @@ -1,7 +1,9 @@ 'use client'; +import { useAppToast } from '@/components/providers/AppToastProvider'; import { useToolState } from '@/components/providers/ToolStateProvider'; import { Button } from '@/components/ui/button'; +import { LocalProcessingNotice } from '@/components/tools/LocalProcessingNotice'; import { CodePanel } from '@/components/ui/code-panel'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { LoadFileButton } from '@/components/ui/load-file-button'; @@ -9,9 +11,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { DEFAULT_JSON_OPTIONS, JSON_EXAMPLES, JSON_FORMAT_OPTIONS } from '@/config/json-formatter-config'; import { useCodeEditorTheme } from '@/hooks/useCodeEditorTheme'; import { formatJson, getJsonStats, type JsonFormatOptions, type JsonFormatResult } from '@/libs/json-formatter'; +import { decodeJsonFormatterShareFragment, encodeJsonFormatterShareFragment } from '@/libs/json-formatter-share-url'; import { cn } from '@/libs/utils'; import { ArrowPathIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; -import { useEffect, useState } from 'react'; +import { Link2 } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; interface JsonFormatterProps { className?: string; @@ -19,6 +23,8 @@ interface JsonFormatterProps { } export function JsonFormatter({ className, instanceId }: JsonFormatterProps) { + const appToast = useAppToast(); + const shareAppliedRef = useRef(false); const { toolState, updateToolState } = useToolState('json-formatter', instanceId); // Initialize with defaults to avoid hydration mismatch @@ -73,6 +79,21 @@ export function JsonFormatter({ className, instanceId }: JsonFormatterProps) { } }, [toolState, isHydrated]); + useEffect(() => { + if (typeof window === 'undefined' || shareAppliedRef.current) return; + const decoded = decodeJsonFormatterShareFragment(window.location.hash); + if (!decoded) return; + shareAppliedRef.current = true; + setInput(decoded.input); + setOptions({ + format: decoded.format, + indentSize: decoded.indentSize, + sortKeys: decoded.sortKeys, + }); + setError(''); + window.history.replaceState(null, '', window.location.pathname + window.location.search); + }, []); + const handleFormat = async () => { if (!input.trim()) { setError('Please enter JSON to format'); @@ -110,6 +131,26 @@ export function JsonFormatter({ className, instanceId }: JsonFormatterProps) { setError(''); }; + const handleCopyShareLink = async () => { + const encoded = encodeJsonFormatterShareFragment({ + input, + format: options.format, + indentSize: options.indentSize, + sortKeys: options.sortKeys, + }); + if ('error' in encoded) { + appToast?.showToast(encoded.error); + return; + } + try { + const url = `${window.location.origin}${window.location.pathname}${window.location.search}#${encoded.fragment}`; + await navigator.clipboard.writeText(url); + appToast?.showToast('Share link copied'); + } catch { + appToast?.showToast('Could not copy link'); + } + }; + const getCharacterCount = (text: string): number => { return text.length; }; @@ -129,6 +170,7 @@ export function JsonFormatter({ className, instanceId }: JsonFormatterProps) {

Format, minify, and validate JSON with syntax highlighting and statistics

+
{/* Body Section */} @@ -247,6 +289,18 @@ export function JsonFormatter({ className, instanceId }: JsonFormatterProps) { + } footerLeftContent={ diff --git a/src/components/tools/QrCodeDecoder.tsx b/src/components/tools/QrCodeDecoder.tsx index 8cab89d..f57955a 100644 --- a/src/components/tools/QrCodeDecoder.tsx +++ b/src/components/tools/QrCodeDecoder.tsx @@ -2,6 +2,7 @@ import { useToolState } from '@/components/providers/ToolStateProvider'; import { Badge } from '@/components/ui/badge'; +import { LocalProcessingNotice } from '@/components/tools/LocalProcessingNotice'; import { Button } from '@/components/ui/button'; import { CodePanel } from '@/components/ui/code-panel'; import { DEFAULT_QR_DECODER_OPTIONS } from '@/config/qr-code-decoder-config'; @@ -303,6 +304,7 @@ export function QrCodeDecoder({ className, instanceId, onResult, onError }: QrCo

Upload an image containing a QR code to decode it with support for multiple formats

+
{/* Body Section */} diff --git a/src/libs/json-formatter-share-url.ts b/src/libs/json-formatter-share-url.ts new file mode 100644 index 0000000..51f48ab --- /dev/null +++ b/src/libs/json-formatter-share-url.ts @@ -0,0 +1,80 @@ +import type { JsonFormatOptions } from '@/libs/json-formatter'; + +const PREFIX = 'jf=v1.'; + +export interface JsonFormatterSharePayload { + input: string; + format: JsonFormatOptions['format']; + indentSize: number; + sortKeys: JsonFormatOptions['sortKeys']; +} + +/** Total hash budget including prefix (some browsers limit URL length). */ +const MAX_FRAGMENT_LENGTH = 1800; + +function utf8ToBase64Url(str: string): string { + const bytes = new TextEncoder().encode(str); + let bin = ''; + for (let i = 0; i < bytes.length; i++) { + bin += String.fromCharCode(bytes[i]); + } + const b64 = btoa(bin); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function base64UrlToUtf8(b64url: string): string { + let padded = b64url.replace(/-/g, '+').replace(/_/g, '/'); + while (padded.length % 4 !== 0) { + padded += '='; + } + const bin = atob(padded); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { + bytes[i] = bin.charCodeAt(i); + } + return new TextDecoder().decode(bytes); +} + +export function encodeJsonFormatterShareFragment(payload: JsonFormatterSharePayload): { + fragment: string; +} | { error: string } { + try { + const json = JSON.stringify(payload); + const body = utf8ToBase64Url(json); + const fragment = `${PREFIX}${body}`; + if (fragment.length > MAX_FRAGMENT_LENGTH) { + return { + error: 'Input is too large to fit in a shareable link. Shorten the JSON or copy it manually.', + }; + } + return { fragment }; + } catch { + return { error: 'Could not build share link for this input.' }; + } +} + +export function decodeJsonFormatterShareFragment(hash: string): JsonFormatterSharePayload | null { + const trimmed = hash.startsWith('#') ? hash.slice(1) : hash; + if (!trimmed.startsWith(PREFIX)) { + return null; + } + const body = trimmed.slice(PREFIX.length); + if (!body) return null; + try { + const json = base64UrlToUtf8(body); + const parsed = JSON.parse(json) as Record; + if (typeof parsed.input !== 'string') return null; + const format = parsed.format === 'minify' || parsed.format === 'beautify' ? parsed.format : 'beautify'; + const indentSize = + typeof parsed.indentSize === 'number' && Number.isFinite(parsed.indentSize) + ? parsed.indentSize + : 2; + const sortKeys = + parsed.sortKeys === 'asc' || parsed.sortKeys === 'desc' || parsed.sortKeys === 'none' + ? parsed.sortKeys + : 'none'; + return { input: parsed.input, format, indentSize, sortKeys }; + } catch { + return null; + } +} diff --git a/src/libs/site-url.ts b/src/libs/site-url.ts new file mode 100644 index 0000000..50f179e --- /dev/null +++ b/src/libs/site-url.ts @@ -0,0 +1,35 @@ +/** + * Build absolute URLs for static export (supports NEXT_PUBLIC_BASE_URL + BASE_PATH). + */ + +export function getSiteOrigin(): string { + return (process.env.NEXT_PUBLIC_BASE_URL || 'https://devpockit.hypkey.com').replace(/\/$/, ''); +} + +export function getPublicBasePathPrefix(): string { + const bp = process.env.NEXT_PUBLIC_BASE_PATH || ''; + if (!bp) return ''; + const trimmed = bp.replace(/^\/+|\/+$/g, ''); + return trimmed ? `/${trimmed}` : ''; +} + +/** + * @param path Absolute path starting with `/` (e.g. `/tools/formatters/json-formatter/`). + */ +export function absoluteSiteUrl(path: string): string { + const origin = getSiteOrigin(); + const base = getPublicBasePathPrefix(); + const p = path.startsWith('/') ? path : `/${path}`; + const combined = base ? `${base}${p}` : p; + const withSlash = combined.endsWith('/') ? combined : `${combined}/`; + return `${origin}${withSlash}`; +} + +/** Same as origin + base path + path, without forcing a trailing slash (for assets like `/og-image.png`). */ +export function absoluteAssetUrl(path: string): string { + const origin = getSiteOrigin(); + const base = getPublicBasePathPrefix(); + const p = path.startsWith('/') ? path : `/${path}`; + const combined = base ? `${base}${p}` : p; + return `${origin}${combined}`; +} From d9fcec0d8e0f52b1dca65e485c857352c393b308 Mon Sep 17 00:00:00 2001 From: DrakeNguyen Date: Fri, 1 May 2026 09:28:30 -0500 Subject: [PATCH 4/8] feat: enhance tool activity with pinning functionality Integrate pinning feature across various components, including AppSidebar, CommandPalette, and WelcomePage, allowing users to easily pin and unpin tools. Update ToolCard to display pin controls and improve user experience with visual cues. Remove LocalProcessingNotice from multiple tools to streamline the interface. Co-authored-by: Cursor --- src/components/AppSidebar.tsx | 87 ++++++++++++++--- src/components/layout/CommandPalette.tsx | 69 +++++-------- src/components/pages/WelcomePage.tsx | 97 ++----------------- src/components/tools/BaseEncoder.tsx | 2 - src/components/tools/HashGenerator.tsx | 2 - src/components/tools/JsonFormatter.tsx | 56 +---------- src/components/tools/JwtDecoder.tsx | 2 - src/components/tools/JwtEncoder.tsx | 2 - .../tools/LocalProcessingNotice.tsx | 15 --- src/components/tools/QrCodeDecoder.tsx | 2 - src/components/ui/tool-card.tsx | 38 +++++++- src/libs/json-formatter-share-url.ts | 80 --------------- 12 files changed, 146 insertions(+), 306 deletions(-) delete mode 100644 src/components/tools/LocalProcessingNotice.tsx delete mode 100644 src/libs/json-formatter-share-url.ts diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index c73ccba..6a629f0 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,6 +1,7 @@ "use client" import { SearchTools } from "@/components/layout/SearchTools" +import { useToolActivity } from "@/components/providers/ToolActivityProvider" import { Collapsible, CollapsibleContent, @@ -49,6 +50,7 @@ import { RefreshCw, Search, Settings, + Star, Sun, type LucideIcon, } from "lucide-react" @@ -95,6 +97,7 @@ export function AppSidebar({ const pathname = usePathname() const { theme, setTheme } = useTheme() const { state, toggleSidebar } = useSidebar() + const { hydrated: pinsHydrated, isPinned, togglePinnedTool } = useToolActivity() const [codeEditorTheme, setCodeEditorTheme] = useCodeEditorTheme('basicDark') const [mounted, setMounted] = React.useState(false) const [openCategories, setOpenCategories] = React.useState>(new Set()) @@ -292,18 +295,46 @@ export function AppSidebar({ {category.tools.map((tool) => (
{ - e.stopPropagation() - handleToolSelect(tool.id) - }} + className="flex items-center gap-1 -mx-2 rounded-md px-2 py-1 text-xs" > - {tool.name} +
{ + e.stopPropagation() + handleToolSelect(tool.id) + }} + > + {tool.name} +
+ {mounted && pinsHydrated ? ( + + ) : null}
))}
), - className: "w-48", + className: "w-56", }} > @@ -315,12 +346,42 @@ export function AppSidebar({ {category.tools.map((tool) => ( - handleToolSelect(tool.id)} - > - {tool.name} - +
+ handleToolSelect(tool.id)} + > + {tool.name} + + {pinsHydrated ? ( + + ) : null} +
))}
diff --git a/src/components/layout/CommandPalette.tsx b/src/components/layout/CommandPalette.tsx index 46c10bf..6463bc6 100644 --- a/src/components/layout/CommandPalette.tsx +++ b/src/components/layout/CommandPalette.tsx @@ -8,12 +8,6 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip'; import { useToolActivity } from '@/components/providers/ToolActivityProvider'; import { useKeyboardShortcut } from '@/hooks/useKeyboardShortcut'; import { getCategoryById, searchTools, toolIcons } from '@/libs/tools-data'; @@ -136,36 +130,31 @@ const CommandPaletteResult = ({
{showPin && onTogglePin ? ( - - - - - Pin or unpin — shown first in Jump back in and home - + ) : null} ); @@ -317,8 +306,7 @@ export function CommandPalette({ open, onOpenChange, onToolSelect }: CommandPale const listboxActive = hasQuery ? hasResults : showShortcuts; return ( - - + -

- Tip: use the star on each row to pin tools—pinned items appear first below and on the home page. -

-
{!hasQuery && !showShortcuts ? (
@@ -481,6 +465,5 @@ export function CommandPalette({ open, onOpenChange, onToolSelect }: CommandPale
-
); } diff --git a/src/components/pages/WelcomePage.tsx b/src/components/pages/WelcomePage.tsx index 4b457e2..0341e38 100644 --- a/src/components/pages/WelcomePage.tsx +++ b/src/components/pages/WelcomePage.tsx @@ -2,12 +2,9 @@ import { useToolActivity } from '@/components/providers/ToolActivityProvider'; import { ToolCard } from '@/components/ui/tool-card'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { getAllTools, getCategoryById, toolIcons } from '@/libs/tools-data'; import type { Tool } from '@/types/tools'; -import { Star } from 'lucide-react'; import { useMemo } from 'react'; -import { cn } from '@/libs/utils'; interface WelcomePageProps { onToolSelect: (toolId: string) => void; @@ -41,27 +38,8 @@ export function WelcomePage({ onToolSelect, activeToolIds = [] }: WelcomePagePro return order; }, [pinnedTools, recentTools, allTools]); - const quickAccessTools = useMemo(() => { - const seen = new Set(); - const out: Tool[] = []; - for (const t of pinnedTools) { - if (!seen.has(t.id)) { - seen.add(t.id); - out.push(t); - } - } - for (const t of recentTools) { - if (!seen.has(t.id)) { - seen.add(t.id); - out.push(t); - } - } - return out.slice(0, 12); - }, [pinnedTools, recentTools]); - return ( - -
+
{/* Hero Section */}
@@ -81,70 +59,6 @@ export function WelcomePage({ onToolSelect, activeToolIds = [] }: WelcomePagePro
- {hydrated && quickAccessTools.length > 0 ? ( -
-

- Pinned & recent -

-
- {quickAccessTools.map(tool => { - const IconComponent = toolIcons[tool.id]; - const pinned = isPinned(tool.id); - return ( -
- - - - - - - {pinned ? 'Unpin from Jump back in and home' : 'Pin — same as star in ⌘K palette'} - - -
- ); - })} -
-
- ) : null} - {/* Tools Grid */}
{orderedTools.map(tool => { @@ -162,11 +76,18 @@ export function WelcomePage({ onToolSelect, activeToolIds = [] }: WelcomePagePro supportsDesktop={tool.supportsDesktop} supportsMobile={tool.supportsMobile} onClick={() => onToolSelect(tool.id)} + pinButton={ + hydrated + ? { + pinned: isPinned(tool.id), + onToggle: () => togglePinnedTool(tool.id), + } + : undefined + } /> ); })}
- ); } diff --git a/src/components/tools/BaseEncoder.tsx b/src/components/tools/BaseEncoder.tsx index e112ef1..04ec7c6 100644 --- a/src/components/tools/BaseEncoder.tsx +++ b/src/components/tools/BaseEncoder.tsx @@ -2,7 +2,6 @@ import { useToolState } from '@/components/providers/ToolStateProvider'; import { Button } from '@/components/ui/button'; -import { LocalProcessingNotice } from '@/components/tools/LocalProcessingNotice'; import { CodePanel } from '@/components/ui/code-panel'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; @@ -170,7 +169,6 @@ export function BaseEncoder({ className, instanceId }: BaseEncoderProps) {

Encode and decode text using Base64, Base32, Base16 (hex), Base85, and other base encodings

-
{/* Body Section */} diff --git a/src/components/tools/HashGenerator.tsx b/src/components/tools/HashGenerator.tsx index f0d0977..b8b3073 100644 --- a/src/components/tools/HashGenerator.tsx +++ b/src/components/tools/HashGenerator.tsx @@ -2,7 +2,6 @@ import { useToolState } from '@/components/providers/ToolStateProvider'; import { Button } from '@/components/ui/button'; -import { LocalProcessingNotice } from '@/components/tools/LocalProcessingNotice'; import { CodePanel } from '@/components/ui/code-panel'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { LabeledInput } from '@/components/ui/labeled-input'; @@ -151,7 +150,6 @@ export function HashGenerator({ className, instanceId }: HashGeneratorProps) {

Generate cryptographic hashes using SHA-1, SHA-256, SHA-512, and SHA-3 algorithms

- {/* Body Section */} diff --git a/src/components/tools/JsonFormatter.tsx b/src/components/tools/JsonFormatter.tsx index c9ba385..adc829a 100644 --- a/src/components/tools/JsonFormatter.tsx +++ b/src/components/tools/JsonFormatter.tsx @@ -1,9 +1,7 @@ 'use client'; -import { useAppToast } from '@/components/providers/AppToastProvider'; import { useToolState } from '@/components/providers/ToolStateProvider'; import { Button } from '@/components/ui/button'; -import { LocalProcessingNotice } from '@/components/tools/LocalProcessingNotice'; import { CodePanel } from '@/components/ui/code-panel'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { LoadFileButton } from '@/components/ui/load-file-button'; @@ -11,11 +9,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { DEFAULT_JSON_OPTIONS, JSON_EXAMPLES, JSON_FORMAT_OPTIONS } from '@/config/json-formatter-config'; import { useCodeEditorTheme } from '@/hooks/useCodeEditorTheme'; import { formatJson, getJsonStats, type JsonFormatOptions, type JsonFormatResult } from '@/libs/json-formatter'; -import { decodeJsonFormatterShareFragment, encodeJsonFormatterShareFragment } from '@/libs/json-formatter-share-url'; import { cn } from '@/libs/utils'; import { ArrowPathIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; -import { Link2 } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; interface JsonFormatterProps { className?: string; @@ -23,8 +19,6 @@ interface JsonFormatterProps { } export function JsonFormatter({ className, instanceId }: JsonFormatterProps) { - const appToast = useAppToast(); - const shareAppliedRef = useRef(false); const { toolState, updateToolState } = useToolState('json-formatter', instanceId); // Initialize with defaults to avoid hydration mismatch @@ -79,21 +73,6 @@ export function JsonFormatter({ className, instanceId }: JsonFormatterProps) { } }, [toolState, isHydrated]); - useEffect(() => { - if (typeof window === 'undefined' || shareAppliedRef.current) return; - const decoded = decodeJsonFormatterShareFragment(window.location.hash); - if (!decoded) return; - shareAppliedRef.current = true; - setInput(decoded.input); - setOptions({ - format: decoded.format, - indentSize: decoded.indentSize, - sortKeys: decoded.sortKeys, - }); - setError(''); - window.history.replaceState(null, '', window.location.pathname + window.location.search); - }, []); - const handleFormat = async () => { if (!input.trim()) { setError('Please enter JSON to format'); @@ -131,26 +110,6 @@ export function JsonFormatter({ className, instanceId }: JsonFormatterProps) { setError(''); }; - const handleCopyShareLink = async () => { - const encoded = encodeJsonFormatterShareFragment({ - input, - format: options.format, - indentSize: options.indentSize, - sortKeys: options.sortKeys, - }); - if ('error' in encoded) { - appToast?.showToast(encoded.error); - return; - } - try { - const url = `${window.location.origin}${window.location.pathname}${window.location.search}#${encoded.fragment}`; - await navigator.clipboard.writeText(url); - appToast?.showToast('Share link copied'); - } catch { - appToast?.showToast('Could not copy link'); - } - }; - const getCharacterCount = (text: string): number => { return text.length; }; @@ -170,7 +129,6 @@ export function JsonFormatter({ className, instanceId }: JsonFormatterProps) {

Format, minify, and validate JSON with syntax highlighting and statistics

- {/* Body Section */} @@ -289,18 +247,6 @@ export function JsonFormatter({ className, instanceId }: JsonFormatterProps) { - } footerLeftContent={ diff --git a/src/components/tools/JwtDecoder.tsx b/src/components/tools/JwtDecoder.tsx index 4fba185..36bf973 100644 --- a/src/components/tools/JwtDecoder.tsx +++ b/src/components/tools/JwtDecoder.tsx @@ -7,7 +7,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { SecretInput } from '@/components/ui/secret-input'; import { JWT_EXAMPLE_TOKENS } from '@/config/jwt-decoder-config'; import { useCodeEditorTheme } from '@/hooks/useCodeEditorTheme'; -import { LocalProcessingNotice } from '@/components/tools/LocalProcessingNotice'; import { decodeJwt, formatTimeRemaining, verifyJwtSignature, type JwtDecodeResult } from '@/libs/jwt-decoder'; import { cn } from '@/libs/utils'; import { ArrowPathIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; @@ -224,7 +223,6 @@ export function JwtDecoder({ className, instanceId }: JwtDecoderProps) {

Decode and analyze JWT tokens. View header, payload, and signature.

- {/* Body Section */} diff --git a/src/components/tools/JwtEncoder.tsx b/src/components/tools/JwtEncoder.tsx index 37deb60..85bde36 100644 --- a/src/components/tools/JwtEncoder.tsx +++ b/src/components/tools/JwtEncoder.tsx @@ -15,7 +15,6 @@ import { type JwtEncoderOptions } from '@/config/jwt-encoder-config'; import { useCodeEditorTheme } from '@/hooks/useCodeEditorTheme'; -import { LocalProcessingNotice } from '@/components/tools/LocalProcessingNotice'; import { encodeJwt, formatJson, getDefaultHeader, validateJson, type JwtEncodeResult } from '@/libs/jwt-encoder'; import { cn } from '@/libs/utils'; import { ArrowPathIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; @@ -216,7 +215,6 @@ export function JwtEncoder({ className, instanceId }: JwtEncoderProps) {

Create and encode JWT tokens with custom headers and payloads.

- {/* Body Section */} diff --git a/src/components/tools/LocalProcessingNotice.tsx b/src/components/tools/LocalProcessingNotice.tsx deleted file mode 100644 index 2e5d8b8..0000000 --- a/src/components/tools/LocalProcessingNotice.tsx +++ /dev/null @@ -1,15 +0,0 @@ -'use client'; - -interface LocalProcessingNoticeProps { - /** Extra tool-specific detail after the standard sentence */ - detail?: string; -} - -export function LocalProcessingNotice({ detail }: LocalProcessingNoticeProps) { - return ( -

- Everything you enter is processed only in this browser tab—it is not sent to our servers. - {detail ? ` ${detail}` : ''} -

- ); -} diff --git a/src/components/tools/QrCodeDecoder.tsx b/src/components/tools/QrCodeDecoder.tsx index f57955a..8cab89d 100644 --- a/src/components/tools/QrCodeDecoder.tsx +++ b/src/components/tools/QrCodeDecoder.tsx @@ -2,7 +2,6 @@ import { useToolState } from '@/components/providers/ToolStateProvider'; import { Badge } from '@/components/ui/badge'; -import { LocalProcessingNotice } from '@/components/tools/LocalProcessingNotice'; import { Button } from '@/components/ui/button'; import { CodePanel } from '@/components/ui/code-panel'; import { DEFAULT_QR_DECODER_OPTIONS } from '@/config/qr-code-decoder-config'; @@ -304,7 +303,6 @@ export function QrCodeDecoder({ className, instanceId, onResult, onError }: QrCo

Upload an image containing a QR code to decode it with support for multiple formats

- {/* Body Section */} diff --git a/src/components/ui/tool-card.tsx b/src/components/ui/tool-card.tsx index 73e676f..a1f9c5f 100644 --- a/src/components/ui/tool-card.tsx +++ b/src/components/ui/tool-card.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Laptop, Smartphone } from 'lucide-react'; +import { Laptop, Smartphone, Star } from 'lucide-react'; import { cn } from '@/libs/utils'; export interface ToolCardProps { @@ -11,6 +11,11 @@ export interface ToolCardProps { supportsMobile?: boolean; onClick?: () => void; className?: string; + /** When set (e.g. after localStorage hydrate), shows a pin control on the card */ + pinButton?: { + pinned: boolean; + onToggle: () => void; + }; } export function ToolCard({ @@ -22,6 +27,7 @@ export function ToolCard({ supportsMobile = true, onClick, className, + pinButton, }: ToolCardProps) { const [isHovered, setIsHovered] = React.useState(false); const isSelected = isActive || isHovered; @@ -29,7 +35,8 @@ export function ToolCard({ return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > + {pinButton ? ( + + ) : null}
{/* Icon Section */}
MAX_FRAGMENT_LENGTH) { - return { - error: 'Input is too large to fit in a shareable link. Shorten the JSON or copy it manually.', - }; - } - return { fragment }; - } catch { - return { error: 'Could not build share link for this input.' }; - } -} - -export function decodeJsonFormatterShareFragment(hash: string): JsonFormatterSharePayload | null { - const trimmed = hash.startsWith('#') ? hash.slice(1) : hash; - if (!trimmed.startsWith(PREFIX)) { - return null; - } - const body = trimmed.slice(PREFIX.length); - if (!body) return null; - try { - const json = base64UrlToUtf8(body); - const parsed = JSON.parse(json) as Record; - if (typeof parsed.input !== 'string') return null; - const format = parsed.format === 'minify' || parsed.format === 'beautify' ? parsed.format : 'beautify'; - const indentSize = - typeof parsed.indentSize === 'number' && Number.isFinite(parsed.indentSize) - ? parsed.indentSize - : 2; - const sortKeys = - parsed.sortKeys === 'asc' || parsed.sortKeys === 'desc' || parsed.sortKeys === 'none' - ? parsed.sortKeys - : 'none'; - return { input: parsed.input, format, indentSize, sortKeys }; - } catch { - return null; - } -} From c0b163393b5d87247ff00fec09138758f0492d8d Mon Sep 17 00:00:00 2001 From: DrakeNguyen Date: Fri, 1 May 2026 11:35:26 -0500 Subject: [PATCH 5/8] feat: add Text Minifier tool for whitespace reduction Introduce a new Text Minifier tool that collapses redundant whitespace in plain text. Users can choose between single-line and per-line modes, with options for normalizing line endings and removing empty lines. The tool includes a user-friendly interface with input/output panels and example text for demonstration. Additionally, implement unit tests to ensure functionality and accuracy of the minification process. Co-authored-by: Cursor --- src/components/tools/TextMinifier.tsx | 277 +++++++++++++++++++++++ src/config/text-minifier-config.ts | 35 +++ src/libs/__tests__/text-minifier.test.ts | 89 ++++++++ src/libs/text-minifier.ts | 67 ++++++ src/libs/tool-components.ts | 1 + src/libs/tools-data.ts | 15 ++ 6 files changed, 484 insertions(+) create mode 100644 src/components/tools/TextMinifier.tsx create mode 100644 src/config/text-minifier-config.ts create mode 100644 src/libs/__tests__/text-minifier.test.ts create mode 100644 src/libs/text-minifier.ts diff --git a/src/components/tools/TextMinifier.tsx b/src/components/tools/TextMinifier.tsx new file mode 100644 index 0000000..93dc185 --- /dev/null +++ b/src/components/tools/TextMinifier.tsx @@ -0,0 +1,277 @@ +'use client'; + +import { useToolState } from '@/components/providers/ToolStateProvider'; +import { Button } from '@/components/ui/button'; +import { CodePanel } from '@/components/ui/code-panel'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { LoadFileButton } from '@/components/ui/load-file-button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { + DEFAULT_TEXT_MINIFY_OPTIONS, + TEXT_MINIFIER_EXAMPLES, + TEXT_MINIFY_MODE_OPTIONS, +} from '@/config/text-minifier-config'; +import { useCodeEditorTheme } from '@/hooks/useCodeEditorTheme'; +import { minifyText, type TextMinifyOptions, type TextMinifyResult } from '@/libs/text-minifier'; +import { cn } from '@/libs/utils'; +import { ArrowPathIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; +import { useEffect, useState } from 'react'; + +interface TextMinifierProps { + className?: string; + instanceId: string; +} + +export function TextMinifier({ className, instanceId }: TextMinifierProps) { + const { toolState, updateToolState } = useToolState('text-minifier', instanceId); + + const [options, setOptions] = useState(DEFAULT_TEXT_MINIFY_OPTIONS); + const [input, setInput] = useState(''); + const [output, setOutput] = useState(''); + const [error, setError] = useState(''); + const [isMinifying, setIsMinifying] = useState(false); + const [isHydrated, setIsHydrated] = useState(false); + const [stats, setStats] = useState | null>(null); + + const [theme] = useCodeEditorTheme('basicDark'); + const [inputWrapText, setInputWrapText] = useState(true); + const [outputWrapText, setOutputWrapText] = useState(true); + + useEffect(() => { + setIsHydrated(true); + if (toolState) { + if (toolState.options) setOptions(toolState.options as TextMinifyOptions); + if (toolState.input) setInput(toolState.input as string); + if (toolState.output) setOutput(toolState.output as string); + if (toolState.error) setError(toolState.error as string); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (isHydrated) { + updateToolState({ + options, + input, + output, + error, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options, input, output, error, isHydrated]); + + useEffect(() => { + if (isHydrated && (!toolState || Object.keys(toolState).length === 0)) { + setOptions(DEFAULT_TEXT_MINIFY_OPTIONS); + setInput(''); + setOutput(''); + setError(''); + setStats(null); + } + }, [toolState, isHydrated]); + + const handleMinify = async () => { + if (!input.trim()) { + setError('Please enter text to minify'); + return; + } + + setIsMinifying(true); + setError(''); + + try { + await new Promise((resolve) => setTimeout(resolve, 150)); + const result = minifyText(input, options); + setOutput(result.output); + setStats({ + originalLength: result.originalLength, + outputLength: result.outputLength, + originalLineCount: result.originalLineCount, + outputLineCount: result.outputLineCount, + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to minify text'); + setOutput(''); + setStats(null); + } finally { + setIsMinifying(false); + } + }; + + const loadExample = (key: keyof typeof TEXT_MINIFIER_EXAMPLES) => { + setInput(TEXT_MINIFIER_EXAMPLES[key]); + setError(''); + }; + + const perLineMode = options.mode === 'per_line'; + + return ( +
+
+

+ Text Minifier +

+

+ Collapse extra whitespace in plain text. Use single-line mode for one paragraph, or per-line mode to compact + each line while preserving line breaks. +

+
+ +
+
+
+
+ + +
+ + setOptions((prev) => ({ ...prev, normalizeLineEndings: checked })) + } + size="sm" + id="text-minify-normalize-eol" + /> + +
+ +
+ + setOptions((prev) => ({ ...prev, removeEmptyLines: checked })) + } + size="sm" + id="text-minify-remove-empty" + /> + +
+
+ + {stats && ( +

+ Before: {stats.originalLength.toLocaleString()} characters · {stats.originalLineCount.toLocaleString()}{' '} + lines → After: {stats.outputLength.toLocaleString()} characters ·{' '} + {stats.outputLineCount.toLocaleString()} lines +

+ )} +
+ +
+ + { + setInput(content); + setError(''); + }} + /> + + + + + + loadExample('multilineParagraph')}> + Multiline paragraph + + loadExample('indentedBlocks')}> + Indented lines + + loadExample('mixedCrlf')}> + Mixed CRLF / LF + + + + + } + footerRightContent={ + + } + /> + + +
+ + {error && ( +
+
{error}
+
+ )} +
+
+
+ ); +} diff --git a/src/config/text-minifier-config.ts b/src/config/text-minifier-config.ts new file mode 100644 index 0000000..d2138d5 --- /dev/null +++ b/src/config/text-minifier-config.ts @@ -0,0 +1,35 @@ +/** + * Text minifier tool configuration + */ + +import type { TextMinifyMode, TextMinifyOptions } from '@/libs/text-minifier'; + +export const TEXT_MINIFY_MODE_OPTIONS = [ + { value: 'single_line' as const, label: 'Single line', description: 'Collapse all whitespace into single spaces (one paragraph)' }, + { value: 'per_line' as const, label: 'Per line', description: 'Trim each line and collapse spaces inside lines; keep newlines' }, +] as const; + +export const DEFAULT_TEXT_MINIFY_OPTIONS: TextMinifyOptions = { + mode: 'single_line', + normalizeLineEndings: true, + removeEmptyLines: false, +}; + +export const TEXT_MINIFIER_EXAMPLES: Record< + 'multilineParagraph' | 'indentedBlocks' | 'mixedCrlf', + string +> = { + multilineParagraph: `The quick + + brown fox + +jumps over the lazy dog.`, + + indentedBlocks: ` + Line one with trailing spaces + Line two + + Line three deeply indented`, + + mixedCrlf: 'Same line\r\nNext line\rMac old style\r\nThird', +}; diff --git a/src/libs/__tests__/text-minifier.test.ts b/src/libs/__tests__/text-minifier.test.ts new file mode 100644 index 0000000..e41a2ae --- /dev/null +++ b/src/libs/__tests__/text-minifier.test.ts @@ -0,0 +1,89 @@ +import { minifyText, type TextMinifyOptions } from '../text-minifier'; + +describe('text-minifier', () => { + const singleDefaults: TextMinifyOptions = { + mode: 'single_line', + normalizeLineEndings: true, + removeEmptyLines: false, + }; + + const perLineDefaults: TextMinifyOptions = { + mode: 'per_line', + normalizeLineEndings: true, + removeEmptyLines: false, + }; + + describe('single_line mode', () => { + it('collapses whitespace and trims ends', () => { + expect(minifyText(' a \n\t b ', singleDefaults)).toMatchObject({ + output: 'a b', + outputLineCount: 1, + }); + }); + + it('produces one logical line for multiline prose', () => { + expect( + minifyText('hello world\ngoodbye', singleDefaults).output + ).toBe('hello world goodbye'); + }); + }); + + describe('per_line mode', () => { + it('trims lines and collapses spaces inside lines while preserving line breaks', () => { + const r = minifyText(' foo bar \n baz\t\tqux ', perLineDefaults); + expect(r.output).toBe('foo bar\nbaz qux'); + expect(r.originalLineCount).toBe(2); + expect(r.outputLineCount).toBe(2); + }); + + it('removes blank lines when removeEmptyLines is true', () => { + const opts: TextMinifyOptions = { + ...perLineDefaults, + removeEmptyLines: true, + }; + expect(minifyText('a\n\n\nb', opts).output).toBe('a\nb'); + }); + + it('preserves blank lines when removeEmptyLines is false', () => { + expect(minifyText('a\n\nb', perLineDefaults).output).toBe('a\n\nb'); + }); + }); + + describe('normalizeLineEndings', () => { + it('normalizes CRLF and CR to LF before minifying (single_line)', () => { + const r = minifyText('x\r\ny', singleDefaults); + expect(r.output).toBe('x y'); + expect(r.originalLineCount).toBe(2); + }); + + it('leaves CRLF as-is when normalization is disabled', () => { + const raw = 'x\r\ny'; + const r = minifyText(raw, { + ...singleDefaults, + normalizeLineEndings: false, + }); + expect(r.output).toBe('x y'); + expect(r.originalLength).toBe(raw.length); + }); + }); + + describe('edge cases', () => { + it('returns empty output and zero stats for empty string', () => { + expect(minifyText('', singleDefaults)).toEqual({ + output: '', + originalLength: 0, + outputLength: 0, + originalLineCount: 0, + outputLineCount: 0, + }); + }); + + it('handles whitespace-only input in single_line as empty string', () => { + expect(minifyText(' \n\t ', singleDefaults)).toMatchObject({ + output: '', + outputLength: 0, + outputLineCount: 0, + }); + }); + }); +}); diff --git a/src/libs/text-minifier.ts b/src/libs/text-minifier.ts new file mode 100644 index 0000000..78b59ac --- /dev/null +++ b/src/libs/text-minifier.ts @@ -0,0 +1,67 @@ +export type TextMinifyMode = 'single_line' | 'per_line'; + +export interface TextMinifyOptions { + mode: TextMinifyMode; + normalizeLineEndings: boolean; + removeEmptyLines: boolean; +} + +export interface TextMinifyResult { + output: string; + originalLength: number; + outputLength: number; + originalLineCount: number; + outputLineCount: number; +} + +/** Line count treating empty string as zero lines */ +function countLines(text: string): number { + if (text === '') return 0; + return text.split('\n').length; +} + +function normalizeEnds(text: string): string { + return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function prepareWorkingCopy(input: string, normalizeLineEndings: boolean): string { + const base = normalizeLineEndings ? normalizeEnds(input) : input; + return base; +} + +function minifySingleLine(working: string): string { + return working.trim().replace(/\s+/g, ' '); +} + +function minifyPerLine(working: string, removeEmptyLines: boolean): string { + const segments = working.split('\n').map((line) => line.trim().replace(/\s+/g, ' ')); + const kept = removeEmptyLines ? segments.filter((line) => line.length > 0) : segments; + return kept.join('\n').trim(); +} + +/** + * Collapse whitespace in plain text. Does not parse structure (JSON/XML). + */ +export function minifyText(input: string, options: TextMinifyOptions): TextMinifyResult { + const working = prepareWorkingCopy(input, options.normalizeLineEndings); + const originalLength = working.length; + const originalLineCount = countLines(working); + + let output: string; + if (options.mode === 'single_line') { + output = minifySingleLine(working); + } else { + output = minifyPerLine(working, options.removeEmptyLines); + } + + const outputLength = output.length; + const outputLineCount = countLines(output); + + return { + output, + originalLength, + outputLength, + originalLineCount, + outputLineCount, + }; +} diff --git a/src/libs/tool-components.ts b/src/libs/tool-components.ts index 8751601..4bcc4d5 100644 --- a/src/libs/tool-components.ts +++ b/src/libs/tool-components.ts @@ -17,6 +17,7 @@ export const getToolComponent = async (componentName: string) => { 'IpChecker': () => import('@/components/tools/IpChecker').then(m => m.IpChecker), 'DiffChecker': () => import('@/components/tools/DiffChecker').then(m => m.DiffChecker), 'HtmlToMarkdown': () => import('@/components/tools/HtmlToMarkdown').then(m => m.HtmlToMarkdown), + 'TextMinifier': () => import('@/components/tools/TextMinifier').then(m => m.TextMinifier), 'TimestampConverter': () => import('@/components/tools/TimestampConverter').then(m => m.TimestampConverter), 'ListComparison': () => import('@/components/tools/ListComparison').then(m => m.ListComparison), 'ListConverter': () => import('@/components/tools/ListConverter').then(m => m.ListConverter), diff --git a/src/libs/tools-data.ts b/src/libs/tools-data.ts index 9bd3af4..db3cd64 100644 --- a/src/libs/tools-data.ts +++ b/src/libs/tools-data.ts @@ -12,6 +12,7 @@ import { List, Lock, MapPin, + Minimize2, Network, QrCode, RefreshCw, @@ -46,6 +47,7 @@ export const toolIcons: Record = { 'ip-checker': MapPin, 'diff-checker': GitCompare, 'html-to-markdown': ClipboardPaste, + 'text-minifier': Minimize2, 'timestamp-converter': Timer, 'list-comparison': List, 'list-converter': ArrowLeftRight, @@ -121,6 +123,19 @@ export const toolCategories: ToolCategory[] = [ supportsDesktop: true, supportsMobile: true, }, + { + id: 'text-minifier', + name: 'Text Minifier', + description: + 'Collapse redundant whitespace in plain text—single-line or per-line—with optional CRLF normalization and empty-line removal', + category: 'text-tools', + icon: '⏬', + isPopular: false, + path: '/tools/text-tools/text-minifier', + component: 'TextMinifier', + supportsDesktop: true, + supportsMobile: true, + }, ], }, { From 122dfc5f006aa5cb9bd9056b99b22fe2859d08d0 Mon Sep 17 00:00:00 2001 From: DrakeNguyen Date: Fri, 1 May 2026 13:03:37 -0500 Subject: [PATCH 6/8] feat: add HTTP Status Cheatsheet tool for quick reference Introduce a new HTTP Status Cheatsheet tool that provides a searchable reference for common HTTP response status codes, including their classes, names, and short explanations. The tool features a user-friendly interface with search and filter options, allowing users to easily find relevant status codes. Additionally, implement unit tests to ensure the accuracy and functionality of the tool. Co-authored-by: Cursor --- next-env.d.ts | 2 +- src/components/tools/HttpStatusCheatsheet.tsx | 298 ++++++++++++++++++ src/config/http-status-cheatsheet-config.ts | 10 + src/libs/__tests__/http-status-codes.test.ts | 49 +++ src/libs/http-status-codes.ts | 272 ++++++++++++++++ src/libs/tool-components.ts | 1 + src/libs/tools-data.ts | 15 + 7 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 src/components/tools/HttpStatusCheatsheet.tsx create mode 100644 src/config/http-status-cheatsheet-config.ts create mode 100644 src/libs/__tests__/http-status-codes.test.ts create mode 100644 src/libs/http-status-codes.ts diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/components/tools/HttpStatusCheatsheet.tsx b/src/components/tools/HttpStatusCheatsheet.tsx new file mode 100644 index 0000000..52ff533 --- /dev/null +++ b/src/components/tools/HttpStatusCheatsheet.tsx @@ -0,0 +1,298 @@ +'use client'; + +import { useToolState } from '@/components/providers/ToolStateProvider'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { HTTP_STATUS_CATEGORY_OPTIONS } from '@/config/http-status-cheatsheet-config'; +import { + filterHttpStatuses, + getHttpStatusLineExample, + getHttpStatusTroubleshooting, + HTTP_STATUS_ENTRIES, + type HttpStatusCategoryFilter, + type HttpStatusEntry, +} from '@/libs/http-status-codes'; +import { cn } from '@/libs/utils'; +import { useEffect, useMemo, useState, type KeyboardEvent } from 'react'; + +interface HttpStatusCheatsheetProps { + className?: string; + instanceId: string; +} + +export function HttpStatusCheatsheet({ className, instanceId }: HttpStatusCheatsheetProps) { + const { toolState, updateToolState } = useToolState('http-status-cheatsheet', instanceId); + + const [searchQuery, setSearchQuery] = useState(''); + const [category, setCategory] = useState('all'); + const [detailEntry, setDetailEntry] = useState(null); + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + setIsHydrated(true); + if (toolState) { + if (toolState.searchQuery !== undefined && toolState.searchQuery !== null) { + setSearchQuery(toolState.searchQuery as string); + } + if (toolState.category) setCategory(toolState.category as HttpStatusCategoryFilter); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (isHydrated) { + updateToolState({ searchQuery, category }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery, category, isHydrated]); + + useEffect(() => { + if (isHydrated && (!toolState || Object.keys(toolState).length === 0)) { + setSearchQuery(''); + setCategory('all'); + } + }, [toolState, isHydrated]); + + const filtered = useMemo( + () => filterHttpStatuses(searchQuery, category), + [searchQuery, category] + ); + + const categoryBadgeClass = (c: HttpStatusCategoryFilter): string => { + switch (c) { + case '1xx': + return 'bg-sky-100 text-sky-900 dark:bg-sky-950/40 dark:text-sky-100'; + case '2xx': + return 'bg-emerald-100 text-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-100'; + case '3xx': + return 'bg-amber-100 text-amber-950 dark:bg-amber-950/35 dark:text-amber-50'; + case '4xx': + return 'bg-orange-100 text-orange-950 dark:bg-orange-950/40 dark:text-orange-50'; + case '5xx': + return 'bg-red-100 text-red-950 dark:bg-red-950/40 dark:text-red-50'; + default: + return 'bg-neutral-100 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100'; + } + }; + + const openDetail = (row: HttpStatusEntry) => setDetailEntry(row); + + const onRowActivate = (row: HttpStatusEntry) => () => openDetail(row); + + const onRowKeyDown = + (row: HttpStatusEntry) => (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openDetail(row); + } + }; + + const troubleshootingParagraphs = useMemo(() => { + if (!detailEntry) return []; + return getHttpStatusTroubleshooting(detailEntry).split('\n\n').filter(Boolean); + }, [detailEntry]); + + return ( +
+
+

+ HTTP Status Codes +

+

+ Searchable cheatsheet for common HTTP response status codes ({HTTP_STATUS_ENTRIES.length}{' '} + entries). Filter by status class or search code, phrase, or description. +

+
+ +
+
+
+
+ + setSearchQuery(e.target.value)} + className="max-w-xl" + /> +
+
+ +
+
+ +

+ Showing {filtered.length}{' '} + {filtered.length === 1 ? 'status code' : 'status codes'} + {category !== 'all' ? ` in ${category}` : ''}. Click any row or press Enter or Space while + focused for more detail. +

+ +
+ + + + + + + + + + + {filtered.map((row, index) => ( + + + + + + + ))} + +
+ Code + + Class + + Name + + Description +
+ {row.code} + + + {row.category} + + + {row.name} + + {row.summary} +
+ + {filtered.length === 0 && ( +
+ No status codes match your search. Try a different phrase or reset the class filter. +
+ )} +
+
+
+ + !open && setDetailEntry(null)}> + + {detailEntry ? ( + <> + +
+ + {detailEntry.category} + +
+ + + {detailEntry.code} + + {detailEntry.name} + +
+ +
+
+

+ Example status line +

+
+                      {getHttpStatusLineExample(detailEntry)}
+                    
+
+
+

+ Overview +

+

+ {detailEntry.summary} +

+
+
+

+ Typical problems & causes +

+
+ {troubleshootingParagraphs.map((para, idx) => ( +

{para}

+ ))} +
+
+
+
+ + + + + + + + ) : null} +
+
+
+ ); +} diff --git a/src/config/http-status-cheatsheet-config.ts b/src/config/http-status-cheatsheet-config.ts new file mode 100644 index 0000000..3903ccc --- /dev/null +++ b/src/config/http-status-cheatsheet-config.ts @@ -0,0 +1,10 @@ +import type { HttpStatusCategoryFilter } from '@/libs/http-status-codes'; + +export const HTTP_STATUS_CATEGORY_OPTIONS: { value: HttpStatusCategoryFilter; label: string }[] = [ + { value: 'all', label: 'All classes' }, + { value: '1xx', label: '1xx Informational' }, + { value: '2xx', label: '2xx Success' }, + { value: '3xx', label: '3xx Redirection' }, + { value: '4xx', label: '4xx Client error' }, + { value: '5xx', label: '5xx Server error' }, +]; diff --git a/src/libs/__tests__/http-status-codes.test.ts b/src/libs/__tests__/http-status-codes.test.ts new file mode 100644 index 0000000..92fa900 --- /dev/null +++ b/src/libs/__tests__/http-status-codes.test.ts @@ -0,0 +1,49 @@ +import { + filterHttpStatuses, + getHttpStatusDetailedDescription, + getHttpStatusLineExample, + getHttpStatusTroubleshooting, + HTTP_STATUS_ENTRIES, + httpStatusProblemsCoversCheatsheet, +} from '../http-status-codes'; + +describe('http-status-codes', () => { + it('exposes a non-empty static list', () => { + expect(HTTP_STATUS_ENTRIES.length).toBeGreaterThan(40); + }); + + it('filters by code substring', () => { + const r = filterHttpStatuses('404', 'all'); + expect(r.some((e) => e.code === 404)).toBe(true); + expect(r.every((e) => e.code.toString().includes('404'))).toBe(true); + }); + + it('filters by name or summary', () => { + const tee = filterHttpStatuses('teapot', 'all'); + expect(tee.some((e) => e.code === 418)).toBe(true); + }); + + it('respects category filter', () => { + const only2xx = filterHttpStatuses('', '2xx'); + expect(only2xx.length).toBeGreaterThan(5); + expect(only2xx.every((e) => e.category === '2xx')).toBe(true); + }); + + it('provides extended troubleshooting (no RFC/IANA footer) for dialogs', () => { + expect(httpStatusProblemsCoversCheatsheet()).toBe(true); + + const entry = HTTP_STATUS_ENTRIES.find((e) => e.code === 404); + expect(entry).toBeDefined(); + if (!entry) return; + expect(getHttpStatusLineExample(entry)).toContain('404'); + expect(getHttpStatusLineExample(entry)).toContain('Not Found'); + + const detail = getHttpStatusDetailedDescription(entry); + expect(detail).toContain(entry.summary); + expect(detail).not.toMatch(/RFC 9110|IANA HTTP Status Code Registry/i); + + const problems = getHttpStatusTroubleshooting(entry); + expect(problems.length).toBeGreaterThan(80); + expect(problems.toLowerCase()).toMatch(/route|proxy|deployment/i); + }); +}); diff --git a/src/libs/http-status-codes.ts b/src/libs/http-status-codes.ts new file mode 100644 index 0000000..71f4f1d --- /dev/null +++ b/src/libs/http-status-codes.ts @@ -0,0 +1,272 @@ +export type HttpStatusCategory = '1xx' | '2xx' | '3xx' | '4xx' | '5xx'; + +export interface HttpStatusEntry { + code: number; + /** Official or common reason phrase */ + name: string; + summary: string; + category: HttpStatusCategory; +} + +/** + * Common HTTP status codes with short descriptions for the cheatsheet table. + */ +export const HTTP_STATUS_ENTRIES: HttpStatusEntry[] = [ + { code: 100, name: 'Continue', summary: 'Client should continue with the request.', category: '1xx' }, + { code: 101, name: 'Switching Protocols', summary: 'Server is switching protocols per Upgrade header.', category: '1xx' }, + { code: 102, name: 'Processing', summary: 'Request received; WebDAV; server is processing.', category: '1xx' }, + { code: 103, name: 'Early Hints', summary: 'HMR-preload hints while the final response is prepared.', category: '1xx' }, + + { code: 200, name: 'OK', summary: 'Request succeeded.', category: '2xx' }, + { code: 201, name: 'Created', summary: 'Resource created; often returns Location.', category: '2xx' }, + { code: 202, name: 'Accepted', summary: 'Accepted for processing; processing not complete.', category: '2xx' }, + { + code: 203, + name: 'Non-Authoritative Information', + summary: 'Success; payload from a transforming proxy.', + category: '2xx', + }, + { code: 204, name: 'No Content', summary: 'Success with no payload body.', category: '2xx' }, + { code: 205, name: 'Reset Content', summary: 'Success; client should reset document UI.', category: '2xx' }, + { code: 206, name: 'Partial Content', summary: 'Range request fulfilled with partial representation.', category: '2xx' }, + { code: 207, name: 'Multi-Status', summary: 'WebDAV; multiple distinct status codes in body.', category: '2xx' }, + { code: 208, name: 'Already Reported', summary: 'WebDAV; avoids repeating members inside a propstat.', category: '2xx' }, + { code: 226, name: 'IM Used', summary: 'Instance manipulation applied inside response.', category: '2xx' }, + + { code: 300, name: 'Multiple Choices', summary: 'Several representations; Link header may guide choice.', category: '3xx' }, + { code: 301, name: 'Moved Permanently', summary: 'Target URI permanently changed.', category: '3xx' }, + { code: 302, name: 'Found', summary: 'Temporary redirect (historically “Moved Temporarily”).', category: '3xx' }, + { code: 303, name: 'See Other', summary: 'GET the resource at another URI.', category: '3xx' }, + { code: 304, name: 'Not Modified', summary: 'Cached representation still valid.', category: '3xx' }, + { code: 305, name: 'Use Proxy', summary: 'Deprecated in HTTP/1.1; deprecated proxy redirection.', category: '3xx' }, + { code: 307, name: 'Temporary Redirect', summary: 'Same method and body on the redirected URI.', category: '3xx' }, + { code: 308, name: 'Permanent Redirect', summary: 'Permanent redirect preserving method and body.', category: '3xx' }, + + { code: 400, name: 'Bad Request', summary: 'Malformed syntax or validation failure.', category: '4xx' }, + { code: 401, name: 'Unauthorized', summary: 'Authentication required (WWW-Authenticate).', category: '4xx' }, + { code: 402, name: 'Payment Required', summary: 'Reserved for future payment schemes.', category: '4xx' }, + { code: 403, name: 'Forbidden', summary: 'Understood request but refusal to authorize.', category: '4xx' }, + { code: 404, name: 'Not Found', summary: 'No matching resource.', category: '4xx' }, + { code: 405, name: 'Method Not Allowed', summary: 'Method not supported for resource.', category: '4xx' }, + { code: 406, name: 'Not Acceptable', summary: 'No representation matches Accept negotiation.', category: '4xx' }, + { code: 407, name: 'Proxy Authentication Required', summary: 'Proxy demands authentication.', category: '4xx' }, + { code: 408, name: 'Request Timeout', summary: 'Server waited too long for a complete request.', category: '4xx' }, + { code: 409, name: 'Conflict', summary: 'Conflict with current resource state.', category: '4xx' }, + { code: 410, name: 'Gone', summary: 'Resource once existed and is intentionally gone.', category: '4xx' }, + { code: 411, name: 'Length Required', summary: 'Content-Length or Transfer-Encoding needed.', category: '4xx' }, + { code: 412, name: 'Precondition Failed', summary: 'Precondition headers failed on evaluation.', category: '4xx' }, + { code: 413, name: 'Payload Too Large', summary: 'Request body exceeds server limits.', category: '4xx' }, + { code: 414, name: 'URI Too Long', summary: 'Request target URI exceeds limits.', category: '4xx' }, + { code: 415, name: 'Unsupported Media Type', summary: 'Format not supported by server.', category: '4xx' }, + { code: 416, name: 'Range Not Satisfiable', summary: 'Range header cannot be fulfilled.', category: '4xx' }, + { code: 417, name: 'Expectation Failed', summary: 'Expect header condition could not be met.', category: '4xx' }, + { code: 418, name: "I'm a teapot", summary: 'Easter egg (RFC 2324); not a serious protocol code.', category: '4xx' }, + { code: 421, name: 'Misdirected Request', summary: 'Request not intended for this origin/server.', category: '4xx' }, + { code: 422, name: 'Unprocessable Entity', summary: 'Semantic errors; common in APIs for validation.', category: '4xx' }, + { code: 423, name: 'Locked', summary: 'WebDAV; resource is locked.', category: '4xx' }, + { code: 424, name: 'Failed Dependency', summary: 'WebDAV; action failed due to another failed request.', category: '4xx' }, + { code: 425, name: 'Too Early', summary: 'Replay risk; early data rejected.', category: '4xx' }, + { code: 426, name: 'Upgrade Required', summary: 'Switch required protocol (Upgrade header).', category: '4xx' }, + { code: 428, name: 'Precondition Required', summary: 'Conditional headers required to avoid conflicts.', category: '4xx' }, + { code: 429, name: 'Too Many Requests', summary: 'Rate limiting; Retry-After may be present.', category: '4xx' }, + { code: 431, name: 'Request Header Fields Too Large', summary: 'Headers overall too large or single field too big.', category: '4xx' }, + { + code: 451, + name: 'Unavailable For Legal Reasons', + summary: 'Blocked for legal or censorship reasons.', + category: '4xx', + }, + + { code: 500, name: 'Internal Server Error', summary: 'Unexpected server condition.', category: '5xx' }, + { code: 501, name: 'Not Implemented', summary: 'Server does not support the functionality.', category: '5xx' }, + { code: 502, name: 'Bad Gateway', summary: 'Invalid response from upstream gateway.', category: '5xx' }, + { code: 503, name: 'Service Unavailable', summary: 'Temporary overload or maintenance.', category: '5xx' }, + { code: 504, name: 'Gateway Timeout', summary: 'Upstream did not respond in time.', category: '5xx' }, + { code: 505, name: 'HTTP Version Not Supported', summary: 'HTTP version not supported.', category: '5xx' }, + { code: 506, name: 'Variant Also Negotiates', summary: 'Transparent negotiation misconfiguration.', category: '5xx' }, + { code: 507, name: 'Insufficient Storage', summary: 'WebDAV; cannot store representation.', category: '5xx' }, + { code: 508, name: 'Loop Detected', summary: 'WebDAV; infinite loop in processing.', category: '5xx' }, + { code: 510, name: 'Not Extended', summary: 'Further extensions required for request.', category: '5xx' }, + { + code: 511, + name: 'Network Authentication Required', + summary: 'Client must authenticate to gain network access.', + category: '5xx', + }, +]; + +export type HttpStatusCategoryFilter = HttpStatusCategory | 'all'; + +/** + * Typical pitfalls, misunderstandings, and debugging angles for modal detail (not normative specs). + */ +const COMMON_PROBLEMS_AND_CAUSES: Record = { + 100: + 'Often tied to Expect: 100-continue uploads. Typical issues: proxies or gateways swallowing intermediates, clients that never resume after Continue, duplicated Expect headers, or servers that mishandle chunked bodies.', + 101: + 'Common with WebSockets or negotiated upgrades. Typical causes: missing or mismatched Upgrade/Connection headers, TLS termination changing the handshake path, proxies blocking Upgrade, ALPN negotiated HTTP/2 when the client expects HTTP/1 upgrade, or websocket origin checks failing.', + 102: + 'WebDAV/long requests. Typical issues: callers timing out assuming hung requests, proxies closing idle intermediates before the real response completes, poor progress reporting in clients.', + 103: + 'Early Hints and preload pushes. Typical problems: duplicate resource loading when hints and HTML both declare scripts, CSP blocking hinted URLs, clients or CDNs that ignore 103 entirely, mismatched preload priorities.', + 200: + 'Usually success. Debugging pain when clients expect 201 after POST, when APIs return bodies that violate schemas, stale cache headers masking fresh data, or success returned for partial/failed workflows.', + 201: + 'Created resources. Typical issues: missing Location (clients cannot discover URIs), double-submit creating duplicates without idempotency keys, transactional rollbacks returning 201 without cleanup, mismatched slug vs canonical URL.', + 202: + 'Async workflows. Typical problems: polling storms, nowhere to correlate job IDs, abandoned jobs appearing “accepted” forever, clients treating 202 like 200 without checking status URLs.', + 203: + 'Transformed payload from a proxy. Typical confusion: validators and ETags from the intermediary not matching the origin; caches treating merged content incorrectly; CDN minification stripping fields clients rely on.', + 204: + 'No body successes. Typical client bugs: parsers requiring JSON on success, XMLHttpRequest/onload handlers crashing on empty body, Axios interceptors misclassifying legitimate empty deletes.', + 205: + 'Rare in APIs. Typical issues: misunderstanding as “reload page” when only document view should reset; clients refetch losing SPA state unnecessarily.', + 206: + 'Partial content downloads. Typical problems: mismatched Range units, requesting ranges past Content-Length, media players looping on overlapping ranges, cache corruption combining partial shards.', + 207: + 'WebDAV multi-status. Typical pain: callers reading only outer HTTP status and ignoring per-member failures in XML/JSON bodies, inconsistent rollback after partial DAV success.', + 208: + 'WebDAV “already reported” to shrink responses. Debugging difficulty when clients omit duplicate bindings and become out of sync with server propstat aggregates.', + 226: + 'Instance manipulations deltas. Typical issues: patching wrong base representation, mishandling IM headers so deltas apply to stale content, intermediary stripping IM metadata.', + 300: + 'Multiple representations. Typical issues: ambiguous default without Link rel=alternate, bots choosing wrong MIME type, caches storing the wrong negotiated variant.', + 301: + 'Permanent redirects. Typical problems: typo’d target URI baked into SEO forever, clients not updating bookmarks despite 301, mixed HTTP→HTTPS redirects creating redirect loops.', + 302: + 'Temporary redirect historically abused. Typical bugs: caches treating temporary like permanent, POST turning into unintended GET on legacy stacks, intermediaries rewriting Location.', + 303: + 'See Other GET follows. Typical issues: AJAX losing POST semantics after POST+303, forgetting to rebuild query parameters on subsequent GET.', + 304: + 'Not modified caching. Debugging traps: validators never changing so clients stay stale forever, clock skew breaking If-Modified-Since, CDN ignoring Vary breaking negotiation.', + 305: + 'Deprecated directive. Encountering this usually signals legacy infra or captive misconfiguration behind old proxies—not something to emulate in modern apps.', + 307: + 'Temporary redirect with method preservation. Typical problems: middleware auto-following redirects and duplicating POST bodies, OAuth redirects breaking when method flips unintentionally.', + 308: + 'Permanent redirect with method preservation. Similar to 307 but caches update longer—wrong destination poisons crawling and API clients similarly to broken 301 chains.', + 400: + 'Bad request framing or validation. Usual suspects: malformed JSON/XML, mismatched Content-Type charset, duplicated fields, proxies corrupting chunked encoding, max header count limits exceeded in reverse proxies.', + 401: + 'Authentication required vs authorization. Typical mix-ups with 403 when tokens are stale or scopes wrong—missing/expired Bearer vs truly blocked users. Omitting WWW-Authenticate confuses frameworks and proxies.', + 402: + 'Reserved and rarely standardized. Seeing it usually means bespoke billing flows or placeholder APIs—integrations break when expectations drift without documentation.', + 403: + 'Forbidden after identity may be known. Typical causes: IP/geo allowlists, missing RBAC role, filesystem permissions in static hosts, accidentally blocking bots that your SSR depends on.', + 404: + 'No route or missing resource—also returned intentionally to obscure existence of private resources. Usual suspects: typo paths, SPA servers not configured with fallback routing, ingress path prefixes stripped wrong, stale deployment without the new routes.', + 405: + 'Method unsupported. Frequently misconfigured OPTIONS for CORS, disabled PATCH/DELETE in reverse proxies, or APIs returning 405 without Allow header so tooling cannot heal itself.', + 406: + 'Content negotiation unhappy. Typical issues: unrealistic Accept:*/* assumptions, serializers missing for requested MIME, GraphQL gateways expecting JSON.', + 407: + 'Proxy authentication. Common in captive corporate networks—the app works direct but breaks behind PAC with missing Proxy-Authorization on clients not configured.', + 408: + 'Server gave up waiting. Often slow mobile uploads or half-open HTTP/2 streams; alternatively aggressive load balancer timeouts while app still computes.', + 409: + 'State conflict—optimistic concurrency failures, uniqueness violations, simultaneous edits hitting the same aggregate, workflow states that disallow the transition.', + 410: + 'Intentionally removed. Helps audit trails vs 404. Problems when CDNs incorrectly cache “gone”, or caches never forget when content returns under a reuse policy.', + 411: + 'Length required—some origins reject chunked bodies without explicit sizing. Debugging reverse proxies rewriting Transfer-Encoding inconsistently.', + 412: + 'Preconditions failed—If-Match/If-Unmodified-Since losing races during frequent updates, retries duplicating conflicting writes if not idempotent.', + 413: + 'Payload too large. Often nginx client_max_body_size, API gateway quotas, multipart parser limits far below user expectation for media uploads.', + 414: + 'URI oversize—GET query strings exploding from filters, misplaced auth tokens in URLs, SSRF gateways limiting path lengths.', + 415: + 'Unsupported Media Type—sending multipart when API expects JSON, missing charset, multipart boundary mismatch, PATCH with wrong subtype.', + 416: + 'Range nonsense—byte ranges exceeding file sizes, mismatched Accept-Ranges semantics, CDN edge missing full object.', + 417: + 'Expect header mishandling—often intermediaries rewriting Expect: 100-continue or forbidding chunked trailers.', + 418: + 'Not meaningful for diagnostics—almost always novelty. If unexpected, hunt for buggy middleware injecting RFC 2324 jokes rather than infra failure.', + 421: + 'Misdirected request—HTTP/2 routing issues: TLS SNI not matching `:authority`, multiplexed connections routed to wrong vhost behind shared IPs.', + 422: + 'Semantic validation failures with syntactically valid JSON—schema mismatch, regex field errors, inconsistent cross-field constraints. Frontend/backend contract drift.', + 423: + 'WebDAV locking—exclusive locks left behind after crashed editors, timeouts not releasing leases, collaborative tooling deadlocks.', + 424: + 'Failed dependency chaining—compound WebDAV failures where diagnosing root cause buried under dependent operation errors.', + 425: + 'Too Early—replay-related rejection of 0‑RTT or early handshake data until TLS/session replays ruled out.', + 426: + 'Upgrade required—old cleartext or HTTP versions blocked; clients must retry with TLS/WebSocket handshake after inspecting Upgrade guidance.', + 428: + 'Precondition Required—lost race protection forcing If-Match/If-Unmodified headers; concurrency bugs if clients ignore mandated preconditions.', + 429: + 'Rate limiting. Common failure modes: retry storms multiplying load, scraping without exponential backoff, shared egress IP tripping quotas for many users.', + 431: + 'Header fields oversized—cookies bloated with SSO claims, gigantic Authorization tokens, proxies aggregating duplicated headers hitting limits.', + 451: + 'Legal/policy blocks geographies or jurisdictions—tests pass locally then fail overseas; misunderstanding compliance vs censorship vs DNS mistakes.', + 500: + 'Unhandled exceptions, corrupted configuration, datastore outages, deadlock threads, mismatched deployments (schema vs code). Always inspect server-side stack traces/logs—never masked by generic mobile error UI.', + 501: + 'Feature/method deliberately absent—calling DELETE on read-only gateways, OPTIONS blocked, HTTP/3 disabled while client upgrades.', + 502: + 'Bad gateway upstream. Classic causes: pod crash loops, RDS connection exhaustion, stale DNS to dead nodes, TLS handshake mismatches between proxy and upstream, chunked encoding breakage through HAProxy/nginx.', + 503: + 'Service unavailable/overloaded/draining instances. Troubleshoot readiness probes flipping, autoscaling delays, noisy neighbors saturating CPUs, deliberate maintenance windows forgetting Retry-After.', + 504: + 'Gateway timeouts—cold starts, synchronous chains waiting on queues, oversized SQL, missing timeouts causing threads to stall until proxy kills them.', + 505: + 'HTTP version refusal—clients forcing incorrect protocol after ALPN downgrade, plaintext HTTP spoken to HTTPS-only backends.', + 506: + 'Variant negotiation loop—opaque server configuration errors in negotiation modules; exceedingly rare.', + 507: + 'Insufficient storage—NAS volumes full on WebDAV, mailboxes quotas, multipart temp disk exhaustion.', + 508: + 'Loop detected deep in DAV trees—collections referencing themselves recursively; miswired reverse proxies chaining infinite internal forwards.', + 510: + 'Feature extension missing—not implemented policy—clients must supply required protocol extensions or drop optional features.', + 511: + 'Network authentication or captive portals—Wi-Fi hotspots intercepting HTTPS with login pages mirrored as 511 in some setups; captive portal probes confused with API failures.', +}; + +/** Returns true when every cheatsheet row has troubleshooting copy (guard for drift). */ +export function httpStatusProblemsCoversCheatsheet(): boolean { + return HTTP_STATUS_ENTRIES.every((e) => + Object.prototype.hasOwnProperty.call(COMMON_PROBLEMS_AND_CAUSES, e.code) + ); +} + +/** Returns troubleshooting copy for modal (typical pitfalls and debugging cues). */ +export function getHttpStatusTroubleshooting(entry: HttpStatusEntry): string { + const problems = COMMON_PROBLEMS_AND_CAUSES[entry.code]; + if (problems === undefined) { + return `No extended troubleshooting blurb configured for HTTP ${entry.code}.`; + } + return problems; +} + +/** + * Concatenates cheatsheet summary and troubleshooting prose (same order as modal sections). + */ +export function getHttpStatusDetailedDescription(entry: HttpStatusEntry): string { + return `${entry.summary}\n\n${getHttpStatusTroubleshooting(entry)}`; +} + +/** Canonical-looking status line (teaching/example only; HTTP version differs on the wire). */ +export function getHttpStatusLineExample(entry: HttpStatusEntry): string { + return `HTTP/1.1 ${entry.code} ${entry.name}`; +} + +/** + * Filter status entries by optional category and free-text query (code, name, summary). + */ +export function filterHttpStatuses( + query: string, + category: HttpStatusCategoryFilter +): HttpStatusEntry[] { + const q = query.trim().toLowerCase(); + return HTTP_STATUS_ENTRIES.filter((e) => { + if (category !== 'all' && e.category !== category) return false; + if (!q) return true; + if (e.code.toString().includes(q)) return true; + return e.name.toLowerCase().includes(q) || e.summary.toLowerCase().includes(q); + }); +} diff --git a/src/libs/tool-components.ts b/src/libs/tool-components.ts index 4bcc4d5..b47e562 100644 --- a/src/libs/tool-components.ts +++ b/src/libs/tool-components.ts @@ -17,6 +17,7 @@ export const getToolComponent = async (componentName: string) => { 'IpChecker': () => import('@/components/tools/IpChecker').then(m => m.IpChecker), 'DiffChecker': () => import('@/components/tools/DiffChecker').then(m => m.DiffChecker), 'HtmlToMarkdown': () => import('@/components/tools/HtmlToMarkdown').then(m => m.HtmlToMarkdown), + 'HttpStatusCheatsheet': () => import('@/components/tools/HttpStatusCheatsheet').then(m => m.HttpStatusCheatsheet), 'TextMinifier': () => import('@/components/tools/TextMinifier').then(m => m.TextMinifier), 'TimestampConverter': () => import('@/components/tools/TimestampConverter').then(m => m.TimestampConverter), 'ListComparison': () => import('@/components/tools/ListComparison').then(m => m.ListComparison), diff --git a/src/libs/tools-data.ts b/src/libs/tools-data.ts index db3cd64..cf4563a 100644 --- a/src/libs/tools-data.ts +++ b/src/libs/tools-data.ts @@ -10,6 +10,7 @@ import { Key, Link, List, + ListOrdered, Lock, MapPin, Minimize2, @@ -64,6 +65,7 @@ export const toolIcons: Record = { 'number-base-converter': Binary, 'base-encoder': Binary, 'hash-generator': Hash, + 'http-status-cheatsheet': ListOrdered, }; export const toolCategories: ToolCategory[] = [ @@ -446,6 +448,19 @@ export const toolCategories: ToolCategory[] = [ supportsDesktop: true, supportsMobile: true, }, + { + id: 'http-status-cheatsheet', + name: 'HTTP Status Codes', + description: + 'Searchable reference for common HTTP response status codes, classes, names, and short explanations', + category: 'network', + icon: '📛', + isPopular: false, + path: '/tools/network/http-status-cheatsheet', + component: 'HttpStatusCheatsheet', + supportsDesktop: true, + supportsMobile: true, + }, { id: 'system-info', name: 'System Information', From 7501c57ed34b68a63a7950b6351ecce038809e62 Mon Sep 17 00:00:00 2001 From: DrakeNguyen Date: Fri, 1 May 2026 13:10:02 -0500 Subject: [PATCH 7/8] feat: add Headers/Cookies Explainer tool for HTTP header and cookie analysis Introduce a new Headers/Cookies Explainer tool that allows users to paste HTTP headers or cookie strings and receive a structured breakdown with glossary explanations. The tool features a user-friendly interface with tabs for different types of input and integrates state management for preserving user input. Additionally, implement unit tests to ensure the accuracy and functionality of the parsing logic. Co-authored-by: Cursor --- next-env.d.ts | 2 +- .../tools/HeadersCookiesExplainer.tsx | 587 ++++++++++++++++++ .../headers-cookies-explainer-config.ts | 341 ++++++++++ .../headers-cookies-explainer.test.ts | 144 +++++ src/libs/headers-cookies-explainer.ts | 294 +++++++++ src/libs/tool-components.ts | 1 + src/libs/tools-data.ts | 15 + 7 files changed, 1383 insertions(+), 1 deletion(-) create mode 100644 src/components/tools/HeadersCookiesExplainer.tsx create mode 100644 src/config/headers-cookies-explainer-config.ts create mode 100644 src/libs/__tests__/headers-cookies-explainer.test.ts create mode 100644 src/libs/headers-cookies-explainer.ts diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/components/tools/HeadersCookiesExplainer.tsx b/src/components/tools/HeadersCookiesExplainer.tsx new file mode 100644 index 0000000..1ca7777 --- /dev/null +++ b/src/components/tools/HeadersCookiesExplainer.tsx @@ -0,0 +1,587 @@ +'use client'; + +import { useToolState } from '@/components/providers/ToolStateProvider'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { + HEADERS_COOKIE_EXAMPLES, + type HeaderCookieGlossaryCategory, + type GlossaryEntry, +} from '@/config/headers-cookies-explainer-config'; +import { + parseHttpHeaderBlock, + parseRequestCookiePairs, + parseSetCookiePaste, + type ParsedHeaderRow, + type ParsedSetCookieCookie, +} from '@/libs/headers-cookies-explainer'; +import { cn } from '@/libs/utils'; +import { ChevronDownIcon, DocumentDuplicateIcon } from '@heroicons/react/24/outline'; +import { useEffect, useMemo, useState } from 'react'; + +const TOOL_ID = 'headers-cookies-explainer' as const; + +type MainTab = 'headers' | 'cookie-request' | 'set-cookie'; + +interface HeadersCookiesExplainerProps { + className?: string; + instanceId: string; +} + +function categoryBadgeClass(cat: HeaderCookieGlossaryCategory | undefined): string { + switch (cat) { + case 'security': + return 'border-red-200 bg-red-100 text-red-900 dark:border-red-900/50 dark:bg-red-950/40 dark:text-red-100'; + case 'caching': + return 'border-amber-200 bg-amber-100 text-amber-950 dark:border-amber-900/40 dark:bg-amber-950/35 dark:text-amber-50'; + case 'cors': + return 'border-violet-200 bg-violet-100 text-violet-950 dark:border-violet-900/40 dark:bg-violet-950/40 dark:text-violet-100'; + case 'auth': + return 'border-blue-200 bg-blue-100 text-blue-950 dark:border-blue-900/40 dark:bg-blue-950/40 dark:text-blue-50'; + case 'privacy': + return 'border-teal-200 bg-teal-100 text-teal-950 dark:border-teal-900/40 dark:bg-teal-950/35 dark:text-teal-50'; + case 'content': + return 'border-sky-200 bg-sky-100 text-sky-950 dark:border-sky-900/40 dark:bg-sky-950/35 dark:text-sky-50'; + default: + return 'border-neutral-200 bg-neutral-100 text-neutral-800 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100'; + } +} + +function copyText(text: string) { + if (!navigator.clipboard?.writeText) return; + navigator.clipboard.writeText(text).catch(console.error); +} + +export function HeadersCookiesExplainer({ className, instanceId }: HeadersCookiesExplainerProps) { + const { toolState, updateToolState } = useToolState(TOOL_ID, instanceId); + + const [mainTab, setMainTab] = useState('headers'); + const [headersText, setHeadersText] = useState(''); + const [cookieRequestText, setCookieRequestText] = useState(''); + const [setCookieText, setSetCookieText] = useState(''); + const [isHydrated, setIsHydrated] = useState(false); + + const [detailEntry, setDetailEntry] = useState<{ title: string; entry: GlossaryEntry } | null>( + null + ); + + useEffect(() => { + setIsHydrated(true); + if (toolState) { + if (toolState.mainTab) setMainTab(toolState.mainTab as MainTab); + if (toolState.headersText !== undefined && toolState.headersText !== null) { + setHeadersText(toolState.headersText as string); + } + if (toolState.cookieRequestText !== undefined && toolState.cookieRequestText !== null) { + setCookieRequestText(toolState.cookieRequestText as string); + } + if (toolState.setCookieText !== undefined && toolState.setCookieText !== null) { + setSetCookieText(toolState.setCookieText as string); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (isHydrated) { + updateToolState({ + mainTab, + headersText, + cookieRequestText, + setCookieText, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mainTab, headersText, cookieRequestText, setCookieText, isHydrated]); + + useEffect(() => { + if (isHydrated && (!toolState || Object.keys(toolState).length === 0)) { + setHeadersText(''); + setCookieRequestText(''); + setSetCookieText(''); + setMainTab('headers'); + } + }, [toolState, isHydrated]); + + const parsedHeaders = useMemo(() => parseHttpHeaderBlock(headersText), [headersText]); + const parsedRequestCookies = useMemo( + () => parseRequestCookiePairs(cookieRequestText), + [cookieRequestText] + ); + const parsedSetCookies = useMemo(() => parseSetCookiePaste(setCookieText), [setCookieText]); + + const allWarnings = + mainTab === 'headers' + ? parsedHeaders.warnings + : mainTab === 'cookie-request' + ? parsedRequestCookies.warnings + : parsedSetCookies.warnings; + + const openGlossary = (title: string, entry?: GlossaryEntry) => { + if (!entry) return; + setDetailEntry({ title, entry }); + }; + + return ( +
+
+

+ Headers / Cookies explainer +

+

+ Paste HTTP headers or cookie strings copied from DevTools or server logs. The tool parses rows + client-side only and attaches short glossary notes—not a substitute for inspecting real traffic policies. +

+
+ +
+ setMainTab(v as MainTab)} + className="flex flex-col gap-4 min-h-0 flex-1" + > + + HTTP headers + Cookie (request) + Set-Cookie + + + +
+
+ +