diff --git a/README.md b/README.md index f1de131..d932439 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![Test Coverage](https://img.shields.io/codecov/c/github/hypkey/devpockit) ![Version](https://img.shields.io/github/package-json/v/hypkey/devpockit) -A modern web application providing essential developer tools with a clean, responsive interface. Built with Next.js 15, featuring 30+ powerful tools that run entirely client-side for optimal performance and privacy. +A modern web application providing essential developer tools with a clean, responsive interface. Built with Next.js (App Router, static export), featuring 30+ powerful tools that run entirely client-side for optimal performance and privacy. 🌐 **[Production](https://devpockit.hypkey.com/)** | 📖 **[Documentation](#-documentation)** | đŸ€ **[Contributing](CONTRIBUTING.md)** | 📝 **[Changelog](CHANGELOG.md)** @@ -14,7 +14,7 @@ A modern web application providing essential developer tools with a clean, respo - **30+ Developer Tools** - JSON formatter, UUID generator, JWT decoder, regex tester, and more - **Client-Side Processing** - All tools run in your browser, no data sent to servers - **Modern UI** - Clean, responsive design with dark/light theme support -- **Fast & Reliable** - Built with Next.js 15 and TypeScript +- **Fast & Reliable** - Built with Next.js and TypeScript - **Mobile Friendly** - Works seamlessly on desktop, tablet, and mobile devices - **Open Source** - MIT licensed, free to use and contribute 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": { diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..6f75f7b --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,17 @@ +{ + "name": "DevPockit", + "short_name": "DevPockit", + "description": "Free developer tools that run in your browser.", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ea580c", + "icons": [ + { + "src": "/assets/devpockit-logo.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c07cf6e..8dc60b9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,7 @@ import { AppLayout } from '@/components/layout/AppLayout' +import { AppToastProvider } from '@/components/providers/AppToastProvider' import { ThemeProvider } from '@/components/providers/ThemeProvider' +import { absoluteAssetUrl, absoluteSiteUrl } from '@/libs/site-url' import type { Metadata, Viewport } from 'next' import { DM_Serif_Text, Geist, Geist_Mono } from 'next/font/google' import './globals.css' @@ -23,8 +25,12 @@ const dmSerifText = DM_Serif_Text({ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://devpockit.hypkey.com'; +const canonicalHome = absoluteSiteUrl('/') +const ogImageUrl = absoluteAssetUrl('/og-image.png') + export const metadata: Metadata = { metadataBase: new URL(baseUrl), + manifest: absoluteAssetUrl('/manifest.webmanifest'), title: { default: 'DevPockit - Free Online Developer Tools', template: '%s | DevPockit', @@ -78,14 +84,14 @@ export const metadata: Metadata = { openGraph: { type: 'website', locale: 'en_US', - url: `${baseUrl}/`, + url: canonicalHome, siteName: 'DevPockit', title: 'DevPockit - Free Online Developer Tools', description: 'Free online developer tools that run locally in your browser. JSON formatter, UUID generator, JWT decoder, and 25+ more tools. Fast, private, no sign-up.', images: [ { - url: '/og-image.png', + url: ogImageUrl, width: 1200, height: 630, alt: 'DevPockit - Developer Tools', @@ -97,10 +103,10 @@ export const metadata: Metadata = { title: 'DevPockit - Free Online Developer Tools', description: 'Free developer tools in your browser. JSON formatter, UUID generator, JWT decoder & more. Private, fast, no sign-up.', - images: ['/og-image.png'], + images: [ogImageUrl], }, alternates: { - canonical: `${baseUrl}/`, + canonical: canonicalHome, }, category: 'technology', } @@ -108,6 +114,10 @@ export const metadata: Metadata = { export const viewport: Viewport = { width: 'device-width', initialScale: 1, + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#ffffff' }, + { media: '(prefers-color-scheme: dark)', color: '#0a0a0a' }, + ], } export default function RootLayout({ @@ -124,7 +134,9 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - {children} + + {children} + diff --git a/src/app/tools/[category]/[toolId]/[instanceId]/page.tsx b/src/app/tools/[category]/[toolId]/[instanceId]/page.tsx index d9c6914..19195c4 100644 --- a/src/app/tools/[category]/[toolId]/[instanceId]/page.tsx +++ b/src/app/tools/[category]/[toolId]/[instanceId]/page.tsx @@ -1,4 +1,6 @@ import { getToolById, getTools } from '@/libs/tools-data'; +import { absoluteAssetUrl, absoluteSiteUrl } from '@/libs/site-url'; +import { Metadata } from 'next'; import { notFound } from 'next/navigation'; interface ToolPageProps { @@ -34,6 +36,39 @@ export async function generateStaticParams() { return params; } +export async function generateMetadata({ params }: ToolPageProps): Promise { + const { category, toolId, instanceId } = await params; + const tool = getToolById(toolId); + + if (!tool || tool.category !== category) { + return { title: 'Tool' }; + } + + const title = `${tool.name} - Free Online Tool`; + const description = `${tool.description} Free, fast, and runs locally in your browser. No sign-up required.`; + const canonicalPath = `/tools/${category}/${toolId}/${instanceId}/`; + const canonical = absoluteSiteUrl(canonicalPath); + const ogImage = absoluteAssetUrl('/og-image.png'); + + return { + title, + description, + openGraph: { + title: `${tool.name} | DevPockit`, + description, + url: canonical, + type: 'website', + images: [{ url: ogImage, width: 1200, height: 630, alt: tool.name }], + }, + twitter: { + card: 'summary_large_image', + title: `${tool.name} | DevPockit`, + description, + }, + alternates: { canonical }, + }; +} + export default async function ToolPage({ params }: ToolPageProps) { try { const { category, toolId, instanceId } = await params; diff --git a/src/app/tools/[category]/[toolId]/page.tsx b/src/app/tools/[category]/[toolId]/page.tsx index e876484..8c9f4d7 100644 --- a/src/app/tools/[category]/[toolId]/page.tsx +++ b/src/app/tools/[category]/[toolId]/page.tsx @@ -1,4 +1,5 @@ import { getCategoryById, getToolById, getTools } from '@/libs/tools-data'; +import { absoluteAssetUrl, absoluteSiteUrl } from '@/libs/site-url'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; @@ -51,9 +52,9 @@ export async function generateMetadata({ params }: ToolPageProps): Promise { + const { category: categoryId } = await params; + const category = getCategoryById(categoryId); + if (!category) { + return { title: 'Tools' }; + } + + const title = `${category.name} — Developer Tools`; + const description = `${category.description}. Free browser-based tools on DevPockit.`; + const canonical = absoluteSiteUrl(`/tools/${category.id}/`); + + return { + title, + description, + openGraph: { + title: `${category.name} | DevPockit`, + description, + url: canonical, + type: 'website', + }, + alternates: { canonical }, + }; +} + export default async function CategoryPage({ params }: CategoryPageProps) { try { const { category: categoryId } = await params; 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/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 */} +
-
-