mirror of
https://github.com/yusufipk/RepoHub.git
synced 2026-09-11 18:46:07 +00:00
- 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
231 lines
6.8 KiB
TypeScript
231 lines
6.8 KiB
TypeScript
import { Package } from "@/models/Package";
|
|
import {
|
|
RecommendationRequest,
|
|
RecommendedPackage,
|
|
UserCategory,
|
|
} from "@/types/recommendations";
|
|
import { getPackagesWithCategories, getPresetDetails, getPresetIcon } from "@/data/recommendationPresets";
|
|
|
|
export class RecommendationService {
|
|
/**
|
|
* Generate package recommendations based on user profile
|
|
*/
|
|
static async generateRecommendations(
|
|
request: RecommendationRequest
|
|
): Promise<RecommendedPackage[]> {
|
|
const { platform_id, categories, limit = 20 } = request;
|
|
|
|
// Step 1: Get preset package names for the user's categories and platform
|
|
const presetPackagesInfo = getPackagesWithCategories(platform_id, categories);
|
|
const presetPackageNames = presetPackagesInfo.map(p => p.name);
|
|
|
|
// Step 2: Generate packages from presets (no DB query)
|
|
const packageCategoryMap = new Map<string, UserCategory>();
|
|
|
|
// Fetch preset packages
|
|
const presetPackages = await this.fetchPresetPackages(
|
|
presetPackagesInfo,
|
|
platform_id,
|
|
packageCategoryMap
|
|
);
|
|
|
|
// Step 3: Score and rank packages
|
|
const scoredPackages = presetPackages.map((pkg) => {
|
|
const matchedCategory = packageCategoryMap.get(pkg.id) || categories[0];
|
|
return this.scorePackage(
|
|
pkg,
|
|
categories,
|
|
platform_id,
|
|
presetPackageNames,
|
|
matchedCategory
|
|
);
|
|
});
|
|
|
|
// Step 4: Sort by score and limit results
|
|
scoredPackages.sort(
|
|
(a, b) => b.recommendationScore - a.recommendationScore
|
|
);
|
|
|
|
return scoredPackages.slice(0, limit);
|
|
}
|
|
|
|
/**
|
|
* Fetch packages that match preset names
|
|
* optimized to use static data instead of DB queries
|
|
*/
|
|
private static async fetchPresetPackages(
|
|
packagesInfo: { name: string; category: UserCategory }[],
|
|
platformId: string,
|
|
categoryMap: Map<string, UserCategory>
|
|
): Promise<Package[]> {
|
|
if (packagesInfo.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const packages: Package[] = [];
|
|
|
|
for (const { name, category } of packagesInfo) {
|
|
// 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 removed as requested
|
|
version: "latest",
|
|
platform_id: platformId,
|
|
type: "cli",
|
|
repository: "official",
|
|
popularity_score: 100,
|
|
is_active: true,
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
downloads_count: 10000,
|
|
platform: {
|
|
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);
|
|
}
|
|
|
|
return packages;
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* Remove duplicate packages (by ID)
|
|
*/
|
|
private static deduplicatePackages(packages: Package[]): Package[] {
|
|
const seen = new Set<string>();
|
|
return packages.filter((pkg) => {
|
|
if (seen.has(pkg.id)) {
|
|
return false;
|
|
}
|
|
seen.add(pkg.id);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Score a package based on simplified factors (popularity & preset)
|
|
*/
|
|
private static scorePackage(
|
|
pkg: Package,
|
|
categories: UserCategory[],
|
|
platformId: string,
|
|
presetPackageNames: string[],
|
|
matchedCategory?: UserCategory
|
|
): 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;
|
|
|
|
if (isPresetMatch) {
|
|
finalScore += 100; // Ensure presets are always on top
|
|
}
|
|
|
|
return {
|
|
id: pkg.id,
|
|
name: pkg.name,
|
|
description: pkg.description || "No description available",
|
|
version: pkg.version || "latest",
|
|
category:
|
|
typeof pkg.category === "string" ? pkg.category : pkg.category?.name,
|
|
license:
|
|
typeof pkg.license === "string" ? pkg.license : pkg.license?.name,
|
|
type: pkg.type || "cli",
|
|
platform: pkg.platform as any,
|
|
platform_id: pkg.platform_id,
|
|
repository: pkg.repository || "official",
|
|
download_url: pkg.download_url,
|
|
lastUpdated: pkg.last_updated ? pkg.last_updated.toString() : undefined,
|
|
downloads: pkg.downloads_count,
|
|
popularity: pkg.popularity_score,
|
|
popularity_score: pkg.popularity_score,
|
|
tags: pkg.tags,
|
|
recommendationScore: finalScore,
|
|
recommendationReason: "", // Removed as requested
|
|
presetMatch: isPresetMatch,
|
|
matchedCategory: matchedCategory,
|
|
icon: icon
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get quick start recommendations (top 5 most essential)
|
|
*/
|
|
static async getQuickStartRecommendations(
|
|
platformId: string,
|
|
primaryCategory: UserCategory
|
|
): Promise<RecommendedPackage[]> {
|
|
return this.generateRecommendations({
|
|
platform_id: platformId,
|
|
categories: [primaryCategory],
|
|
limit: 5,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get recommendations for multiple categories with balanced distribution
|
|
*/
|
|
static async getBalancedRecommendations(
|
|
platformId: string,
|
|
categories: UserCategory[],
|
|
totalLimit: number = 20
|
|
): Promise<RecommendedPackage[]> {
|
|
const perCategory = Math.ceil(totalLimit / categories.length);
|
|
const allRecommendations: RecommendedPackage[] = [];
|
|
|
|
for (const category of categories) {
|
|
const recommendations = await this.generateRecommendations({
|
|
platform_id: platformId,
|
|
categories: [category],
|
|
limit: perCategory,
|
|
});
|
|
allRecommendations.push(...recommendations);
|
|
}
|
|
|
|
// Deduplicate by ID and re-sort
|
|
const seen = new Set<string>();
|
|
const deduplicated = allRecommendations.filter((pkg) => {
|
|
if (seen.has(pkg.id)) {
|
|
return false;
|
|
}
|
|
seen.add(pkg.id);
|
|
return true;
|
|
});
|
|
|
|
deduplicated.sort((a, b) => b.recommendationScore - a.recommendationScore);
|
|
|
|
return deduplicated.slice(0, totalLimit);
|
|
}
|
|
}
|