diff --git a/web/src/app/icons/[icon]/page.tsx b/web/src/app/icons/[icon]/page.tsx index 1530f52d..3d83d4fb 100644 --- a/web/src/app/icons/[icon]/page.tsx +++ b/web/src/app/icons/[icon]/page.tsx @@ -99,5 +99,5 @@ export default async function IconPage({ params }: { params: Promise<{ icon: str const authorData = await getAuthorData(originalIconData.update.author.id) - return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} /> + return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} allIcons={iconsData} /> } diff --git a/web/src/app/icons/page.tsx b/web/src/app/icons/page.tsx index a218be25..ae8f4b5a 100644 --- a/web/src/app/icons/page.tsx +++ b/web/src/app/icons/page.tsx @@ -1,7 +1,7 @@ +import { IconSearch } from "@/components/icon-search" import { BASE_URL } from "@/constants" import { getIconsArray } from "@/lib/api" import type { Metadata } from "next" -import { IconSearch } from "./components/icon-search" export async function generateMetadata(): Promise<Metadata> { const icons = await getIconsArray() diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 34bd443b..b792f1d1 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -7,7 +7,7 @@ import type { Metadata, Viewport } from "next" import { Inter } from "next/font/google" import { Toaster } from "sonner" import "./globals.css" -import { BASE_URL, getDescription, WEB_URL, websiteTitle } from "@/constants" +import { BASE_URL, WEB_URL, getDescription, websiteTitle } from "@/constants" import { ThemeProvider } from "./theme-provider" const inter = Inter({ diff --git a/web/src/components/icon-card.tsx b/web/src/components/icon-card.tsx new file mode 100644 index 00000000..cc3ea24d --- /dev/null +++ b/web/src/components/icon-card.tsx @@ -0,0 +1,35 @@ +import { MagicCard } from "@/components/magicui/magic-card" +import { BASE_URL } from "@/constants" +import type { Icon } from "@/types/icons" +import Image from "next/image" +import Link from "next/link" + +export function IconCard({ + name, + data: iconData, + matchedAlias, +}: { + name: string + data: Icon + matchedAlias?: string | null +}) { + return ( + <MagicCard className="rounded-md shadow-md"> + <Link prefetch={false} href={`/icons/${name}`} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer"> + <div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2"> + <Image + src={`${BASE_URL}/${iconData.base}/${name}.${iconData.base}`} + alt={`${name} icon`} + fill + className="object-contain p-1 group-hover:scale-110 transition-transform duration-300" + /> + </div> + <span className="text-xs sm:text-sm text-center truncate w-full capitalize group- dark:group-hover:text-primary transition-colors duration-200 font-medium"> + {name.replace(/-/g, " ")} + </span> + + {matchedAlias && <span className="text-[10px] text-center truncate w-full mt-1">Alias: {matchedAlias}</span>} + </Link> + </MagicCard> + ) +} diff --git a/web/src/components/icon-details.tsx b/web/src/components/icon-details.tsx index 5b760e2d..2bdd5430 100644 --- a/web/src/components/icon-details.tsx +++ b/web/src/components/icon-details.tsx @@ -1,15 +1,15 @@ "use client" +import { IconsGrid } from "@/components/icon-grid" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { BASE_URL, REPO_PATH } from "@/constants" -import type { AuthorData, Icon } from "@/types/icons" +import type { AuthorData, Icon, IconFile } from "@/types/icons" import confetti from "canvas-confetti" import { motion } from "framer-motion" import { Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun } from "lucide-react" -import { useTheme } from "next-themes" import Image from "next/image" import Link from "next/link" import { useCallback, useState } from "react" @@ -22,9 +22,10 @@ export type IconDetailsProps = { icon: string iconData: Icon authorData: AuthorData + allIcons: IconFile } -export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { +export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetailsProps) { const authorName = authorData.name || authorData.login || "" const iconColorVariants = iconData.colors const formattedDate = new Date(iconData.update.timestamp).toLocaleDateString("en-GB", { @@ -159,6 +160,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} onClick={(e) => handleCopy(imageUrl, variantKey, e)} + aria-label={`Copy ${format.toUpperCase()} URL for ${iconName}${theme ? ` (${theme} theme)` : ""}`} > <div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors" /> @@ -264,23 +266,25 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { } return ( - <div className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8"> + <main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-6"> {/* Left Column: Icon Info and Author */} <div className="lg:col-span-1"> <Card className="h-full bg-background/50 border shadow-lg"> <CardHeader className="pb-4"> <div className="flex flex-col items-center"> - <div className="relative w-32 h-32 rounded-xl overflow-hidden border flex items-center justify-center p-3 "> + <div className="relative w-32 h-32 rounded-xl overflow-hidden border flex items-center justify-center p-3"> <Image src={`${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`} width={96} height={96} - alt={icon} + alt={`High quality ${icon.replace(/-/g, " ")} icon in ${iconData.base.toUpperCase()} format`} className="w-full h-full object-contain" /> </div> - <CardTitle className="text-2xl font-bold capitalize text-center mb-2">{icon}</CardTitle> + <CardTitle className="text-2xl font-bold capitalize text-center mb-2"> + <h1>{icon.replace(/-/g, " ")}</h1> + </CardTitle> </div> </CardHeader> <CardContent> @@ -289,14 +293,14 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { <div className="space-y-2"> <div className="flex items-center gap-2"> <p className="text-sm"> - <span className="font-medium">Updated on:</span> {formattedDate} + <span className="font-medium">Updated on:</span> <time dateTime={iconData.update.timestamp}>{formattedDate}</time> </p> </div> <div className="flex items-center gap-2"> <div className="flex items-center gap-2"> <p className="text-sm font-medium">By:</p> <Avatar className="h-5 w-5 border"> - <AvatarImage src={authorData.avatar_url} alt={authorName} /> + <AvatarImage src={authorData.avatar_url} alt={`${authorName}'s avatar`} /> <AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback> </Avatar> {authorData.html_url ? ( @@ -379,7 +383,9 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { <div className="lg:col-span-2"> <Card className="h-full bg-background/50 shadow-lg"> <CardHeader> - <CardTitle>Icon variants</CardTitle> + <CardTitle> + <h2>Icon variants</h2> + </CardTitle> <CardDescription>Click on any icon to copy its URL to your clipboard</CardDescription> </CardHeader> <CardContent> @@ -470,6 +476,31 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { </Card> </div> </div> - </div> + {iconData.categories && iconData.categories.length > 0 && ( + <section className="container mx-auto mt-12" aria-labelledby="related-icons-title"> + <Card className="bg-background/50 border shadow-lg"> + <CardHeader> + <CardTitle> + <h2 id="related-icons-title">Related Icons</h2> + </CardTitle> + <CardDescription> + Other icons from {iconData.categories.map((cat) => cat.replace(/-/g, " ")).join(", ")} categories + </CardDescription> + </CardHeader> + <CardContent> + <IconsGrid + filteredIcons={Object.entries(allIcons) + .filter(([name, data]) => { + if (name === icon) return false + return data.categories?.some((cat) => iconData.categories?.includes(cat)) + }) + .map(([name, data]) => ({ name, data }))} + matchedAliases={{}} + /> + </CardContent> + </Card> + </section> + )} + </main> ) } diff --git a/web/src/components/icon-grid.tsx b/web/src/components/icon-grid.tsx new file mode 100644 index 00000000..241b069f --- /dev/null +++ b/web/src/components/icon-grid.tsx @@ -0,0 +1,21 @@ +import type { Icon } from "@/types/icons" + +import { IconCard } from "./icon-card" + +interface IconsGridProps { + filteredIcons: { name: string; data: Icon }[] + matchedAliases: Record<string, string> +} + +export function IconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) { + return ( + <> + <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-2"> + {filteredIcons.slice(0, 120).map(({ name, data }) => ( + <IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name] || null} /> + ))} + </div> + {filteredIcons.length > 120 && <p className="text-sm text-muted-foreground">And {filteredIcons.length - 120} more...</p>} + </> + ) +} diff --git a/web/src/app/icons/components/icon-search.tsx b/web/src/components/icon-search.tsx similarity index 76% rename from web/src/app/icons/components/icon-search.tsx rename to web/src/components/icon-search.tsx index 321a56fd..375716ba 100644 --- a/web/src/app/icons/components/icon-search.tsx +++ b/web/src/components/icon-search.tsx @@ -1,7 +1,7 @@ "use client" +import { IconsGrid } from "@/components/icon-grid" import { IconSubmissionContent } from "@/components/icon-submission-form" -import { MagicCard } from "@/components/magicui/magic-card" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { @@ -17,12 +17,9 @@ import { } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" -import { BASE_URL } from "@/constants" -import type { Icon, IconSearchProps } from "@/types/icons" +import type { IconSearchProps } from "@/types/icons" import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react" import { useTheme } from "next-themes" -import Image from "next/image" -import Link from "next/link" import { usePathname, useRouter, useSearchParams } from "next/navigation" import posthog from "posthog-js" import { useCallback, useEffect, useMemo, useRef, useState } from "react" @@ -228,11 +225,11 @@ export function IconSearch({ icons }: IconSearchProps) { const getSortLabel = (sort: SortOption) => { switch (sort) { case "relevance": - return "Relevance" + return "Best match" case "alphabetical-asc": - return "Name (A-Z)" + return "A to Z" case "alphabetical-desc": - return "Name (Z-A)" + return "Z to A" case "newest": return "Newest first" default: @@ -265,7 +262,7 @@ export function IconSearch({ icons }: IconSearchProps) { </div> <Input type="search" - placeholder="Search for icons..." + placeholder="Search icons by name, alias, or category..." className="w-full h-10 pl-9 cursor-text transition-all duration-300 text-sm md:text-base border-border shadow-sm" value={searchQuery} onChange={(e) => handleSearch(e.target.value)} @@ -277,18 +274,18 @@ export function IconSearch({ icons }: IconSearchProps) { {/* Filter dropdown */} <DropdownMenu> <DropdownMenuTrigger asChild> - <Button - variant="outline" - size="sm" - className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm" - aria-label="Filter icons" - > + <Button variant="outline" size="sm" className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm "> <Filter className="h-4 w-4 mr-2" /> - <span>{selectedCategories.length > 0 ? `Filters (${selectedCategories.length})` : "Filter"}</span> + <span>Filter</span> + {selectedCategories.length > 0 && ( + <Badge variant="secondary" className="ml-2 px-1.5"> + {selectedCategories.length} + </Badge> + )} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start" className="w-64 sm:w-56"> - <DropdownMenuLabel className="font-semibold">Select Categories</DropdownMenuLabel> + <DropdownMenuLabel className="font-semibold">Categories</DropdownMenuLabel> <DropdownMenuSeparator /> <div className="max-h-[40vh] overflow-y-auto p-1"> @@ -314,7 +311,7 @@ export function IconSearch({ icons }: IconSearchProps) { }} className="cursor-pointer focus: focus:bg-rose-50 dark:focus:bg-rose-950/20" > - Clear categories + Clear all filters </DropdownMenuItem> </> )} @@ -330,18 +327,18 @@ export function IconSearch({ icons }: IconSearchProps) { </Button> </DropdownMenuTrigger> <DropdownMenuContent align="start" className="w-56"> - <DropdownMenuLabel className="font-semibold">Sort Icons</DropdownMenuLabel> + <DropdownMenuLabel className="font-semibold">Sort By</DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuRadioGroup value={sortOption} onValueChange={(value) => handleSortChange(value as SortOption)}> <DropdownMenuRadioItem value="relevance" className="cursor-pointer"> <Search className="h-4 w-4 mr-2" /> - Relevance + Best match </DropdownMenuRadioItem> <DropdownMenuRadioItem value="alphabetical-asc" className="cursor-pointer"> - <ArrowDownAZ className="h-4 w-4 mr-2" />Name (A-Z) + <ArrowDownAZ className="h-4 w-4 mr-2" />A to Z </DropdownMenuRadioItem> <DropdownMenuRadioItem value="alphabetical-desc" className="cursor-pointer"> - <ArrowUpZA className="h-4 w-4 mr-2" />Name (Z-A) + <ArrowUpZA className="h-4 w-4 mr-2" />Z to A </DropdownMenuRadioItem> <DropdownMenuRadioItem value="newest" className="cursor-pointer"> <Calendar className="h-4 w-4 mr-2" /> @@ -353,15 +350,9 @@ export function IconSearch({ icons }: IconSearchProps) { {/* Clear all button */} {(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && ( - <Button - variant="outline" - size="sm" - onClick={clearFilters} - className="flex-1 sm:flex-none cursor-pointer bg-background" - aria-label="Reset all filters" - > + <Button variant="outline" size="sm" onClick={clearFilters} className="flex-1 sm:flex-none cursor-pointer bg-background"> <X className="h-4 w-4 mr-2" /> - <span>Reset</span> + <span>Clear all</span> </Button> )} </div> @@ -369,7 +360,7 @@ export function IconSearch({ icons }: IconSearchProps) { {/* Active filter badges */} {selectedCategories.length > 0 && ( <div className="flex flex-wrap items-center gap-2 mt-2"> - <span className="text-sm text-muted-foreground">Selected:</span> + <span className="text-sm text-muted-foreground">Filters:</span> <div className="flex flex-wrap gap-2"> {selectedCategories.map((category) => ( <Badge key={category} variant="secondary" className="flex items-center gap-1 pl-2 pr-1"> @@ -395,7 +386,7 @@ export function IconSearch({ icons }: IconSearchProps) { }} className="text-xs h-7 px-2 cursor-pointer" > - Clear + Clear all </Button> </div> )} @@ -406,33 +397,27 @@ export function IconSearch({ icons }: IconSearchProps) { {filteredIcons.length === 0 ? ( <div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto items-center"> <div className="text-center"> - <h2 className="text-3xl sm:text-5xl font-semibold">Icon not found</h2> - <p className="text-lg text-muted-foreground mt-2">Help us expand our collection</p> - </div> - <div className="flex flex-col gap-4 items-center w-full"> - <IconSubmissionContent /> - <div className="mt-4 flex items-center gap-2 justify-center"> - <span className="text-sm text-muted-foreground">Can't submit it yourself?</span> - <Button - className="cursor-pointer" - variant="outline" - size="sm" - onClick={() => { - setIsLazyRequestSubmitted(true) - toast("Request received!", { - description: `We've noted your request for "${searchQuery || "this icon"}". Thanks for your suggestion.`, - }) - posthog.capture("lazy icon request", { - query: searchQuery, - categories: selectedCategories, - }) - }} - disabled={isLazyRequestSubmitted} - > - Request this icon - </Button> - </div> + <h2 className="text-3xl sm:text-5xl font-semibold">We don't have this one...yet!</h2> </div> + <Button + className="cursor-pointer motion-preset-pop" + variant="default" + size="lg" + onClick={() => { + setIsLazyRequestSubmitted(true) + toast("We hear you!", { + description: `Okay, okay... we'll consider adding "${searchQuery || "that icon"}" just for you. 😉`, + }) + posthog.capture("lazy icon request", { + query: searchQuery, + categories: selectedCategories, + }) + }} + disabled={isLazyRequestSubmitted} + > + I want this icon added but I'm too lazy to add it myself + </Button> + <IconSubmissionContent /> </div> ) : ( <> @@ -453,51 +438,3 @@ export function IconSearch({ icons }: IconSearchProps) { </> ) } - -function IconCard({ - name, - data: iconData, - matchedAlias, -}: { - name: string - data: Icon - matchedAlias?: string | null -}) { - return ( - <MagicCard className="rounded-md shadow-md"> - <Link prefetch={false} href={`/icons/${name}`} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer"> - <div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2"> - <Image - src={`${BASE_URL}/${iconData.base}/${name}.${iconData.base}`} - alt={`${name} icon`} - fill - className="object-contain p-1 group-hover:scale-110 transition-transform duration-300" - /> - </div> - <span className="text-xs sm:text-sm text-center truncate w-full capitalize group-hover:text-rose-500 dark:group-hover:text-rose-400 transition-colors duration-200 font-medium"> - {name.replace(/-/g, " ")} - </span> - - {matchedAlias && <span className="text-[10px] text-center truncate w-full mt-1">Alias: {matchedAlias}</span>} - </Link> - </MagicCard> - ) -} - -interface IconsGridProps { - filteredIcons: { name: string; data: Icon }[] - matchedAliases: Record<string, string> -} - -function IconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) { - return ( - <> - <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-2"> - {filteredIcons.slice(0, 120).map(({ name, data }) => ( - <IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name] || null} /> - ))} - </div> - {filteredIcons.length > 120 && <p className="text-sm text-muted-foreground">And {filteredIcons.length - 120} more...</p>} - </> - ) -}