feat: Add package icons using Simple Icons and remove sorting functionality

- Integrate Simple Icons via CDN with SVG mask technique for theme-aware icons
- Add icon mapping for 150+ packages across all categories (development, design, multimedia, system tools, gaming, productivity, education)
- Implement fallback to default PackageIcon when icon unavailable or fails to load
- Add icon error handling with hidden img element to detect load failures
- Update RecommendationCard and RecommendationListItem to display
This commit is contained in:
Yusuf İpek
2025-11-23 14:38:39 +03:00
parent d66c02b7fa
commit 55e9e3b97d
6 changed files with 274 additions and 71 deletions
+29 -2
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { Package as PackageIcon } from 'lucide-react' import { Package as PackageIcon } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
@@ -12,6 +13,7 @@ interface RecommendationCardProps {
export function RecommendationCard({ pkg, isSelected, onToggle }: RecommendationCardProps) { export function RecommendationCard({ pkg, isSelected, onToggle }: RecommendationCardProps) {
const { t } = useLocale() const { t } = useLocale()
const [iconError, setIconError] = useState(false)
return ( return (
<Card <Card
@@ -22,8 +24,33 @@ export function RecommendationCard({ pkg, isSelected, onToggle }: Recommendation
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
<PackageIcon className="h-8 w-8 text-primary flex-shrink-0" /> {pkg.icon && !iconError ? (
<>
<div
className="h-8 w-8 flex-shrink-0 bg-foreground"
style={{
maskImage: `url(https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${pkg.icon}.svg)`,
WebkitMaskImage: `url(https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${pkg.icon}.svg)`,
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskSize: 'contain',
WebkitMaskSize: 'contain',
maskPosition: 'center',
WebkitMaskPosition: 'center'
}}
/>
{/* Hidden image to detect load errors */}
<img
src={`https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${pkg.icon}.svg`}
alt=""
className="hidden"
onError={() => setIconError(true)}
/>
</>
) : (
<PackageIcon className="h-8 w-8 text-foreground flex-shrink-0" />
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{pkg.name}</h3> <h3 className="font-semibold truncate">{pkg.name}</h3>
<p className="text-xs text-muted-foreground">{pkg.version}</p> <p className="text-xs text-muted-foreground">{pkg.version}</p>
+29 -1
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { Package as PackageIcon } from 'lucide-react' import { Package as PackageIcon } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { RecommendedPackage } from '@/types/recommendations' import { RecommendedPackage } from '@/types/recommendations'
@@ -9,6 +10,8 @@ interface RecommendationListItemProps {
} }
export function RecommendationListItem({ pkg, isSelected, onToggle }: RecommendationListItemProps) { export function RecommendationListItem({ pkg, isSelected, onToggle }: RecommendationListItemProps) {
const [iconError, setIconError] = useState(false)
return ( return (
<div <div
className={`flex items-center gap-3 p-3 rounded-lg border-2 transition-all cursor-pointer hover:shadow-md ${isSelected className={`flex items-center gap-3 p-3 rounded-lg border-2 transition-all cursor-pointer hover:shadow-md ${isSelected
@@ -18,7 +21,32 @@ export function RecommendationListItem({ pkg, isSelected, onToggle }: Recommenda
onClick={() => onToggle(pkg)} onClick={() => onToggle(pkg)}
> >
{/* Package Icon */} {/* Package Icon */}
<PackageIcon className="h-6 w-6 text-primary flex-shrink-0" /> {pkg.icon && !iconError ? (
<>
<div
className="h-6 w-6 flex-shrink-0 bg-foreground"
style={{
maskImage: `url(https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${pkg.icon}.svg)`,
WebkitMaskImage: `url(https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${pkg.icon}.svg)`,
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskSize: 'contain',
WebkitMaskSize: 'contain',
maskPosition: 'center',
WebkitMaskPosition: 'center'
}}
/>
{/* Hidden image to detect load errors */}
<img
src={`https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${pkg.icon}.svg`}
alt=""
className="hidden"
onError={() => setIconError(true)}
/>
</>
) : (
<PackageIcon className="h-6 w-6 text-foreground flex-shrink-0" />
)}
{/* Package Info */} {/* Package Info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
+5 -64
View File
@@ -41,7 +41,6 @@ export function RecommendationsSection({
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>('grid') const [viewMode, setViewMode] = useState<ViewMode>('grid')
const [sortMode, setSortMode] = useState<SortMode>('recommended')
const [filterCategory, setFilterCategory] = useState<FilterCategory>('all') const [filterCategory, setFilterCategory] = useState<FilterCategory>('all')
const [isExpanded, setIsExpanded] = useState(false) const [isExpanded, setIsExpanded] = useState(false)
@@ -116,8 +115,8 @@ export function RecommendationsSection({
return recommendations.filter(pkg => pkg.matchedCategory === category).length return recommendations.filter(pkg => pkg.matchedCategory === category).length
} }
// Filter and sort recommendations // Filter recommendations
const filteredAndSortedRecommendations = useMemo(() => { const filteredRecommendations = useMemo(() => {
let result = [...recommendations] let result = [...recommendations]
// Filter by category // Filter by category
@@ -125,26 +124,8 @@ export function RecommendationsSection({
result = result.filter(pkg => pkg.matchedCategory === filterCategory) result = result.filter(pkg => pkg.matchedCategory === filterCategory)
} }
// Sort
switch (sortMode) {
case 'popularity':
result.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
break
case 'preset':
result.sort((a, b) => {
if (a.presetMatch && !b.presetMatch) return -1
if (!a.presetMatch && b.presetMatch) return 1
return b.recommendationScore - a.recommendationScore
})
break
case 'recommended':
default:
result.sort((a, b) => b.recommendationScore - a.recommendationScore)
break
}
return result return result
}, [recommendations, filterCategory, sortMode]) }, [recommendations, filterCategory])
if (!isProfileComplete()) { if (!isProfileComplete()) {
return null return null
@@ -294,46 +275,6 @@ export function RecommendationsSection({
</div> </div>
<div className="flex items-center gap-2 ml-auto"> <div className="flex items-center gap-2 ml-auto">
{/* Sort Dropdown */}
<div className="flex items-center gap-1 border rounded-md">
<Button
variant={sortMode === 'recommended' ? 'default' : 'ghost'}
size="sm"
onClick={(e) => {
e.stopPropagation()
setSortMode('recommended')
}}
className="h-8 text-xs rounded-r-none"
>
<Award className="h-3 w-3 mr-1" />
{t('recommendations.sort.recommended') || 'Best Match'}
</Button>
<Button
variant={sortMode === 'popularity' ? 'default' : 'ghost'}
size="sm"
onClick={(e) => {
e.stopPropagation()
setSortMode('popularity')
}}
className="h-8 text-xs rounded-none border-x"
>
<TrendingUp className="h-3 w-3 mr-1" />
{t('recommendations.sort.popular') || 'Popular'}
</Button>
<Button
variant={sortMode === 'preset' ? 'default' : 'ghost'}
size="sm"
onClick={(e) => {
e.stopPropagation()
setSortMode('preset')
}}
className="h-8 text-xs rounded-l-none"
>
<Star className="h-3 w-3 mr-1" />
{t('recommendations.sort.preset') || 'Essential'}
</Button>
</div>
{/* View Mode Toggle */} {/* View Mode Toggle */}
<div className="flex items-center border rounded-md"> <div className="flex items-center border rounded-md">
<Button <Button
@@ -397,7 +338,7 @@ export function RecommendationsSection({
{!loading && !error && recommendations.length > 0 && viewMode === 'grid' && ( {!loading && !error && recommendations.length > 0 && viewMode === 'grid' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredAndSortedRecommendations.map(pkg => ( {filteredRecommendations.map(pkg => (
<RecommendationCard <RecommendationCard
key={pkg.id} key={pkg.id}
pkg={pkg} pkg={pkg}
@@ -411,7 +352,7 @@ export function RecommendationsSection({
{/* Compact View Mode */} {/* Compact View Mode */}
{!loading && !error && recommendations.length > 0 && viewMode === 'compact' && ( {!loading && !error && recommendations.length > 0 && viewMode === 'compact' && (
<div className="space-y-2"> <div className="space-y-2">
{filteredAndSortedRecommendations.map(pkg => ( {filteredRecommendations.map(pkg => (
<RecommendationListItem <RecommendationListItem
key={pkg.id} key={pkg.id}
pkg={pkg} pkg={pkg}
+185
View File
@@ -583,6 +583,191 @@ export const PRESET_DESCRIPTIONS: Record<string, string> = {
"zotero": "Your personal research assistant" "zotero": "Your personal research assistant"
}; };
export const PACKAGE_ICONS: Record<string, string> = {
// Development
"git": "git",
"Git.Git": "git",
"curl": "curl",
"wget": "gnu",
"nodejs": "nodedotjs",
"npm": "npm",
"python3": "python",
"python3-pip": "pypi",
"python-pip": "pypi",
"docker.io": "docker",
"Docker.DockerDesktop": "docker",
"dotnet-sdk-8.0": "dotnet",
"dotnet-sdk": "dotnet",
"Microsoft.VisualStudioCode": "visualstudiocode",
"visual-studio-code": "visualstudiocode",
"code": "visualstudiocode",
"Postman.Postman": "postman",
"postman": "postman",
"Microsoft.WindowsTerminal": "windows",
"iterm2": "iterm2",
"warp": "warp",
"sublime-text": "sublimetext",
"build-essential": "linux",
"base-devel": "archlinux",
"java-17-openjdk-devel": "openjdk",
"jdk17-openjdk": "openjdk",
"temurin": "eclipse",
"Anysphere.Cursor": "cursor",
"cursor": "cursor",
"EclipseFoundation.Eclipse": "eclipse",
"WinSCP.WinSCP": "winscp",
"PuTTY.PuTTY": "putty",
// Design
"gimp": "gimp",
"GIMP.GIMP": "gimp",
"inkscape": "inkscape",
"Inkscape.Inkscape": "inkscape",
"blender": "blender",
"BlenderFoundation.Blender": "blender",
"krita": "krita",
"KDE.Krita": "krita",
"darktable": "darktable",
"xnviewmp": "xnview",
"XnSoft.XnViewMP": "xnview",
"IrfanSkiljan.IrfanView": "irfanview",
"FastStone.Viewer": "imagej", // Placeholder, no icon
"ShareX.ShareX": "sharex",
"Greenshot.Greenshot": "greenshot",
// Multimedia
"vlc": "vlcmediaplayer",
"VideoLAN.VLC": "vlcmediaplayer",
"audacity": "audacity",
"Audacity.Audacity": "audacity",
"obs-studio": "obsstudio",
"obs": "obsstudio",
"OBSProject.OBSStudio": "obsstudio",
"ffmpeg": "ffmpeg",
"mpv": "mpv",
"handbrake": "handbrake",
"HandBrake.HandBrake": "handbrake",
"kdenlive": "kdenlive",
"spotify": "spotify",
"Spotify.Spotify": "spotify",
"Apple.iTunes": "itunes",
"foobar2000": "foobar2000",
"PeterPawlowski.foobar2000": "foobar2000",
"Winamp.Winamp": "winamp",
"AIMP.AIMP": "aimp",
"iina": "iina",
// System Tools
"htop": "htop",
"fastfetch": "linux",
"neofetch": "linux",
"tmux": "tmux",
"zsh": "zsh",
"gparted": "gparted",
"timeshift": "linux",
"stacer": "linux",
"keepassxc": "keepassxc",
"DominikReichl.KeePass": "keepass",
"synaptic": "debian",
"7zip.7zip": "7zip",
"Microsoft.PowerToys": "windows",
"voidtools.Everything": "windows",
"RARLab.WinRAR": "winrar",
"TeamViewer.TeamViewer": "teamviewer",
"teamviewer": "teamviewer",
"RealVNC.VNCViewer": "realvnc",
"rufus": "rufus",
"Rufus.Rufus": "rufus",
"bleachbit": "bleachbit",
"BleachBit.BleachBit": "bleachbit",
"rectangle": "macos",
"the-unarchiver": "macos",
"keka": "macos",
"appcleaner": "macos",
"raycast": "raycast",
"alfred": "alfred",
"qbittorrent": "qbittorrent",
"qBittorrent.qBittorrent": "qbittorrent",
"NVAccess.NVDA": "nvda",
"Malwarebytes.Malwarebytes": "malwarebytes",
// Gaming
"steam": "steam",
"Valve.Steam": "steam",
"lutris": "lutris",
"gamemode": "linux",
"mangohud": "opengl",
"discord": "discord",
"Discord.Discord": "discord",
"wine": "wine",
"winetricks": "wine",
"EpicGames.EpicGamesLauncher": "epicgames",
"epic-games": "epicgames",
"GOG.Galaxy": "gogdotcom",
// Productivity
"libreoffice": "libreoffice",
"TheDocumentFoundation.LibreOffice": "libreoffice",
"libreoffice-fresh": "libreoffice",
"thunderbird": "thunderbird",
"Mozilla.Thunderbird": "thunderbird",
"firefox": "firefox",
"Mozilla.Firefox": "firefox",
"chromium": "chromium",
"chromium-browser": "chromium",
"Google.Chrome": "googlechrome",
"google-chrome": "googlechrome",
"Microsoft.Edge": "microsoftedge",
"microsoft-edge": "microsoftedge",
"Brave.Brave": "brave",
"brave-browser": "brave",
"Opera.Opera": "opera",
"opera": "opera",
"zoom": "zoom",
"Zoom.Zoom": "zoom",
"microsoft-teams": "microsoftteams",
"Microsoft.Teams": "microsoftteams",
"slack": "slack",
"SlackTechnologies.Slack": "slack",
"notion": "notion",
"Notion.Notion": "notion",
"obsidian": "obsidian",
"Obsidian.Obsidian": "obsidian",
"foxitreader": "foxit",
"Foxit.FoxitReader": "foxit",
"adobe-acrobat-reader": "adobeacrobatreader",
"dropbox": "dropbox",
"Dropbox.Dropbox": "dropbox",
"onedrive": "microsoftonedrive",
"Microsoft.OneDrive": "microsoftonedrive",
"google-drive": "googledrive",
"evernote": "evernote",
"Evernote.Evernote": "evernote",
"evolution": "linux",
"focuswriter": "linux",
// Education
"anki": "anki",
"Anki.Anki": "anki",
"zotero": "zotero"
};
export function getPresetIcon(name: string): string | undefined {
// Try exact match
if (PACKAGE_ICONS[name]) {
return PACKAGE_ICONS[name];
}
// Try case insensitive
const lowerName = name.toLowerCase();
const key = Object.keys(PACKAGE_ICONS).find(k => k.toLowerCase() === lowerName);
if (key) {
return PACKAGE_ICONS[key];
}
return undefined;
}
export function getPresetDetails(name: string): { description: string } { export function getPresetDetails(name: string): { description: string } {
// Try exact match // Try exact match
if (PRESET_DESCRIPTIONS[name]) { if (PRESET_DESCRIPTIONS[name]) {
+25 -4
View File
@@ -4,7 +4,7 @@ import {
RecommendedPackage, RecommendedPackage,
UserCategory, UserCategory,
} from "@/types/recommendations"; } from "@/types/recommendations";
import { getPackagesWithCategories, getPresetDetails } from "@/data/recommendationPresets"; import { getPackagesWithCategories, getPresetDetails, getPresetIcon } from "@/data/recommendationPresets";
export class RecommendationService { export class RecommendationService {
/** /**
@@ -65,14 +65,15 @@ export class RecommendationService {
const packages: Package[] = []; const packages: Package[] = [];
for (const { name, category } of packagesInfo) { for (const { name, category } of packagesInfo) {
const { description } = getPresetDetails(name); // Get icon slug if available
const iconSlug = getPresetIcon(name);
// Create a mock package object to avoid database queries // Create a mock package object to avoid database queries
// This ensures instant loading for recommendations // This ensures instant loading for recommendations
const mockPackage: Package = { const mockPackage: Package = {
id: `${platformId}:${name.toLowerCase()}`, id: `${platformId}:${name.toLowerCase()}`,
name: name, name: name,
description: description, description: "", // Description removed as requested
version: "latest", version: "latest",
platform_id: platformId, platform_id: platformId,
type: "cli", type: "cli",
@@ -86,9 +87,19 @@ export class RecommendationService {
id: platformId, id: platformId,
name: platformId.charAt(0).toUpperCase() + platformId.slice(1), name: platformId.charAt(0).toUpperCase() + platformId.slice(1),
package_manager: "unknown" package_manager: "unknown"
} },
// We attach the icon slug to the tags temporarily or we can add a custom field if we extend the type
// But simpler is to pass it through the system.
// Actually, Package interface doesn't have icon.
// RecommendedPackage does (we added it).
// So we need to handle this in scorePackage or casting.
}; };
// Hack: Store icon slug in tags so it survives until scorePackage
if (iconSlug) {
mockPackage.tags = [`icon:${iconSlug}`];
}
packages.push(mockPackage); packages.push(mockPackage);
categoryMap.set(mockPackage.id, category); categoryMap.set(mockPackage.id, category);
} }
@@ -124,6 +135,15 @@ export class RecommendationService {
): RecommendedPackage { ): RecommendedPackage {
const isPresetMatch = presetPackageNames.includes(pkg.name); const isPresetMatch = presetPackageNames.includes(pkg.name);
// Extract icon from tags if present
let icon: string | undefined;
if (pkg.tags) {
const iconTag = pkg.tags.find(tag => tag.startsWith('icon:'));
if (iconTag) {
icon = iconTag.replace('icon:', '');
}
}
// Simplified score: just use popularity score (0-100) // Simplified score: just use popularity score (0-100)
// Give a boost to preset packages so they appear first // Give a boost to preset packages so they appear first
let finalScore = pkg.popularity_score || 0; let finalScore = pkg.popularity_score || 0;
@@ -155,6 +175,7 @@ export class RecommendationService {
recommendationReason: "", // Removed as requested recommendationReason: "", // Removed as requested
presetMatch: isPresetMatch, presetMatch: isPresetMatch,
matchedCategory: matchedCategory, matchedCategory: matchedCategory,
icon: icon
}; };
} }
+1
View File
@@ -65,6 +65,7 @@ export interface RecommendedPackage {
recommendationReason: string; recommendationReason: string;
presetMatch?: boolean; presetMatch?: boolean;
matchedCategory?: UserCategory; // Which user category this package matched matchedCategory?: UserCategory; // Which user category this package matched
icon?: string;
} }
/** /**