mirror of
https://github.com/yusufipk/RepoHub.git
synced 2026-09-11 18:46:07 +00:00
Merge pull request #7 from yusufipk/pr-4
Created a recommendation system for all platforms.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { RecommendationService } from "@/services/recommendationService";
|
||||
import { RecommendationRequest, UserCategory } from "@/types/recommendations";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: RecommendationRequest = await request.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!body.platform_id) {
|
||||
return NextResponse.json(
|
||||
{ error: "platform_id is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!body.categories || body.categories.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "At least one category is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate platform_id
|
||||
const validPlatforms = [
|
||||
"windows",
|
||||
"macos",
|
||||
"ubuntu",
|
||||
"debian",
|
||||
"arch",
|
||||
"fedora",
|
||||
];
|
||||
if (!validPlatforms.includes(body.platform_id)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Invalid platform_id. Must be one of: ${validPlatforms.join(
|
||||
", "
|
||||
)}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate categories
|
||||
const validCategories = [
|
||||
"development",
|
||||
"design",
|
||||
"multimedia",
|
||||
"system-tools",
|
||||
"gaming",
|
||||
"productivity",
|
||||
"education",
|
||||
];
|
||||
const invalidCategories = body.categories.filter(
|
||||
(cat) => !validCategories.includes(cat)
|
||||
);
|
||||
if (invalidCategories.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Invalid categories: ${invalidCategories.join(", ")}`,
|
||||
validCategories,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate and set limit with better error message
|
||||
if (body.limit !== undefined && (body.limit < 1 || body.limit > 1000)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Limit must be between 1 and 1000" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const limit =
|
||||
body.limit && body.limit > 0 && body.limit <= 1000 ? body.limit : 50;
|
||||
|
||||
// Generate recommendations
|
||||
const recommendations = await RecommendationService.generateRecommendations(
|
||||
{
|
||||
platform_id: body.platform_id,
|
||||
categories: body.categories,
|
||||
limit,
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
recommendations,
|
||||
total: recommendations.length,
|
||||
userProfile: {
|
||||
categories: body.categories,
|
||||
platform: body.platform_id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating recommendations:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to generate recommendations",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const platformId = searchParams.get("platform_id");
|
||||
const categoriesParam = searchParams.get("categories");
|
||||
const limit = searchParams.get("limit");
|
||||
|
||||
// Validate required fields
|
||||
if (!platformId) {
|
||||
return NextResponse.json(
|
||||
{ error: "platform_id query parameter is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!categoriesParam) {
|
||||
return NextResponse.json(
|
||||
{ error: "categories query parameter is required (comma-separated)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse categories
|
||||
const categories = categoriesParam.split(",").map((c) => c.trim());
|
||||
|
||||
// Validate platform_id
|
||||
const validPlatforms = [
|
||||
"windows",
|
||||
"macos",
|
||||
"ubuntu",
|
||||
"debian",
|
||||
"arch",
|
||||
"fedora",
|
||||
];
|
||||
if (!validPlatforms.includes(platformId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Invalid platform_id. Must be one of: ${validPlatforms.join(
|
||||
", "
|
||||
)}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate categories
|
||||
const validCategories: UserCategory[] = [
|
||||
"development",
|
||||
"design",
|
||||
"multimedia",
|
||||
"system-tools",
|
||||
"gaming",
|
||||
"productivity",
|
||||
"education",
|
||||
];
|
||||
const invalidCategories = categories.filter(
|
||||
(cat) => !validCategories.includes(cat as UserCategory)
|
||||
);
|
||||
if (invalidCategories.length > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Invalid categories: ${invalidCategories.join(", ")}`,
|
||||
validCategories,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Set default limit
|
||||
const parsedLimit =
|
||||
limit && parseInt(limit) > 0 && parseInt(limit) <= 50
|
||||
? parseInt(limit)
|
||||
: 20;
|
||||
|
||||
// Generate recommendations with validated types
|
||||
const recommendations = await RecommendationService.generateRecommendations(
|
||||
{
|
||||
platform_id: platformId,
|
||||
categories: categories as UserCategory[],
|
||||
limit: parsedLimit,
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
recommendations,
|
||||
total: recommendations.length,
|
||||
userProfile: {
|
||||
categories,
|
||||
platform: platformId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error generating recommendations:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to generate recommendations",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+22
-11
@@ -10,20 +10,25 @@
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary: 218 63% 38%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -33,8 +38,8 @@
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 84% 4.9%;
|
||||
--primary: 218 63% 38%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
@@ -45,7 +50,12 @@
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 94.1%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +63,8 @@
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,16 @@ import { Button } from '@/components/ui/button'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
import { useLocale } from '@/contexts/LocaleContext'
|
||||
import { SupportModal } from '@/components/SupportModal'
|
||||
import { Sun, Moon, Monitor, Globe, Heart, Github } from 'lucide-react'
|
||||
import { Sun, Moon, Monitor, Globe, Heart, Github, Settings } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
export interface HeaderProps {
|
||||
cryptomusEnabled: boolean
|
||||
onResetPreferences?: () => void
|
||||
hasProfile?: boolean
|
||||
}
|
||||
|
||||
export function Header({ cryptomusEnabled }: HeaderProps) {
|
||||
export function Header({ cryptomusEnabled, onResetPreferences, hasProfile }: HeaderProps) {
|
||||
const { theme, isDark, toggleTheme } = useTheme()
|
||||
const { locale, toggleLocale, t } = useLocale()
|
||||
const [isSupportModalOpen, setIsSupportModalOpen] = useState(false)
|
||||
@@ -58,6 +60,21 @@ export function Header({ cryptomusEnabled }: HeaderProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Preferences Button - Show when profile exists */}
|
||||
{hasProfile && onResetPreferences && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onResetPreferences}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="ml-2 hidden sm:inline">
|
||||
{t('recommendations.customize')}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Support Button - Only show when Cryptomus is enabled */}
|
||||
{cryptomusEnabled && (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { X, ChevronRight, ChevronLeft, Sparkles } from 'lucide-react'
|
||||
import { UserCategory } from '@/types/recommendations'
|
||||
import { useLocale } from '@/contexts/LocaleContext'
|
||||
import { CATEGORY_ICONS } from '@/constants/categoryIcons'
|
||||
|
||||
interface OnboardingModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onComplete: (data: {
|
||||
categories: UserCategory[]
|
||||
selectedOS?: string
|
||||
}) => void
|
||||
detectedOS: string
|
||||
}
|
||||
|
||||
const PLATFORMS = [
|
||||
{ id: 'windows', name: 'Windows' },
|
||||
{ id: 'macos', name: 'macOS' },
|
||||
{ id: 'ubuntu', name: 'Ubuntu' },
|
||||
{ id: 'debian', name: 'Debian' },
|
||||
{ id: 'arch', name: 'Arch Linux' },
|
||||
{ id: 'fedora', name: 'Fedora' }
|
||||
]
|
||||
|
||||
const iconSlug: Record<string, string> = {
|
||||
debian: 'debian',
|
||||
ubuntu: 'ubuntu',
|
||||
fedora: 'fedora',
|
||||
arch: 'archlinux',
|
||||
windows: 'windows',
|
||||
macos: 'apple'
|
||||
}
|
||||
|
||||
const iconBase = (slug: string) => `https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${slug}.svg`
|
||||
|
||||
export function OnboardingModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onComplete,
|
||||
detectedOS
|
||||
}: OnboardingModalProps) {
|
||||
const { t } = useLocale()
|
||||
const [step, setStep] = useState(1)
|
||||
const [selectedCategories, setSelectedCategories] = useState<UserCategory[]>([])
|
||||
// Default to ubuntu if OS detection fails
|
||||
const [selectedOS, setSelectedOS] = useState<string>(
|
||||
detectedOS !== 'unknown' ? detectedOS : 'ubuntu'
|
||||
)
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStep(1)
|
||||
// Keep current OS selection or use detected
|
||||
setSelectedOS(detectedOS !== 'unknown' ? detectedOS : 'ubuntu')
|
||||
}
|
||||
}, [isOpen, detectedOS])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleCategoryToggle = (category: UserCategory) => {
|
||||
setSelectedCategories(prev => {
|
||||
if (prev.includes(category)) {
|
||||
return prev.filter(c => c !== category)
|
||||
}
|
||||
return [...prev, category]
|
||||
})
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (step < 2) {
|
||||
setStep(step + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) {
|
||||
setStep(step - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
if (selectedCategories.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
onComplete({
|
||||
categories: selectedCategories,
|
||||
selectedOS: selectedOS
|
||||
})
|
||||
|
||||
// Close modal
|
||||
onClose()
|
||||
}
|
||||
|
||||
const canProceed = () => {
|
||||
if (step === 1) return selectedCategories.length > 0
|
||||
if (step === 2) return true
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<Card className="w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<CardHeader className="relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-6 w-6 text-primary" />
|
||||
<CardTitle className="text-2xl">
|
||||
{t('onboarding.title')}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{t('onboarding.subtitle')}
|
||||
</CardDescription>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="flex gap-2 mt-4">
|
||||
{[1, 2].map(i => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-2 flex-1 rounded-full transition-colors ${i <= step ? 'bg-primary' : 'bg-secondary'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Step 1: Categories */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t('onboarding.step1.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('onboarding.step1.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{(Object.entries(CATEGORY_ICONS) as [UserCategory, any][]).map(([category, Icon]) => {
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => handleCategoryToggle(category)}
|
||||
className={`p-4 rounded-lg border-2 text-left transition-all hover:scale-105 ${selectedCategories.includes(category)
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
<h4 className="font-semibold capitalize">
|
||||
{t(`categories.${category}.name`)}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(`categories.${category}.description`)}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Operating System */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t('onboarding.step2.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t('onboarding.step2.description')}
|
||||
</p>
|
||||
{detectedOS !== 'unknown' && (
|
||||
<p className="text-sm text-primary mb-4">
|
||||
{t('onboarding.step2.detected', { os: detectedOS })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{PLATFORMS.map(platform => (
|
||||
<button
|
||||
key={platform.id}
|
||||
onClick={() => setSelectedOS(platform.id)}
|
||||
className={`p-4 rounded-lg border-2 text-center transition-all hover:scale-105 ${selectedOS === platform.id
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`h-12 w-12 mx-auto mb-3 ${selectedOS === platform.id ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
style={{
|
||||
WebkitMaskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
|
||||
maskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskRepeat: 'no-repeat',
|
||||
WebkitMaskSize: 'contain',
|
||||
maskSize: 'contain',
|
||||
WebkitMaskPosition: 'center',
|
||||
maskPosition: 'center',
|
||||
backgroundColor: 'currentColor'
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
<div className="font-semibold text-sm">{platform.name}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
{step > 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
className="flex-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{step < 2 ? (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className="flex-1"
|
||||
>
|
||||
{t('common.next')}
|
||||
<ChevronRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
disabled={!canProceed()}
|
||||
className="flex-1"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
{t('common.finish')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,16 +7,26 @@ import { apiClient } from '@/lib/api/client'
|
||||
import { useLocale } from '@/contexts/LocaleContext'
|
||||
import { Platform } from '@/types'
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Lock } from 'lucide-react'
|
||||
|
||||
interface PlatformSelectorProps {
|
||||
selectedPlatform: Platform | null
|
||||
onPlatformSelect: (platform: Platform) => void
|
||||
isLocked?: boolean
|
||||
lockedMessage?: string
|
||||
}
|
||||
|
||||
export function PlatformSelector({ selectedPlatform, onPlatformSelect }: PlatformSelectorProps) {
|
||||
export function PlatformSelector({
|
||||
selectedPlatform,
|
||||
onPlatformSelect,
|
||||
isLocked = false,
|
||||
lockedMessage = "Platform selection is locked"
|
||||
}: PlatformSelectorProps) {
|
||||
const { t } = useLocale()
|
||||
const [platforms, setPlatforms] = useState<Platform[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
|
||||
const iconSlug: Record<string, string> = {
|
||||
debian: 'debian',
|
||||
ubuntu: 'ubuntu',
|
||||
@@ -66,43 +76,73 @@ export function PlatformSelector({ selectedPlatform, onPlatformSelect }: Platfor
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">{t('platform.select')}</CardTitle>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
{t('platform.select')}
|
||||
{isLocked && <Lock className="h-4 w-4 text-muted-foreground" />}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
{t('platform.description')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2">
|
||||
{platforms.map((platform) => (
|
||||
<Button
|
||||
key={platform.id}
|
||||
variant="outline"
|
||||
className={`h-12 px-2 py-1 flex flex-col items-center justify-center gap-1 rounded-md border transition-colors
|
||||
${selectedPlatform?.id === platform.id
|
||||
? 'border-primary ring-2 ring-primary/60 bg-primary/5'
|
||||
: 'border-border hover:bg-secondary/60'}`}
|
||||
onClick={() => onPlatformSelect(platform)}
|
||||
>
|
||||
<div
|
||||
className={`h-5 w-5 ${selectedPlatform?.id === platform.id ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
style={{
|
||||
WebkitMaskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
|
||||
maskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskRepeat: 'no-repeat',
|
||||
WebkitMaskSize: 'contain',
|
||||
maskSize: 'contain',
|
||||
WebkitMaskPosition: 'center',
|
||||
maskPosition: 'center',
|
||||
backgroundColor: 'currentColor'
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-xs leading-tight truncate max-w-[90px]">{platform.name}</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2">
|
||||
{platforms.map((platform) => {
|
||||
const isSelected = selectedPlatform?.id === platform.id
|
||||
const isDisabled = isLocked && !isSelected
|
||||
|
||||
const ButtonContent = (
|
||||
<Button
|
||||
key={platform.id}
|
||||
variant="outline"
|
||||
className={`h-12 px-2 py-1 flex flex-col items-center justify-center gap-1 rounded-md border transition-colors w-full
|
||||
${isSelected
|
||||
? 'border-primary ring-2 ring-primary/60 bg-primary/5'
|
||||
: 'border-border hover:bg-secondary/60'}
|
||||
${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
onClick={() => !isLocked && onPlatformSelect(platform)}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<div
|
||||
className={`h-5 w-5 ${isSelected ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
style={{
|
||||
WebkitMaskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
|
||||
maskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
maskRepeat: 'no-repeat',
|
||||
WebkitMaskSize: 'contain',
|
||||
maskSize: 'contain',
|
||||
WebkitMaskPosition: 'center',
|
||||
maskPosition: 'center',
|
||||
backgroundColor: 'currentColor'
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-xs leading-tight truncate max-w-[90px]">{platform.name}</div>
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (isLocked && !isSelected) {
|
||||
return (
|
||||
<Tooltip key={platform.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="w-full cursor-not-allowed">
|
||||
{ButtonContent}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{lockedMessage}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return ButtonContent
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react'
|
||||
import { Package as PackageIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { RecommendedPackage } from '@/types/recommendations'
|
||||
import { useLocale } from '@/contexts/LocaleContext'
|
||||
|
||||
interface RecommendationCardProps {
|
||||
pkg: RecommendedPackage
|
||||
isSelected: boolean
|
||||
onToggle: (pkg: RecommendedPackage) => void
|
||||
}
|
||||
|
||||
export function RecommendationCard({ pkg, isSelected, onToggle }: RecommendationCardProps) {
|
||||
const { t } = useLocale()
|
||||
const [iconError, setIconError] = useState(false)
|
||||
|
||||
// Helper to format package name for display
|
||||
const getDisplayName = (name: string, platformId?: string) => {
|
||||
if (platformId === 'windows' && name.includes('.')) {
|
||||
// Handle winget IDs like "Microsoft.VisualStudioCode" or "Git.Git"
|
||||
const parts = name.split('.')
|
||||
const appName = parts[parts.length - 1] // Take the last part
|
||||
|
||||
// Add spaces to CamelCase (e.g. "VisualStudioCode" -> "Visual Studio Code")
|
||||
return appName.replace(/([A-Z])/g, ' $1').trim()
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
const displayName = getDisplayName(pkg.name, pkg.platform_id)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`relative overflow-hidden transition-all hover:shadow-lg cursor-pointer ${isSelected ? 'ring-2 ring-primary' : ''
|
||||
}`}
|
||||
onClick={() => onToggle(pkg)}
|
||||
>
|
||||
|
||||
|
||||
<CardContent className="p-4">
|
||||
<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" title={displayName}>{displayName}</h3>
|
||||
<p className="text-xs text-muted-foreground">{pkg.version}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="w-full mt-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle(pkg)
|
||||
}}
|
||||
>
|
||||
{isSelected ? '✓ Selected' : t('recommendations.add_to_selection')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from 'react'
|
||||
import { Package as PackageIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RecommendedPackage } from '@/types/recommendations'
|
||||
|
||||
interface RecommendationListItemProps {
|
||||
pkg: RecommendedPackage
|
||||
isSelected: boolean
|
||||
onToggle: (pkg: RecommendedPackage) => void
|
||||
}
|
||||
|
||||
export function RecommendationListItem({ pkg, isSelected, onToggle }: RecommendationListItemProps) {
|
||||
const [iconError, setIconError] = useState(false)
|
||||
|
||||
// Helper to format package name for display
|
||||
const getDisplayName = (name: string, platformId?: string) => {
|
||||
if (platformId === 'windows' && name.includes('.')) {
|
||||
// Handle winget IDs like "Microsoft.VisualStudioCode" or "Git.Git"
|
||||
const parts = name.split('.')
|
||||
const appName = parts[parts.length - 1] // Take the last part
|
||||
|
||||
// Add spaces to CamelCase (e.g. "VisualStudioCode" -> "Visual Studio Code")
|
||||
return appName.replace(/([A-Z])/g, ' $1').trim()
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
const displayName = getDisplayName(pkg.name, pkg.platform_id)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 transition-all cursor-pointer hover:shadow-md ${isSelected
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50'
|
||||
}`}
|
||||
onClick={() => onToggle(pkg)}
|
||||
>
|
||||
{/* Package Icon */}
|
||||
{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">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-sm truncate" title={displayName}>{displayName}</h4>
|
||||
<span className="text-xs text-muted-foreground">{pkg.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Select Button */}
|
||||
<Button
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="ml-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggle(pkg)
|
||||
}}
|
||||
>
|
||||
{isSelected ? '✓' : '+'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sparkles, Settings, Package as PackageIcon, Star, Grid3x3, List, TrendingUp, Award, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { RecommendedPackage, UserCategory } from '@/types/recommendations'
|
||||
import { Package } from '@/types'
|
||||
import { useLocale } from '@/contexts/LocaleContext'
|
||||
import { useRecommendationProfile } from '@/hooks/useRecommendationProfile'
|
||||
import { CATEGORY_ICONS } from '@/constants/categoryIcons'
|
||||
import { RecommendationCard } from './RecommendationCard'
|
||||
import { RecommendationListItem } from './RecommendationListItem'
|
||||
|
||||
import { UserProfile } from '@/types/recommendations'
|
||||
|
||||
interface RecommendationsSectionProps {
|
||||
onPackageToggle: (pkg: Package) => void
|
||||
selectedPackages: Package[]
|
||||
onCustomizeClick: () => void
|
||||
profile: UserProfile
|
||||
}
|
||||
|
||||
type ViewMode = 'grid' | 'compact'
|
||||
type SortMode = 'recommended' | 'popularity' | 'preset'
|
||||
type FilterCategory = 'all' | string
|
||||
|
||||
export function RecommendationsSection({
|
||||
onPackageToggle,
|
||||
selectedPackages,
|
||||
onCustomizeClick,
|
||||
profile
|
||||
}: RecommendationsSectionProps) {
|
||||
const { t } = useLocale()
|
||||
|
||||
// Derived state from props
|
||||
const getEffectiveOS = () => profile.selectedOS || profile.detectedOS || "ubuntu"
|
||||
const isProfileComplete = () => profile.categories.length > 0 && getEffectiveOS() !== "unknown"
|
||||
|
||||
const [recommendations, setRecommendations] = useState<RecommendedPackage[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid')
|
||||
const [filterCategory, setFilterCategory] = useState<FilterCategory>('all')
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
|
||||
const fetchRecommendations = async () => {
|
||||
if (!isProfileComplete()) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/recommendations', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
platform_id: getEffectiveOS(),
|
||||
categories: profile.categories,
|
||||
limit: 1000
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
|
||||
if (response.status === 400) {
|
||||
throw new Error(errorData.error || 'Invalid request parameters')
|
||||
} else if (response.status === 500) {
|
||||
throw new Error('Server error. Please try again later.')
|
||||
} else if (response.status === 404) {
|
||||
throw new Error('Recommendation service not available')
|
||||
} else {
|
||||
throw new Error(`Request failed: ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const recs = data.recommendations || []
|
||||
setRecommendations(recs)
|
||||
} catch (err) {
|
||||
console.error('Error fetching recommendations:', err)
|
||||
|
||||
// User-friendly error messages
|
||||
if (err instanceof TypeError && err.message.includes('fetch')) {
|
||||
setError('Network error. Please check your internet connection.')
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load recommendations')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recommendations on mount and when profile changes
|
||||
useEffect(() => {
|
||||
if (isProfileComplete()) {
|
||||
// If profile just changed (e.g. from customization), we might want to force refresh
|
||||
// But for now, let's rely on the cache key changing which includes profile data
|
||||
fetchRecommendations()
|
||||
}
|
||||
}, [profile.categories, profile.selectedOS])
|
||||
|
||||
const isPackageSelected = (pkg: RecommendedPackage) => {
|
||||
return selectedPackages.some(selected => selected.id === pkg.id)
|
||||
}
|
||||
|
||||
// Get package count per category
|
||||
const getCategoryCount = (category: string): number => {
|
||||
return recommendations.filter(pkg => pkg.matchedCategory === category).length
|
||||
}
|
||||
|
||||
// Filter recommendations
|
||||
const filteredRecommendations = useMemo(() => {
|
||||
let result = [...recommendations]
|
||||
|
||||
// Filter by category
|
||||
if (filterCategory !== 'all') {
|
||||
result = result.filter(pkg => pkg.matchedCategory === filterCategory)
|
||||
}
|
||||
|
||||
return result
|
||||
}, [recommendations, filterCategory])
|
||||
|
||||
if (!isProfileComplete()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="cursor-pointer hover:bg-accent/50 transition-colors" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-lg">
|
||||
{t('recommendations.title')}
|
||||
</CardTitle>
|
||||
{!isExpanded && recommendations.length > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||
{recommendations.length} {t('recommendations.packages') || 'packages'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-sm">
|
||||
{t('recommendations.subtitle')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Toggle: if all recommendations are selected, deselect them, otherwise select all
|
||||
const allSelected = recommendations.every(rec =>
|
||||
selectedPackages.some(sel => sel.id === rec.id)
|
||||
)
|
||||
|
||||
if (allSelected) {
|
||||
// Deselect all recommendations
|
||||
recommendations.forEach(rec => {
|
||||
if (isPackageSelected(rec)) {
|
||||
onPackageToggle(rec)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Select all recommendations
|
||||
recommendations.forEach(rec => {
|
||||
if (!isPackageSelected(rec)) {
|
||||
onPackageToggle(rec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
disabled={loading || recommendations.length === 0}
|
||||
>
|
||||
{recommendations.every(rec => selectedPackages.some(sel => sel.id === rec.id))
|
||||
? (t('common.deselect_all') || 'Deselect All')
|
||||
: (t('common.select_all') || 'Select All')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCustomizeClick()
|
||||
}}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t('recommendations.customize')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show user profile info */}
|
||||
{isExpanded && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
|
||||
{getEffectiveOS()}
|
||||
</span>
|
||||
{profile.categories.map(cat => (
|
||||
<span
|
||||
key={cat}
|
||||
className="text-xs px-2 py-1 rounded-full bg-secondary text-secondary-foreground"
|
||||
>
|
||||
{t(`categories.${cat}.name`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters and View Controls */}
|
||||
{isExpanded && !loading && !error && recommendations.length > 0 && (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-3 mt-4 pt-4 border-t"
|
||||
onClick={(e) => e.stopPropagation()} // Prevent collapse when clicking filter area
|
||||
>
|
||||
{/* Category Filter Tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={filterCategory === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setFilterCategory('all')
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
All ({recommendations.length})
|
||||
</Button>
|
||||
{profile.categories.map(cat => {
|
||||
const count = getCategoryCount(cat)
|
||||
const Icon = CATEGORY_ICONS[cat]
|
||||
return (
|
||||
<Button
|
||||
key={cat}
|
||||
variant={filterCategory === cat ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setFilterCategory(cat)
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
disabled={count === 0}
|
||||
>
|
||||
{Icon && <Icon className="h-3 w-3 mr-1.5" />}
|
||||
{t(`categories.${cat}.name`)} ({count})
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center border rounded-md">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setViewMode('grid')
|
||||
}}
|
||||
className="h-8 w-8 p-0 rounded-r-none"
|
||||
>
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'compact' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setViewMode('compact')
|
||||
}}
|
||||
className="h-8 w-8 p-0 rounded-l-none border-l"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
{loading && (
|
||||
<div className="text-center py-12">
|
||||
<Sparkles className="h-8 w-8 animate-spin mx-auto mb-4 text-primary" />
|
||||
<p className="text-muted-foreground">{t('recommendations.loading')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-destructive mb-4">{error}</p>
|
||||
<Button onClick={() => fetchRecommendations()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && recommendations.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<PackageIcon className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t('recommendations.no_recommendations')}
|
||||
</p>
|
||||
<Button onClick={onCustomizeClick} variant="outline">
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
{t('recommendations.customize')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && recommendations.length > 0 && viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredRecommendations.map(pkg => (
|
||||
<RecommendationCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
isSelected={isPackageSelected(pkg)}
|
||||
onToggle={onPackageToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compact View Mode */}
|
||||
{!loading && !error && recommendations.length > 0 && viewMode === 'compact' && (
|
||||
<div className="space-y-2">
|
||||
{filteredRecommendations.map(pkg => (
|
||||
<RecommendationListItem
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
isSelected={isPackageSelected(pkg)}
|
||||
onToggle={onPackageToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { LocaleProvider } from '@/contexts/LocaleContext'
|
||||
import { Header } from './Header'
|
||||
import { PlatformSelector } from './PlatformSelector'
|
||||
import { PackageBrowserV2 } from './PackageBrowserV2'
|
||||
import { SelectionManager } from './SelectionManager'
|
||||
import { ScriptPreview } from '@/components/ScriptPreview'
|
||||
import { OnboardingModal } from '@/components/OnboardingModal'
|
||||
import { RecommendationsSection } from '@/components/RecommendationsSection'
|
||||
import { generateScript } from '@/lib/scriptGenerator'
|
||||
import { useLocale } from '@/contexts/LocaleContext'
|
||||
import { useRecommendationProfile } from '@/hooks/useRecommendationProfile'
|
||||
import { Platform, Package, SelectedPackage, FilterOptions, GeneratedScript } from '@/types'
|
||||
import { UserCategory } from '@/types/recommendations'
|
||||
import { Sparkles, Settings, Package as PackageIcon } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean }) {
|
||||
const { t, locale } = useLocale()
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<Platform | null>(null)
|
||||
const [selectedPackages, setSelectedPackages] = useState<SelectedPackage[]>([])
|
||||
const [generatedScript, setGeneratedScript] = useState<GeneratedScript | null>(null)
|
||||
const [availablePlatforms, setAvailablePlatforms] = useState<Platform[]>([])
|
||||
|
||||
// Recommendation profile management
|
||||
const {
|
||||
profile,
|
||||
isLoading: isProfileLoading,
|
||||
hasCompletedOnboarding,
|
||||
saveProfile,
|
||||
detectedOS,
|
||||
getEffectiveOS
|
||||
} = useRecommendationProfile()
|
||||
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
|
||||
// Load platforms on mount
|
||||
useEffect(() => {
|
||||
const loadPlatforms = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/platforms')
|
||||
if (response.ok) {
|
||||
const platforms = await response.json()
|
||||
setAvailablePlatforms(platforms)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load platforms:', error)
|
||||
}
|
||||
}
|
||||
loadPlatforms()
|
||||
}, [])
|
||||
|
||||
// Show onboarding modal on first visit - DISABLED as per user request
|
||||
/*
|
||||
useEffect(() => {
|
||||
if (!isProfileLoading && !hasCompletedOnboarding) {
|
||||
// Delay to allow page to render first
|
||||
const timer = setTimeout(() => {
|
||||
setShowOnboarding(true)
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isProfileLoading, hasCompletedOnboarding])
|
||||
*/
|
||||
|
||||
const handleOnboardingComplete = (data: {
|
||||
categories: UserCategory[]
|
||||
selectedOS?: string
|
||||
}) => {
|
||||
console.log('🎯 Onboarding completed with data:', data)
|
||||
|
||||
const success = saveProfile({
|
||||
categories: data.categories,
|
||||
selectedOS: data.selectedOS,
|
||||
hasCompletedOnboarding: true
|
||||
})
|
||||
|
||||
console.log('💾 Profile save result:', success)
|
||||
|
||||
// Don't call completeOnboarding() - it causes a second save with empty state!
|
||||
// completeOnboarding()
|
||||
|
||||
// Force close modal
|
||||
setShowOnboarding(false)
|
||||
}
|
||||
|
||||
const handleCustomizePreferences = () => {
|
||||
setShowOnboarding(true)
|
||||
}
|
||||
|
||||
const handlePlatformSelect = (platform: Platform) => {
|
||||
setSelectedPlatform(platform)
|
||||
@@ -44,8 +118,28 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
|
||||
}
|
||||
|
||||
const handleGenerateScript = () => {
|
||||
if (selectedPlatform && selectedPackages.length > 0) {
|
||||
const script = generateScript(selectedPackages, selectedPlatform)
|
||||
if (selectedPackages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use selected platform, or if not selected, find platform from available platforms
|
||||
let platformToUse = selectedPlatform
|
||||
|
||||
if (!platformToUse && hasCompletedOnboarding) {
|
||||
// Get effective OS from profile and find matching platform from loaded platforms
|
||||
const effectiveOS = profile.selectedOS || detectedOS
|
||||
|
||||
if (effectiveOS && availablePlatforms.length > 0) {
|
||||
platformToUse = availablePlatforms.find(p => p.id === effectiveOS) || null
|
||||
|
||||
if (!platformToUse) {
|
||||
console.warn(`Platform not found for OS: ${effectiveOS}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (platformToUse) {
|
||||
const script = generateScript(selectedPackages, platformToUse)
|
||||
setGeneratedScript(script)
|
||||
}
|
||||
}
|
||||
@@ -58,10 +152,31 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
|
||||
const handleCloseScriptPreview = () => {
|
||||
setGeneratedScript(null)
|
||||
}
|
||||
// Track previous effective OS to detect profile changes
|
||||
const prevEffectiveOS = useRef<string | null>(null)
|
||||
|
||||
// Calculate current effective OS
|
||||
const effectiveOS = getEffectiveOS()
|
||||
|
||||
// Sync platform selection with profile changes
|
||||
useEffect(() => {
|
||||
if (hasCompletedOnboarding && availablePlatforms.length > 0) {
|
||||
const platform = availablePlatforms.find(p => p.id === effectiveOS)
|
||||
|
||||
// If profile OS changed, or if no platform is selected yet, update selection
|
||||
if (platform && (effectiveOS !== prevEffectiveOS.current || !selectedPlatform)) {
|
||||
setSelectedPlatform(platform)
|
||||
prevEffectiveOS.current = effectiveOS
|
||||
}
|
||||
}
|
||||
}, [hasCompletedOnboarding, effectiveOS, availablePlatforms, selectedPlatform])
|
||||
return (
|
||||
<div key={locale} className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<Header cryptomusEnabled={cryptomusEnabled} />
|
||||
<Header
|
||||
cryptomusEnabled={cryptomusEnabled}
|
||||
onResetPreferences={handleCustomizePreferences}
|
||||
hasProfile={hasCompletedOnboarding}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
@@ -79,10 +194,45 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="space-y-8">
|
||||
{/* Recommendations Section - Show if profile is complete */}
|
||||
{hasCompletedOnboarding && profile.categories.length > 0 ? (
|
||||
<RecommendationsSection
|
||||
onPackageToggle={handlePackageToggle}
|
||||
selectedPackages={selectedPackages}
|
||||
onCustomizeClick={handleCustomizePreferences}
|
||||
profile={profile}
|
||||
/>
|
||||
) : (
|
||||
/* Empty State for Recommendations */
|
||||
<Card className="w-full bg-gradient-to-r from-primary/5 to-secondary/5 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="bg-background p-4 rounded-full shadow-sm mb-4">
|
||||
<Sparkles className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
{t('recommendations.empty_title') || 'Get Personalized Recommendations'}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md mb-6">
|
||||
{t('recommendations.empty_description') || 'Tell us about your role and platform to get a curated list of essential packages.'}
|
||||
</p>
|
||||
<Button onClick={handleCustomizePreferences} size="lg">
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
{t('recommendations.start') || 'Start Recommendation Wizard'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Platform Selector */}
|
||||
<PlatformSelector
|
||||
selectedPlatform={selectedPlatform}
|
||||
onPlatformSelect={handlePlatformSelect}
|
||||
isLocked={selectedPackages.length > 0}
|
||||
lockedMessage="Clear your selection to switch platforms"
|
||||
/>
|
||||
|
||||
{/* Package Browser */}
|
||||
@@ -107,10 +257,27 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
|
||||
<ScriptPreview
|
||||
generatedScript={generatedScript}
|
||||
selectedPackages={selectedPackages}
|
||||
selectedPlatform={selectedPlatform}
|
||||
selectedPlatform={selectedPlatform ||
|
||||
availablePlatforms.find(p => p.id === generatedScript.platform) ||
|
||||
{
|
||||
id: generatedScript.platform,
|
||||
name: generatedScript.platform.charAt(0).toUpperCase() + generatedScript.platform.slice(1),
|
||||
description: '',
|
||||
icon: '',
|
||||
packageManager: ''
|
||||
}
|
||||
}
|
||||
onClose={handleCloseScriptPreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Onboarding Modal */}
|
||||
<OnboardingModal
|
||||
isOpen={showOnboarding}
|
||||
onClose={() => setShowOnboarding(false)}
|
||||
onComplete={handleOnboardingComplete}
|
||||
detectedOS={detectedOS || 'unknown'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
Code,
|
||||
Palette,
|
||||
Film,
|
||||
Cpu,
|
||||
Gamepad2,
|
||||
CheckSquare,
|
||||
GraduationCap,
|
||||
LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { UserCategory } from '@/types/recommendations';
|
||||
|
||||
export const CATEGORY_ICONS: Record<UserCategory, LucideIcon> = {
|
||||
development: Code,
|
||||
design: Palette,
|
||||
multimedia: Film,
|
||||
"system-tools": Cpu,
|
||||
gaming: Gamepad2,
|
||||
productivity: CheckSquare,
|
||||
education: GraduationCap,
|
||||
};
|
||||
@@ -10,7 +10,12 @@ const translations = {
|
||||
title: "RepoHub - Cross-Platform Package Manager",
|
||||
subtitle: "Cross-Platform Package Manager",
|
||||
description: "Simplify software installation across Linux, Windows, and macOS with official repositories",
|
||||
close: "Close"
|
||||
close: "Close",
|
||||
next: "Next",
|
||||
back: "Back",
|
||||
select_all: "Select All",
|
||||
deselect_all: "Deselect All",
|
||||
finish: "Finish"
|
||||
},
|
||||
platform: {
|
||||
select: "Select Your Platform",
|
||||
@@ -106,6 +111,91 @@ const translations = {
|
||||
success_note1: "Payment confirmation will be sent to your email if provided.",
|
||||
success_note2: "You can close this page and return to RepoHub.",
|
||||
back_to_site: "Back to RepoHub"
|
||||
},
|
||||
onboarding: {
|
||||
title: "Welcome to RepoHub!",
|
||||
subtitle: "Let's personalize your package recommendations",
|
||||
complete: "Get My Recommendations",
|
||||
step1: {
|
||||
title: "What do you want to use your computer for?",
|
||||
description: "Select the categories that match your needs"
|
||||
},
|
||||
step2: {
|
||||
title: "Select your operating system",
|
||||
description: "We'll recommend compatible packages for your platform",
|
||||
detected: "✓ Detected: {os}"
|
||||
},
|
||||
step3: {
|
||||
title: "What's your experience level?",
|
||||
description: "This helps us recommend appropriate tools",
|
||||
levels: {
|
||||
beginner: {
|
||||
name: "Beginner",
|
||||
description: "New to software installation and package management"
|
||||
},
|
||||
intermediate: {
|
||||
name: "Intermediate",
|
||||
description: "Comfortable with basic command-line operations"
|
||||
},
|
||||
advanced: {
|
||||
name: "Advanced",
|
||||
description: "Experienced with system administration and package management"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
categories: {
|
||||
development: {
|
||||
name: "Development",
|
||||
description: "Code editors, compilers, and development tools"
|
||||
},
|
||||
design: {
|
||||
name: "Design",
|
||||
description: "Graphics, UI/UX, and creative software"
|
||||
},
|
||||
multimedia: {
|
||||
name: "Multimedia",
|
||||
description: "Audio, video editing, and media tools"
|
||||
},
|
||||
"system-tools": {
|
||||
name: "System Tools",
|
||||
description: "System administration and utilities"
|
||||
},
|
||||
gaming: {
|
||||
name: "Gaming",
|
||||
description: "Gaming platforms and related tools"
|
||||
},
|
||||
productivity: {
|
||||
name: "Productivity",
|
||||
description: "Office, note-taking, and productivity apps"
|
||||
},
|
||||
education: {
|
||||
name: "Education",
|
||||
description: "Educational and scientific software"
|
||||
}
|
||||
},
|
||||
recommendations: {
|
||||
title: "Recommended for You",
|
||||
subtitle: "Personalized package recommendations based on your profile",
|
||||
refresh: "Refresh Recommendations",
|
||||
customize: "Customize Preferences",
|
||||
no_recommendations: "No recommendations available. Please update your preferences.",
|
||||
loading: "Finding the best packages for you...",
|
||||
score: "Match Score",
|
||||
preset_badge: "Essential",
|
||||
view_details: "View Details",
|
||||
add_to_selection: "Add to Selection",
|
||||
reason: "Why recommended:",
|
||||
based_on: "Based on your interests in:",
|
||||
packages: "packages",
|
||||
empty_title: "Get Personalized Recommendations",
|
||||
empty_description: "Tell us about your role and platform to get a curated list of essential packages.",
|
||||
start: "Start Recommendation Wizard",
|
||||
sort: {
|
||||
recommended: "Best Match",
|
||||
popular: "Popular",
|
||||
preset: "Essential"
|
||||
}
|
||||
}
|
||||
},
|
||||
tr: {
|
||||
@@ -113,7 +203,12 @@ const translations = {
|
||||
title: "RepoHub - Çok Platformlu Paket Yöneticisi",
|
||||
subtitle: "Çok Platformlu Paket Yöneticisi",
|
||||
description: "Linux, Windows ve macOS'te resmi depoları kullanarak yazılım kurulumunu basitleştirin",
|
||||
close: "Kapat"
|
||||
close: "Kapat",
|
||||
next: "İleri",
|
||||
back: "Geri",
|
||||
select_all: "Tümünü Seç",
|
||||
deselect_all: "Seçimi Kaldır",
|
||||
finish: "Bitir"
|
||||
},
|
||||
platform: {
|
||||
select: "Platformunuzu Seçin",
|
||||
@@ -209,6 +304,91 @@ const translations = {
|
||||
success_note1: "Ödeme onayı sağlandıysa e-postanıza gönderilecektir.",
|
||||
success_note2: "Bu sayfayı kapatabilir ve RepoHub'a dönebilirsiniz.",
|
||||
back_to_site: "RepoHub'a Geri Dön"
|
||||
},
|
||||
onboarding: {
|
||||
title: "RepoHub'a Hoş Geldiniz!",
|
||||
subtitle: "Paket önerilerinizi kişiselleştirelim",
|
||||
complete: "Önerilerimi Getir",
|
||||
step1: {
|
||||
title: "Bilgisayarınızı ne için kullanmak istiyorsunuz?",
|
||||
description: "İhtiyaçlarınıza uygun kategorileri seçin"
|
||||
},
|
||||
step2: {
|
||||
title: "İşletim sisteminizi seçin",
|
||||
description: "Platformunuzla uyumlu paketler önereceğiz",
|
||||
detected: "✓ Tespit edildi: {os}"
|
||||
},
|
||||
step3: {
|
||||
title: "Deneyim seviyeniz nedir?",
|
||||
description: "Bu, size uygun araçları önermemize yardımcı olur",
|
||||
levels: {
|
||||
beginner: {
|
||||
name: "Başlangıç",
|
||||
description: "Yazılım kurulumu ve paket yönetimine yeni"
|
||||
},
|
||||
intermediate: {
|
||||
name: "Orta",
|
||||
description: "Temel komut satırı işlemlerinde rahat"
|
||||
},
|
||||
advanced: {
|
||||
name: "İleri",
|
||||
description: "Sistem yönetimi ve paket yönetiminde deneyimli"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
categories: {
|
||||
development: {
|
||||
name: "Geliştirme",
|
||||
description: "Kod editörleri, derleyiciler ve geliştirme araçları"
|
||||
},
|
||||
design: {
|
||||
name: "Tasarım",
|
||||
description: "Grafik, UI/UX ve yaratıcı yazılımlar"
|
||||
},
|
||||
multimedia: {
|
||||
name: "Multimedya",
|
||||
description: "Ses, video düzenleme ve medya araçları"
|
||||
},
|
||||
"system-tools": {
|
||||
name: "Sistem Araçları",
|
||||
description: "Sistem yönetimi ve yardımcı programlar"
|
||||
},
|
||||
gaming: {
|
||||
name: "Oyun",
|
||||
description: "Oyun platformları ve ilgili araçlar"
|
||||
},
|
||||
productivity: {
|
||||
name: "Üretkenlik",
|
||||
description: "Ofis, not alma ve üretkenlik uygulamaları"
|
||||
},
|
||||
education: {
|
||||
name: "Eğitim",
|
||||
description: "Eğitim ve bilimsel yazılımlar"
|
||||
}
|
||||
},
|
||||
recommendations: {
|
||||
title: "Size Özel Öneriler",
|
||||
subtitle: "Profilinize göre kişiselleştirilmiş paket önerileri",
|
||||
refresh: "Önerileri Yenile",
|
||||
customize: "Tercihleri Özelleştir",
|
||||
no_recommendations: "Öneri mevcut değil. Lütfen tercihlerinizi güncelleyin.",
|
||||
loading: "Sizin için en iyi paketleri buluyoruz...",
|
||||
score: "Eşleşme Skoru",
|
||||
preset_badge: "Temel",
|
||||
view_details: "Detayları Gör",
|
||||
add_to_selection: "Seçime Ekle",
|
||||
reason: "Neden önerildi:",
|
||||
based_on: "İlgi alanlarınıza göre:",
|
||||
packages: "paket",
|
||||
empty_title: "Kişiselleştirilmiş Öneriler Alın",
|
||||
empty_description: "Size temel paketlerden oluşan bir liste sunmamız için kategorileri ve platformunuzu belirtin.",
|
||||
start: "Öneri Sihirbazını Başlat",
|
||||
sort: {
|
||||
recommended: "En Uygun",
|
||||
popular: "Popüler",
|
||||
preset: "Temel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,7 +418,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
if (browserLang.startsWith('tr')) {
|
||||
return 'tr'
|
||||
}
|
||||
|
||||
|
||||
return 'en'
|
||||
}
|
||||
|
||||
@@ -257,7 +437,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
const keys = key.split('.')
|
||||
let value: any = translations[locale]
|
||||
|
||||
|
||||
for (const k of keys) {
|
||||
value = value?.[k]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,785 @@
|
||||
import { UserCategory } from "@/types/recommendations";
|
||||
|
||||
/**
|
||||
* Curated package recommendations for each platform and category
|
||||
* Simple structure: Platform → Category → Package names
|
||||
*
|
||||
* Edit this file to add/remove packages for each platform/category combination
|
||||
*/
|
||||
|
||||
type PlatformId = "windows" | "macos" | "ubuntu" | "debian" | "arch" | "fedora";
|
||||
|
||||
export const PACKAGE_PRESETS: Record<PlatformId, Record<UserCategory, string[]>> = {
|
||||
"windows": {
|
||||
"development": [
|
||||
"Git.Git",
|
||||
"Microsoft.VisualStudioCode",
|
||||
"Docker.DockerDesktop",
|
||||
"Postman.Postman",
|
||||
"Microsoft.WindowsTerminal",
|
||||
"Microsoft.PowerShell",
|
||||
"Notepad++.Notepad++",
|
||||
"WinSCP.WinSCP",
|
||||
"PuTTY.PuTTY",
|
||||
"WinMerge.WinMerge",
|
||||
"EclipseFoundation.Eclipse",
|
||||
"Anysphere.Cursor"
|
||||
],
|
||||
"design": [
|
||||
"GIMP.GIMP",
|
||||
"Inkscape.Inkscape",
|
||||
"BlenderFoundation.Blender",
|
||||
"KDE.Krita",
|
||||
"IrfanSkiljan.IrfanView",
|
||||
"XnSoft.XnViewMP",
|
||||
"FastStone.Viewer",
|
||||
"Greenshot.Greenshot",
|
||||
"ShareX.ShareX"
|
||||
],
|
||||
"multimedia": [
|
||||
"VideoLAN.VLC",
|
||||
"Audacity.Audacity",
|
||||
"OBSProject.OBSStudio",
|
||||
"Apple.iTunes",
|
||||
"AIMP.AIMP",
|
||||
"PeterPawlowski.foobar2000",
|
||||
"Winamp.Winamp",
|
||||
"GOMLab.GOMPlayer",
|
||||
"Spotify.Spotify",
|
||||
"VentisMedia.MediaMonkey",
|
||||
"HandBrake.HandBrake"
|
||||
],
|
||||
"system-tools": [
|
||||
"7zip.7zip",
|
||||
"Microsoft.PowerToys",
|
||||
"voidtools.Everything",
|
||||
"RARLab.WinRAR",
|
||||
"DominikReichl.KeePass",
|
||||
"TeamViewer.TeamViewer",
|
||||
"RealVNC.VNCViewer",
|
||||
"CodeSector.TeraCopy",
|
||||
"LIGHTNINGUK.ImgBurn",
|
||||
"WinDirStat.WinDirStat",
|
||||
"AntibodySoftware.WizTree",
|
||||
"Glarysoft.GlaryUtilities",
|
||||
"ChristianKindahl.InfraRecorder",
|
||||
"Open-Shell.Open-Shell-Menu",
|
||||
"Piriform.CCleaner",
|
||||
"Rufus.Rufus",
|
||||
"BleachBit.BleachBit",
|
||||
"NVAccess.NVDA",
|
||||
"Malwarebytes.Malwarebytes",
|
||||
"SUPERAntiSpyware.SUPERAntiSpyware",
|
||||
"qBittorrent.qBittorrent"
|
||||
],
|
||||
"gaming": [
|
||||
"Valve.Steam",
|
||||
"Discord.Discord",
|
||||
"EpicGames.EpicGamesLauncher",
|
||||
"GOG.Galaxy"
|
||||
],
|
||||
"productivity": [
|
||||
"Notion.Notion",
|
||||
"Obsidian.Obsidian",
|
||||
"SlackTechnologies.Slack",
|
||||
"Google.Chrome",
|
||||
"Mozilla.Firefox",
|
||||
"Microsoft.Edge",
|
||||
"Brave.Brave",
|
||||
"Opera.Opera",
|
||||
"Zoom.Zoom",
|
||||
"Microsoft.Teams",
|
||||
"Pidgin.Pidgin",
|
||||
"Mozilla.Thunderbird",
|
||||
"Foxit.FoxitReader",
|
||||
"TheDocumentFoundation.LibreOffice",
|
||||
"SumatraPDF.SumatraPDF",
|
||||
"AcroSoftware.CutePDFWriter",
|
||||
"Apache.OpenOffice",
|
||||
"Dropbox.Dropbox",
|
||||
"Microsoft.OneDrive",
|
||||
"Google.EarthPro",
|
||||
"Evernote.Evernote"
|
||||
],
|
||||
"education": [
|
||||
"Anki.Anki"
|
||||
]
|
||||
},
|
||||
|
||||
"macos": {
|
||||
"development": [
|
||||
"git",
|
||||
"visual-studio-code",
|
||||
"cursor",
|
||||
"node",
|
||||
"postman",
|
||||
"iterm2",
|
||||
"warp",
|
||||
"sublime-text",
|
||||
"cyberduck",
|
||||
"meld",
|
||||
"dotnet-sdk",
|
||||
"temurin"
|
||||
],
|
||||
"design": [
|
||||
"gimp",
|
||||
"inkscape",
|
||||
"blender",
|
||||
"krita",
|
||||
"xnviewmp"
|
||||
],
|
||||
"multimedia": [
|
||||
"vlc",
|
||||
"audacity",
|
||||
"obs",
|
||||
"spotify",
|
||||
"handbrake",
|
||||
"iina",
|
||||
"foobar2000"
|
||||
],
|
||||
"system-tools": [
|
||||
"rectangle",
|
||||
"the-unarchiver",
|
||||
"keka",
|
||||
"appcleaner",
|
||||
"keepassxc",
|
||||
"teamviewer",
|
||||
"anydesk",
|
||||
"malwarebytes",
|
||||
"raycast",
|
||||
"alfred",
|
||||
"qbittorrent"
|
||||
],
|
||||
"gaming": [
|
||||
"steam",
|
||||
"discord",
|
||||
"epic-games"
|
||||
],
|
||||
"productivity": [
|
||||
"notion",
|
||||
"obsidian",
|
||||
"slack",
|
||||
"zoom",
|
||||
"microsoft-teams",
|
||||
"thunderbird",
|
||||
"google-chrome",
|
||||
"firefox",
|
||||
"microsoft-edge",
|
||||
"brave-browser",
|
||||
"opera",
|
||||
"libreoffice",
|
||||
"foxitreader",
|
||||
"adobe-acrobat-reader",
|
||||
"dropbox",
|
||||
"google-drive",
|
||||
"onedrive"
|
||||
],
|
||||
"education": [
|
||||
"anki",
|
||||
"zotero"
|
||||
]
|
||||
},
|
||||
|
||||
"ubuntu": {
|
||||
"development": [
|
||||
"git",
|
||||
"curl",
|
||||
"wget",
|
||||
"nodejs",
|
||||
"npm",
|
||||
"python3-pip",
|
||||
"docker.io",
|
||||
"dotnet-sdk-8.0"
|
||||
],
|
||||
"design": [
|
||||
"gimp",
|
||||
"inkscape",
|
||||
"blender",
|
||||
"krita",
|
||||
"darktable"
|
||||
],
|
||||
"multimedia": [
|
||||
"vlc",
|
||||
"audacity",
|
||||
"obs-studio",
|
||||
"ffmpeg",
|
||||
"mpv",
|
||||
"handbrake",
|
||||
"kdenlive"
|
||||
],
|
||||
"system-tools": [
|
||||
"neofetch",
|
||||
"timeshift",
|
||||
"stacer",
|
||||
"keepassxc",
|
||||
"synaptic"
|
||||
],
|
||||
"gaming": [
|
||||
"steam",
|
||||
"lutris",
|
||||
"mangohud"
|
||||
],
|
||||
"productivity": [
|
||||
"libreoffice",
|
||||
"chromium-browser",
|
||||
"evolution",
|
||||
"focuswriter"
|
||||
],
|
||||
"education": [
|
||||
"anki"
|
||||
]
|
||||
},
|
||||
"debian": {
|
||||
"development": [
|
||||
"git",
|
||||
"build-essential",
|
||||
"curl",
|
||||
"wget",
|
||||
"nodejs",
|
||||
"npm",
|
||||
"python3",
|
||||
"python3-pip",
|
||||
"docker.io"
|
||||
],
|
||||
"design": [
|
||||
"gimp",
|
||||
"inkscape",
|
||||
"blender",
|
||||
"krita"
|
||||
],
|
||||
"multimedia": [
|
||||
"vlc",
|
||||
"audacity",
|
||||
"obs-studio",
|
||||
"ffmpeg",
|
||||
"handbrake"
|
||||
],
|
||||
"system-tools": [
|
||||
"htop",
|
||||
"fastfetch",
|
||||
"tmux",
|
||||
"zsh",
|
||||
"gparted",
|
||||
"timeshift",
|
||||
"keepassxc"
|
||||
],
|
||||
"gaming": [
|
||||
"steam",
|
||||
"lutris",
|
||||
"gamemode",
|
||||
"mangohud"
|
||||
],
|
||||
"productivity": [
|
||||
"libreoffice",
|
||||
"thunderbird",
|
||||
"firefox-esr",
|
||||
"chromium"
|
||||
],
|
||||
"education": [
|
||||
]
|
||||
},
|
||||
"arch": {
|
||||
"development": [
|
||||
"git",
|
||||
"base-devel",
|
||||
"code",
|
||||
"nodejs",
|
||||
"npm",
|
||||
"python-pip",
|
||||
"jdk17-openjdk"
|
||||
],
|
||||
"design": [
|
||||
"gimp",
|
||||
"inkscape",
|
||||
"blender",
|
||||
"krita"
|
||||
],
|
||||
"multimedia": [
|
||||
"vlc",
|
||||
"audacity",
|
||||
"obs-studio",
|
||||
"ffmpeg",
|
||||
"mpv",
|
||||
"handbrake"
|
||||
],
|
||||
"system-tools": [
|
||||
"htop",
|
||||
"fastfetch",
|
||||
"tldr",
|
||||
"tmux",
|
||||
"zsh",
|
||||
"gparted",
|
||||
"timeshift",
|
||||
"keepassxc",
|
||||
"reflector",
|
||||
"pacman-contrib"
|
||||
],
|
||||
"gaming": [
|
||||
"steam",
|
||||
"lutris",
|
||||
"gamemode",
|
||||
"mangohud",
|
||||
"discord",
|
||||
"wine",
|
||||
"winetricks"
|
||||
],
|
||||
"productivity": [
|
||||
"libreoffice-fresh",
|
||||
"thunderbird",
|
||||
"firefox",
|
||||
"chromium",
|
||||
"obsidian"
|
||||
],
|
||||
"education": [
|
||||
"anki"
|
||||
]
|
||||
},
|
||||
"fedora": {
|
||||
"development": [
|
||||
"git",
|
||||
"curl",
|
||||
"nodejs",
|
||||
"python3",
|
||||
"python3-pip",
|
||||
"java-17-openjdk-devel",
|
||||
"dotnet-sdk-8.0"
|
||||
],
|
||||
"design": [
|
||||
"gimp",
|
||||
"inkscape",
|
||||
"blender",
|
||||
"krita"
|
||||
],
|
||||
"multimedia": [
|
||||
"vlc",
|
||||
"audacity",
|
||||
"obs-studio",
|
||||
"mpv"
|
||||
],
|
||||
"system-tools": [
|
||||
"htop",
|
||||
"fastfetch",
|
||||
"tldr",
|
||||
"tmux",
|
||||
"zsh",
|
||||
"gparted",
|
||||
"keepassxc",
|
||||
"dnf-plugins-core"
|
||||
],
|
||||
"gaming": [
|
||||
"lutris",
|
||||
"gamemode",
|
||||
"mangohud"
|
||||
],
|
||||
"productivity": [
|
||||
"libreoffice",
|
||||
"thunderbird",
|
||||
"firefox",
|
||||
"chromium"
|
||||
],
|
||||
"education": []
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get package names for a specific platform and categories
|
||||
*/
|
||||
export function getPackagesForPlatform(
|
||||
platformId: string,
|
||||
categories: UserCategory[]
|
||||
): string[] {
|
||||
const platform = PACKAGE_PRESETS[platformId as PlatformId];
|
||||
if (!platform) return [];
|
||||
|
||||
const packages: string[] = [];
|
||||
categories.forEach(category => {
|
||||
const categoryPackages = platform[category] || [];
|
||||
packages.push(...categoryPackages);
|
||||
});
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get package names with their categories for a specific platform
|
||||
*/
|
||||
export function getPackagesWithCategories(
|
||||
platformId: string,
|
||||
categories: UserCategory[]
|
||||
): { name: string; category: UserCategory }[] {
|
||||
const platform = PACKAGE_PRESETS[platformId as PlatformId];
|
||||
if (!platform) return [];
|
||||
|
||||
const packages: { name: string; category: UserCategory }[] = [];
|
||||
categories.forEach((category) => {
|
||||
const categoryPackages = platform[category] || [];
|
||||
categoryPackages.forEach((name) => {
|
||||
packages.push({ name, category });
|
||||
});
|
||||
});
|
||||
|
||||
return packages;
|
||||
}
|
||||
|
||||
export const PRESET_DESCRIPTIONS: Record<string, string> = {
|
||||
// Development
|
||||
"git": "Distributed version control system",
|
||||
"Git.Git": "Distributed version control system",
|
||||
"curl": "Command line tool for transferring data with URLs",
|
||||
"wget": "Network utility to retrieve files from the Web",
|
||||
"nodejs": "JavaScript runtime built on Chrome's V8 JavaScript engine",
|
||||
"npm": "Package manager for the Node.js JavaScript platform",
|
||||
"python3": "Interpreted, interactive, object-oriented programming language",
|
||||
"python3-pip": "Python package installer",
|
||||
"python-pip": "Python package installer",
|
||||
"docker.io": "Linux container runtime",
|
||||
"Docker.DockerDesktop": "Build, Share, and Run container applications",
|
||||
"dotnet-sdk-8.0": ".NET 8.0 Software Development Kit",
|
||||
"dotnet-sdk": ".NET Software Development Kit",
|
||||
"Microsoft.VisualStudioCode": "Code editing. Redefined.",
|
||||
"visual-studio-code": "Code editing. Redefined.",
|
||||
"code": "The Open Source build of Visual Studio Code",
|
||||
"Postman.Postman": "Platform for building and using APIs",
|
||||
"postman": "Platform for building and using APIs",
|
||||
"Microsoft.WindowsTerminal": "Modern terminal application for Windows",
|
||||
"iterm2": "Terminal emulator for macOS",
|
||||
"warp": "AI-powered terminal",
|
||||
"sublime-text": "Sophisticated text editor for code, markup and prose",
|
||||
"build-essential": "Informational list of build-essential packages",
|
||||
"base-devel": "Basic tools to build Arch Linux packages",
|
||||
"java-17-openjdk-devel": "OpenJDK 17 Development Kit",
|
||||
"jdk17-openjdk": "OpenJDK 17 Development Kit",
|
||||
"temurin": "Eclipse Temurin Java SE binaries",
|
||||
|
||||
// Design
|
||||
"gimp": "GNU Image Manipulation Program",
|
||||
"GIMP.GIMP": "GNU Image Manipulation Program",
|
||||
"inkscape": "Vector-based drawing program",
|
||||
"Inkscape.Inkscape": "Vector-based drawing program",
|
||||
"blender": "Very fast and versatile 3D modeller/renderer",
|
||||
"BlenderFoundation.Blender": "Very fast and versatile 3D modeller/renderer",
|
||||
"krita": "Digital painting and sketching application",
|
||||
"KDE.Krita": "Digital painting and sketching application",
|
||||
"darktable": "Virtual lighttable and darkroom for photographers",
|
||||
"xnviewmp": "Image viewer, browser and converter",
|
||||
"XnSoft.XnViewMP": "Image viewer, browser and converter",
|
||||
"IrfanSkiljan.IrfanView": "Fast and compact image viewer",
|
||||
"FastStone.Viewer": "Image viewer, converter and editor",
|
||||
"ShareX.ShareX": "Screen capture, file sharing and productivity tool",
|
||||
"Greenshot.Greenshot": "Lightweight screenshot software tool",
|
||||
|
||||
// Multimedia
|
||||
"vlc": "Multimedia player and streamer",
|
||||
"VideoLAN.VLC": "Multimedia player and streamer",
|
||||
"audacity": "Multi-track audio editor and recorder",
|
||||
"Audacity.Audacity": "Multi-track audio editor and recorder",
|
||||
"obs-studio": "Software for live streaming and screen recording",
|
||||
"obs": "Software for live streaming and screen recording",
|
||||
"OBSProject.OBSStudio": "Software for live streaming and screen recording",
|
||||
"ffmpeg": "Tools for transcoding, streaming and playing of multimedia files",
|
||||
"mpv": "Video player based on MPlayer/mplayer2",
|
||||
"handbrake": "Open Source Video Transcoder",
|
||||
"HandBrake.HandBrake": "Open Source Video Transcoder",
|
||||
"kdenlive": "Non-linear video editor",
|
||||
"spotify": "Music streaming service",
|
||||
"Spotify.Spotify": "Music streaming service",
|
||||
"Apple.iTunes": "Media player, media library, and mobile device management utility",
|
||||
"foobar2000": "Advanced audio player",
|
||||
"PeterPawlowski.foobar2000": "Advanced audio player",
|
||||
"Winamp.Winamp": "Media player for Windows",
|
||||
"AIMP.AIMP": "Free audio player",
|
||||
"iina": "The modern video player for macOS",
|
||||
|
||||
// System Tools
|
||||
"htop": "Interactive process viewer",
|
||||
"fastfetch": "Like neofetch, but much faster",
|
||||
"neofetch": "Command-line system information tool",
|
||||
"tmux": "Terminal multiplexer",
|
||||
"zsh": "Shell with lots of features",
|
||||
"gparted": "GNOME Partition Editor",
|
||||
"timeshift": "System restore utility",
|
||||
"stacer": "Linux System Optimizer and Monitoring",
|
||||
"keepassxc": "Cross Platform Password Manager",
|
||||
"DominikReichl.KeePass": "Password manager",
|
||||
"synaptic": "Graphical package manager",
|
||||
"7zip.7zip": "File archiver with a high compression ratio",
|
||||
"Microsoft.PowerToys": "Set of system utilities for power users",
|
||||
"voidtools.Everything": "Locate files and folders by name instantly",
|
||||
"RARLab.WinRAR": "Powerful archiver and archive manager",
|
||||
"TeamViewer.TeamViewer": "Remote control and meeting software",
|
||||
"teamviewer": "Remote control and meeting software",
|
||||
"RealVNC.VNCViewer": "Remote control software",
|
||||
"rufus": "Create bootable USB drives the easy way",
|
||||
"Rufus.Rufus": "Create bootable USB drives the easy way",
|
||||
"bleachbit": "Delete unnecessary files from the system",
|
||||
"BleachBit.BleachBit": "Delete unnecessary files from the system",
|
||||
"rectangle": "Move and resize windows in macOS using keyboard shortcuts",
|
||||
"the-unarchiver": "Unpack any archive file",
|
||||
"keka": "The macOS file archiver",
|
||||
"appcleaner": "Uninstall unwanted apps",
|
||||
"raycast": "Productivity tool that replaces Spotlight",
|
||||
"alfred": "Productivity app for macOS",
|
||||
"qbittorrent": "BitTorrent client",
|
||||
"qBittorrent.qBittorrent": "BitTorrent client",
|
||||
|
||||
// Gaming
|
||||
"steam": "Digital distribution platform for video games",
|
||||
"Valve.Steam": "Digital distribution platform for video games",
|
||||
"lutris": "Open Source gaming platform for Linux",
|
||||
"gamemode": "Optimize Linux system performance for gaming",
|
||||
"mangohud": "Vulkan and OpenGL overlay for monitoring FPS, temperatures, CPU/GPU load",
|
||||
"discord": "All-in-one voice and text chat for gamers",
|
||||
"Discord.Discord": "All-in-one voice and text chat for gamers",
|
||||
"wine": "Run Windows applications on Linux",
|
||||
"winetricks": "Workarounds for problems in Wine",
|
||||
"EpicGames.EpicGamesLauncher": "Epic Games Store",
|
||||
"epic-games": "Epic Games Store",
|
||||
"GOG.Galaxy": "GOG Galaxy Client",
|
||||
|
||||
// Productivity
|
||||
"libreoffice": "Office productivity suite",
|
||||
"TheDocumentFoundation.LibreOffice": "Office productivity suite",
|
||||
"libreoffice-fresh": "Office productivity suite (fresh version)",
|
||||
"thunderbird": "Email, newsgroup and chat client",
|
||||
"Mozilla.Thunderbird": "Email, newsgroup and chat client",
|
||||
"firefox": "Mozilla Firefox web browser",
|
||||
"Mozilla.Firefox": "Mozilla Firefox web browser",
|
||||
"chromium": "Web browser",
|
||||
"chromium-browser": "Web browser",
|
||||
"Google.Chrome": "Web browser",
|
||||
"google-chrome": "Web browser",
|
||||
"Microsoft.Edge": "Web browser",
|
||||
"microsoft-edge": "Web browser",
|
||||
"Brave.Brave": "Secure, fast, and private web browser",
|
||||
"brave-browser": "Secure, fast, and private web browser",
|
||||
"Opera.Opera": "Web browser",
|
||||
"opera": "Web browser",
|
||||
"zoom": "Video conferencing",
|
||||
"Zoom.Zoom": "Video conferencing",
|
||||
"microsoft-teams": "Communication and collaboration platform",
|
||||
"Microsoft.Teams": "Communication and collaboration platform",
|
||||
"slack": "Collaboration hub for work",
|
||||
"SlackTechnologies.Slack": "Collaboration hub for work",
|
||||
"notion": "All-in-one workspace",
|
||||
"Notion.Notion": "All-in-one workspace",
|
||||
"obsidian": "Knowledge base that works on local Markdown files",
|
||||
"Obsidian.Obsidian": "Knowledge base that works on local Markdown files",
|
||||
"foxitreader": "PDF Reader",
|
||||
"Foxit.FoxitReader": "PDF Reader",
|
||||
"adobe-acrobat-reader": "PDF Reader",
|
||||
"dropbox": "File hosting service",
|
||||
"Dropbox.Dropbox": "File hosting service",
|
||||
"onedrive": "File hosting service",
|
||||
"Microsoft.OneDrive": "File hosting service",
|
||||
"google-drive": "File hosting service",
|
||||
"evernote": "Note taking app",
|
||||
"Evernote.Evernote": "Note taking app",
|
||||
"evolution": "Groupware suite",
|
||||
"focuswriter": "Distraction-free word processor",
|
||||
|
||||
// Education
|
||||
"anki": "Powerful, intelligent flash cards",
|
||||
"Anki.Anki": "Powerful, intelligent flash cards",
|
||||
"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]) {
|
||||
return { description: PRESET_DESCRIPTIONS[name] };
|
||||
}
|
||||
|
||||
// Try case insensitive
|
||||
const lowerName = name.toLowerCase();
|
||||
const key = Object.keys(PRESET_DESCRIPTIONS).find(k => k.toLowerCase() === lowerName);
|
||||
if (key) {
|
||||
return { description: PRESET_DESCRIPTIONS[key] };
|
||||
}
|
||||
|
||||
return { description: "Recommended package" };
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
UserProfile,
|
||||
UserCategory,
|
||||
} from "@/types/recommendations";
|
||||
|
||||
const STORAGE_KEY = "repohub_user_profile";
|
||||
|
||||
/**
|
||||
* Detect user's operating system from browser
|
||||
*/
|
||||
function detectOS(): string {
|
||||
if (typeof window === "undefined") {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||
const platform = window.navigator.platform.toLowerCase();
|
||||
|
||||
// Windows
|
||||
if (userAgent.indexOf("win") !== -1 || platform.indexOf("win") !== -1) {
|
||||
return "windows";
|
||||
}
|
||||
|
||||
// macOS
|
||||
if (
|
||||
userAgent.indexOf("mac") !== -1 ||
|
||||
platform.indexOf("mac") !== -1 ||
|
||||
userAgent.indexOf("darwin") !== -1
|
||||
) {
|
||||
return "macos";
|
||||
}
|
||||
|
||||
// Linux distros
|
||||
if (userAgent.indexOf("linux") !== -1 || platform.indexOf("linux") !== -1) {
|
||||
// Try to detect specific distro from user agent (rare but possible)
|
||||
if (userAgent.indexOf("ubuntu") !== -1) {
|
||||
return "ubuntu";
|
||||
}
|
||||
if (userAgent.indexOf("fedora") !== -1) {
|
||||
return "fedora";
|
||||
}
|
||||
if (userAgent.indexOf("arch") !== -1) {
|
||||
return "arch";
|
||||
}
|
||||
if (userAgent.indexOf("debian") !== -1) {
|
||||
return "debian";
|
||||
}
|
||||
|
||||
// Default to Ubuntu for generic Linux
|
||||
return "ubuntu";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const CURRENT_PROFILE_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Get default user profile
|
||||
*/
|
||||
function getDefaultProfile(): UserProfile {
|
||||
return {
|
||||
version: CURRENT_PROFILE_VERSION,
|
||||
categories: [],
|
||||
detectedOS: detectOS(),
|
||||
selectedOS: undefined,
|
||||
hasCompletedOnboarding: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing user recommendation profile in localStorage
|
||||
*/
|
||||
export function useRecommendationProfile() {
|
||||
const [profile, setProfile] = useState<UserProfile>(getDefaultProfile());
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load profile from localStorage on mount
|
||||
useEffect(() => {
|
||||
// We intentionally do NOT load from localStorage anymore to reset on refresh
|
||||
// as requested by user preference change.
|
||||
|
||||
// Initialize with default profile (detects OS)
|
||||
const defaultProfile = getDefaultProfile();
|
||||
setProfile(defaultProfile);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// Save profile to state only (session persistence)
|
||||
const saveProfile = useCallback(
|
||||
(newProfile: Partial<UserProfile>) => {
|
||||
try {
|
||||
const updated: UserProfile = {
|
||||
...profile,
|
||||
...newProfile,
|
||||
version: CURRENT_PROFILE_VERSION,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log("💾 Saving profile (Session only):", updated);
|
||||
|
||||
setProfile(updated);
|
||||
// localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); // Disabled persistence
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("❌ Error saving user profile:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[profile]
|
||||
);
|
||||
|
||||
// Update categories
|
||||
const updateCategories = useCallback(
|
||||
(categories: UserCategory[]) => {
|
||||
return saveProfile({ categories });
|
||||
},
|
||||
[saveProfile]
|
||||
);
|
||||
|
||||
// Update selected OS (manual override)
|
||||
const updateSelectedOS = useCallback(
|
||||
(os: string) => {
|
||||
return saveProfile({ selectedOS: os });
|
||||
},
|
||||
[saveProfile]
|
||||
);
|
||||
|
||||
|
||||
|
||||
// Mark onboarding as completed
|
||||
const completeOnboarding = useCallback(() => {
|
||||
return saveProfile({ hasCompletedOnboarding: true });
|
||||
}, [saveProfile]);
|
||||
|
||||
// Reset profile
|
||||
const resetProfile = useCallback(() => {
|
||||
try {
|
||||
const defaultProfile = getDefaultProfile();
|
||||
setProfile(defaultProfile);
|
||||
// localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultProfile)); // Disabled persistence
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error resetting user profile:", error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get effective OS (selectedOS or detectedOS)
|
||||
const getEffectiveOS = useCallback((): string => {
|
||||
return profile.selectedOS || profile.detectedOS || "ubuntu";
|
||||
}, [profile]);
|
||||
|
||||
// Check if profile is complete enough for recommendations
|
||||
const isProfileComplete = useCallback((): boolean => {
|
||||
return profile.categories.length > 0 && getEffectiveOS() !== "unknown";
|
||||
}, [profile, getEffectiveOS]);
|
||||
|
||||
return {
|
||||
profile,
|
||||
isLoading,
|
||||
saveProfile,
|
||||
updateCategories,
|
||||
updateSelectedOS,
|
||||
completeOnboarding,
|
||||
resetProfile,
|
||||
getEffectiveOS,
|
||||
isProfileComplete,
|
||||
detectedOS: profile.detectedOS,
|
||||
hasCompletedOnboarding: profile.hasCompletedOnboarding,
|
||||
};
|
||||
}
|
||||
+83
-48
@@ -1,92 +1,127 @@
|
||||
import { Platform, Package, FilterOptions } from '@/types'
|
||||
import { Platform, Package, FilterOptions } from "@/types";
|
||||
import {
|
||||
RecommendationRequest,
|
||||
RecommendationResponse,
|
||||
} from "@/types/recommendations";
|
||||
|
||||
const API_BASE_URL = (process.env.NEXT_PUBLIC_API_URL && process.env.NEXT_PUBLIC_API_URL.trim() !== '')
|
||||
? process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, '')
|
||||
: '/api'
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL &&
|
||||
process.env.NEXT_PUBLIC_API_URL.trim() !== ""
|
||||
? process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, "")
|
||||
: "/api";
|
||||
|
||||
class ApiClient {
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.statusText}`)
|
||||
throw new Error(`API request failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json()
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Platform operations
|
||||
async getPlatforms(): Promise<Platform[]> {
|
||||
return this.request<Platform[]>('/platforms')
|
||||
return this.request<Platform[]>("/platforms");
|
||||
}
|
||||
|
||||
async getPlatform(id: string): Promise<Platform | null> {
|
||||
try {
|
||||
return await this.request<Platform>(`/platforms/${id}`)
|
||||
return await this.request<Platform>(`/platforms/${id}`);
|
||||
} catch (error) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Package operations
|
||||
async getPackages(filters: FilterOptions = {}): Promise<{ packages: Package[], total: number }> {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (filters.platform_id) params.append('platform_id', filters.platform_id)
|
||||
if (filters.category_id) params.append('category_id', filters.category_id.toString())
|
||||
if (filters.type) params.append('type', filters.type)
|
||||
if (filters.repository) params.append('repository', filters.repository)
|
||||
if (filters.search) params.append('search', filters.search)
|
||||
if (filters.limit) params.append('limit', filters.limit.toString())
|
||||
if (filters.offset) params.append('offset', filters.offset.toString())
|
||||
if (filters.sort_by) params.append('sort_by', filters.sort_by)
|
||||
if (filters.sort_order) params.append('sort_order', filters.sort_order)
|
||||
async getPackages(
|
||||
filters: FilterOptions = {}
|
||||
): Promise<{ packages: Package[]; total: number }> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
return this.request<{ packages: Package[], total: number }>(`/packages${query}`)
|
||||
if (filters.platform_id) params.append("platform_id", filters.platform_id);
|
||||
if (filters.category_id)
|
||||
params.append("category_id", filters.category_id.toString());
|
||||
if (filters.type) params.append("type", filters.type);
|
||||
if (filters.repository) params.append("repository", filters.repository);
|
||||
if (filters.search) params.append("search", filters.search);
|
||||
if (filters.limit) params.append("limit", filters.limit.toString());
|
||||
if (filters.offset) params.append("offset", filters.offset.toString());
|
||||
if (filters.sort_by) params.append("sort_by", filters.sort_by);
|
||||
if (filters.sort_order) params.append("sort_order", filters.sort_order);
|
||||
|
||||
const query = params.toString() ? `?${params.toString()}` : "";
|
||||
return this.request<{ packages: Package[]; total: number }>(
|
||||
`/packages${query}`
|
||||
);
|
||||
}
|
||||
|
||||
async getPackage(id: string): Promise<Package | null> {
|
||||
try {
|
||||
return await this.request<Package>(`/packages/${id}`)
|
||||
return await this.request<Package>(`/packages/${id}`);
|
||||
} catch (error) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync operations
|
||||
async syncDebianPackages(): Promise<{ message: string, timestamp: string }> {
|
||||
return this.request<{ message: string, timestamp: string }>('/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ source: 'debian-official' }),
|
||||
})
|
||||
async syncDebianPackages(): Promise<{ message: string; timestamp: string }> {
|
||||
return this.request<{ message: string; timestamp: string }>("/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source: "debian-official" }),
|
||||
});
|
||||
}
|
||||
|
||||
async syncUbuntuPackages(): Promise<{ message: string, timestamp: string }> {
|
||||
return this.request<{ message: string, timestamp: string }>('/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ source: 'ubuntu-official' }),
|
||||
})
|
||||
async syncUbuntuPackages(): Promise<{ message: string; timestamp: string }> {
|
||||
return this.request<{ message: string; timestamp: string }>("/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source: "ubuntu-official" }),
|
||||
});
|
||||
}
|
||||
|
||||
async syncAllDebianPackages(): Promise<{ message: string, timestamp: string }> {
|
||||
return this.request<{ message: string, timestamp: string }>('/sync', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ source: 'all-official' }),
|
||||
})
|
||||
async syncAllDebianPackages(): Promise<{
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}> {
|
||||
return this.request<{ message: string; timestamp: string }>("/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source: "all-official" }),
|
||||
});
|
||||
}
|
||||
|
||||
async getSyncStatus(): Promise<{ status: string, last_sync: string | null, platforms: string[] }> {
|
||||
return this.request<{ status: string, last_sync: string | null, platforms: string[] }>('/sync')
|
||||
async getSyncStatus(): Promise<{
|
||||
status: string;
|
||||
last_sync: string | null;
|
||||
platforms: string[];
|
||||
}> {
|
||||
return this.request<{
|
||||
status: string;
|
||||
last_sync: string | null;
|
||||
platforms: string[];
|
||||
}>("/sync");
|
||||
}
|
||||
|
||||
// Recommendation operations
|
||||
async getRecommendations(
|
||||
request: RecommendationRequest
|
||||
): Promise<RecommendationResponse> {
|
||||
return this.request<RecommendationResponse>("/recommendations", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
|
||||
+10
-2
@@ -16,8 +16,16 @@ export async function middleware(request: NextRequest) {
|
||||
request.ip ||
|
||||
'CACHE_TOKEN'
|
||||
|
||||
// 50 requests per minute per IP
|
||||
await limiter.check(null, 50, ip)
|
||||
// Exempt localhost from rate limiting
|
||||
const isLocalhost = ip === '127.0.0.1' ||
|
||||
ip === '::1' ||
|
||||
ip === 'localhost' ||
|
||||
ip === 'CACHE_TOKEN'
|
||||
|
||||
if (!isLocalhost) {
|
||||
// 50 requests per minute per IP
|
||||
await limiter.check(null, 50, ip)
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Too Many Requests' },
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Package, Platform } from "./index";
|
||||
|
||||
/**
|
||||
* User category types for package recommendations
|
||||
*/
|
||||
export type UserCategory =
|
||||
| "development"
|
||||
| "design"
|
||||
| "multimedia"
|
||||
| "system-tools"
|
||||
| "gaming"
|
||||
| "productivity"
|
||||
| "education";
|
||||
|
||||
/**
|
||||
* User profile stored in localStorage
|
||||
*/
|
||||
export interface UserProfile {
|
||||
version: number; // Schema version for future migrations
|
||||
categories: UserCategory[];
|
||||
detectedOS?: string;
|
||||
selectedOS?: string; // Manual override
|
||||
hasCompletedOnboarding: boolean;
|
||||
createdAt: string;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request payload for recommendation API
|
||||
*/
|
||||
export interface RecommendationRequest {
|
||||
platform_id: string;
|
||||
categories: UserCategory[];
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommended package with score
|
||||
*/
|
||||
export interface RecommendedPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
category?: string;
|
||||
license?: string;
|
||||
type: "gui" | "cli";
|
||||
platform?: Platform;
|
||||
platform_id?: string;
|
||||
repository: "official" | "third-party" | "aur";
|
||||
download_url?: string;
|
||||
lastUpdated?: string;
|
||||
downloads?: number;
|
||||
popularity?: number;
|
||||
popularity_score?: number;
|
||||
tags?: string[];
|
||||
recommendationScore: number;
|
||||
recommendationReason: string;
|
||||
presetMatch?: boolean;
|
||||
matchedCategory?: UserCategory; // Which user category this package matched
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preset package configuration
|
||||
*/
|
||||
export interface PackagePreset {
|
||||
packageName: string;
|
||||
platforms: string[]; // ['windows', 'macos', 'ubuntu', 'arch', 'fedora']
|
||||
priority: number; // 1-10, higher = more important
|
||||
reason: string; // Why this package is recommended
|
||||
}
|
||||
|
||||
/**
|
||||
* Category preset configuration
|
||||
*/
|
||||
export interface CategoryPreset {
|
||||
category: UserCategory;
|
||||
packages: PackagePreset[];
|
||||
description: string;
|
||||
// Icon removed in favor of UI-side mapping
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommendation response
|
||||
*/
|
||||
export interface RecommendationResponse {
|
||||
recommendations: RecommendedPackage[];
|
||||
total: number;
|
||||
userProfile: {
|
||||
categories: UserCategory[];
|
||||
platform: string;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user