mirror of
https://github.com/yusufipk/RepoHub.git
synced 2026-09-11 10:36:07 +00:00
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:
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { Package as PackageIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
@@ -12,6 +13,7 @@ interface RecommendationCardProps {
|
||||
|
||||
export function RecommendationCard({ pkg, isSelected, onToggle }: RecommendationCardProps) {
|
||||
const { t } = useLocale()
|
||||
const [iconError, setIconError] = useState(false)
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -22,8 +24,33 @@ export function RecommendationCard({ pkg, isSelected, onToggle }: Recommendation
|
||||
|
||||
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<PackageIcon className="h-8 w-8 text-primary flex-shrink-0" />
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
{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">
|
||||
<h3 className="font-semibold truncate">{pkg.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">{pkg.version}</p>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { Package as PackageIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RecommendedPackage } from '@/types/recommendations'
|
||||
@@ -9,6 +10,8 @@ interface RecommendationListItemProps {
|
||||
}
|
||||
|
||||
export function RecommendationListItem({ pkg, isSelected, onToggle }: RecommendationListItemProps) {
|
||||
const [iconError, setIconError] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
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)}
|
||||
>
|
||||
{/* 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 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -41,7 +41,6 @@ export function RecommendationsSection({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid')
|
||||
const [sortMode, setSortMode] = useState<SortMode>('recommended')
|
||||
const [filterCategory, setFilterCategory] = useState<FilterCategory>('all')
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
@@ -116,8 +115,8 @@ export function RecommendationsSection({
|
||||
return recommendations.filter(pkg => pkg.matchedCategory === category).length
|
||||
}
|
||||
|
||||
// Filter and sort recommendations
|
||||
const filteredAndSortedRecommendations = useMemo(() => {
|
||||
// Filter recommendations
|
||||
const filteredRecommendations = useMemo(() => {
|
||||
let result = [...recommendations]
|
||||
|
||||
// Filter by category
|
||||
@@ -125,26 +124,8 @@ export function RecommendationsSection({
|
||||
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
|
||||
}, [recommendations, filterCategory, sortMode])
|
||||
}, [recommendations, filterCategory])
|
||||
|
||||
if (!isProfileComplete()) {
|
||||
return null
|
||||
@@ -294,46 +275,6 @@ export function RecommendationsSection({
|
||||
</div>
|
||||
|
||||
<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 */}
|
||||
<div className="flex items-center border rounded-md">
|
||||
<Button
|
||||
@@ -397,7 +338,7 @@ export function RecommendationsSection({
|
||||
|
||||
{!loading && !error && recommendations.length > 0 && viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredAndSortedRecommendations.map(pkg => (
|
||||
{filteredRecommendations.map(pkg => (
|
||||
<RecommendationCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
@@ -411,7 +352,7 @@ export function RecommendationsSection({
|
||||
{/* Compact View Mode */}
|
||||
{!loading && !error && recommendations.length > 0 && viewMode === 'compact' && (
|
||||
<div className="space-y-2">
|
||||
{filteredAndSortedRecommendations.map(pkg => (
|
||||
{filteredRecommendations.map(pkg => (
|
||||
<RecommendationListItem
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
|
||||
@@ -583,6 +583,191 @@ export const PRESET_DESCRIPTIONS: Record<string, string> = {
|
||||
"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 } {
|
||||
// Try exact match
|
||||
if (PRESET_DESCRIPTIONS[name]) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
RecommendedPackage,
|
||||
UserCategory,
|
||||
} from "@/types/recommendations";
|
||||
import { getPackagesWithCategories, getPresetDetails } from "@/data/recommendationPresets";
|
||||
import { getPackagesWithCategories, getPresetDetails, getPresetIcon } from "@/data/recommendationPresets";
|
||||
|
||||
export class RecommendationService {
|
||||
/**
|
||||
@@ -65,14 +65,15 @@ export class RecommendationService {
|
||||
const packages: Package[] = [];
|
||||
|
||||
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
|
||||
// This ensures instant loading for recommendations
|
||||
const mockPackage: Package = {
|
||||
id: `${platformId}:${name.toLowerCase()}`,
|
||||
name: name,
|
||||
description: description,
|
||||
description: "", // Description removed as requested
|
||||
version: "latest",
|
||||
platform_id: platformId,
|
||||
type: "cli",
|
||||
@@ -86,9 +87,19 @@ export class RecommendationService {
|
||||
id: platformId,
|
||||
name: platformId.charAt(0).toUpperCase() + platformId.slice(1),
|
||||
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);
|
||||
categoryMap.set(mockPackage.id, category);
|
||||
}
|
||||
@@ -124,6 +135,15 @@ export class RecommendationService {
|
||||
): RecommendedPackage {
|
||||
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)
|
||||
// Give a boost to preset packages so they appear first
|
||||
let finalScore = pkg.popularity_score || 0;
|
||||
@@ -155,6 +175,7 @@ export class RecommendationService {
|
||||
recommendationReason: "", // Removed as requested
|
||||
presetMatch: isPresetMatch,
|
||||
matchedCategory: matchedCategory,
|
||||
icon: icon
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface RecommendedPackage {
|
||||
recommendationReason: string;
|
||||
presetMatch?: boolean;
|
||||
matchedCategory?: UserCategory; // Which user category this package matched
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user