refactor: standardize string quotes and improve code readability across multiple files

- Updated string quotes from single to double in useRecommendationProfile.ts, client.ts, recommendationService.ts, and recommendations.ts for consistency.
- Enhanced error handling and logging in API client and recommendation service.
- Improved code structure and formatting for better readability and maintainability.
- Ensured consistent use of semicolons and spacing throughout the codebase.
This commit is contained in:
ersaayan
2025-11-22 16:21:15 +03:00
parent 73f9248e98
commit bcf8652728
10 changed files with 1129 additions and 1054 deletions
+98 -72
View File
@@ -1,70 +1,82 @@
import { NextRequest, NextResponse } from 'next/server'
import { RecommendationService } from '@/services/recommendationService'
import { RecommendationRequest } from '@/types/recommendations'
import { NextRequest, NextResponse } from "next/server";
import { RecommendationService } from "@/services/recommendationService";
import { RecommendationRequest } from "@/types/recommendations";
export async function POST(request: NextRequest) {
try {
const body: RecommendationRequest = await request.json()
const body: RecommendationRequest = await request.json();
// Validate required fields
if (!body.platform_id) {
return NextResponse.json(
{ error: 'platform_id is required' },
{ error: "platform_id is required" },
{ status: 400 }
)
);
}
if (!body.categories || body.categories.length === 0) {
return NextResponse.json(
{ error: 'At least one category is required' },
{ error: "At least one category is required" },
{ status: 400 }
)
);
}
// Validate platform_id
const validPlatforms = ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora']
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(', ')}` },
{
error: `Invalid platform_id. Must be one of: ${validPlatforms.join(
", "
)}`,
},
{ status: 400 }
)
);
}
// Validate categories
const validCategories = [
'development',
'design',
'multimedia',
'system-tools',
'gaming',
'productivity',
'education'
]
"development",
"design",
"multimedia",
"system-tools",
"gaming",
"productivity",
"education",
];
const invalidCategories = body.categories.filter(
cat => !validCategories.includes(cat)
)
(cat) => !validCategories.includes(cat)
);
if (invalidCategories.length > 0) {
return NextResponse.json(
{
error: `Invalid categories: ${invalidCategories.join(', ')}`,
validCategories
{
error: `Invalid categories: ${invalidCategories.join(", ")}`,
validCategories,
},
{ status: 400 }
)
);
}
// Set default limit
const limit = body.limit && body.limit > 0 && body.limit <= 50
? body.limit
: 20
const limit =
body.limit && body.limit > 0 && body.limit <= 50 ? body.limit : 20;
// Generate recommendations
const recommendations = await RecommendationService.generateRecommendations({
platform_id: body.platform_id,
categories: body.categories,
experienceLevel: body.experienceLevel,
limit
})
const recommendations = await RecommendationService.generateRecommendations(
{
platform_id: body.platform_id,
categories: body.categories,
experienceLevel: body.experienceLevel,
limit,
}
);
return NextResponse.json({
recommendations,
@@ -72,68 +84,82 @@ export async function POST(request: NextRequest) {
userProfile: {
categories: body.categories,
platform: body.platform_id,
experienceLevel: body.experienceLevel
}
})
experienceLevel: body.experienceLevel,
},
});
} catch (error) {
console.error('Error generating recommendations:', error)
console.error("Error generating recommendations:", error);
return NextResponse.json(
{
error: 'Failed to generate recommendations',
details: error instanceof Error ? error.message : 'Unknown error'
{
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 experienceLevel = searchParams.get('experience_level')
const limit = searchParams.get('limit')
const { searchParams } = new URL(request.url);
const platformId = searchParams.get("platform_id");
const categoriesParam = searchParams.get("categories");
const experienceLevel = searchParams.get("experience_level");
const limit = searchParams.get("limit");
// Validate required fields
if (!platformId) {
return NextResponse.json(
{ error: 'platform_id query parameter is required' },
{ error: "platform_id query parameter is required" },
{ status: 400 }
)
);
}
if (!categoriesParam) {
return NextResponse.json(
{ error: 'categories query parameter is required (comma-separated)' },
{ error: "categories query parameter is required (comma-separated)" },
{ status: 400 }
)
);
}
// Parse categories
const categories = categoriesParam.split(',').map(c => c.trim())
const categories = categoriesParam.split(",").map((c) => c.trim());
// Validate platform_id
const validPlatforms = ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora']
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(', ')}` },
{
error: `Invalid platform_id. Must be one of: ${validPlatforms.join(
", "
)}`,
},
{ status: 400 }
)
);
}
// Set default limit
const parsedLimit = limit && parseInt(limit) > 0 && parseInt(limit) <= 50
? parseInt(limit)
: 20
const parsedLimit =
limit && parseInt(limit) > 0 && parseInt(limit) <= 50
? parseInt(limit)
: 20;
// Generate recommendations
const recommendations = await RecommendationService.generateRecommendations({
platform_id: platformId,
categories: categories as any,
experienceLevel: experienceLevel as any,
limit: parsedLimit
})
const recommendations = await RecommendationService.generateRecommendations(
{
platform_id: platformId,
categories: categories as any,
experienceLevel: experienceLevel as any,
limit: parsedLimit,
}
);
return NextResponse.json({
recommendations,
@@ -141,17 +167,17 @@ export async function GET(request: NextRequest) {
userProfile: {
categories,
platform: platformId,
experienceLevel
}
})
experienceLevel,
},
});
} catch (error) {
console.error('Error generating recommendations:', error)
console.error("Error generating recommendations:", error);
return NextResponse.json(
{
error: 'Failed to generate recommendations',
details: error instanceof Error ? error.message : 'Unknown error'
{
error: "Failed to generate recommendations",
details: error instanceof Error ? error.message : "Unknown error",
},
{ status: 500 }
)
);
}
}
+238 -242
View File
@@ -10,270 +10,266 @@ import { useLocale } from '@/contexts/LocaleContext'
import { RECOMMENDATION_PRESETS } from '@/data/recommendationPresets'
interface OnboardingModalProps {
isOpen: boolean
onClose: () => void
onComplete: (data: {
categories: UserCategory[]
selectedOS?: string
experienceLevel: ExperienceLevel
}) => void
detectedOS: string
isOpen: boolean
onClose: () => void
onComplete: (data: {
categories: UserCategory[]
selectedOS?: string
experienceLevel: ExperienceLevel
}) => void
detectedOS: string
}
const PLATFORMS = [
{ id: 'windows', name: 'Windows', icon: '🪟' },
{ id: 'macos', name: 'macOS', icon: '🍎' },
{ id: 'ubuntu', name: 'Ubuntu', icon: '🐧' },
{ id: 'debian', name: 'Debian', icon: '🐧' },
{ id: 'arch', name: 'Arch Linux', icon: '🏛️' },
{ id: 'fedora', name: 'Fedora', icon: '🎩' }
{ id: 'windows', name: 'Windows', icon: '🪟' },
{ id: 'macos', name: 'macOS', icon: '🍎' },
{ id: 'ubuntu', name: 'Ubuntu', icon: '🐧' },
{ id: 'debian', name: 'Debian', icon: '🐧' },
{ id: 'arch', name: 'Arch Linux', icon: '🏛️' },
{ id: 'fedora', name: 'Fedora', icon: '🎩' }
]
export function OnboardingModal({
isOpen,
onClose,
onComplete,
detectedOS
export function OnboardingModal({
isOpen,
onClose,
onComplete,
detectedOS
}: OnboardingModalProps) {
const { t } = useLocale()
const [step, setStep] = useState(1)
const [selectedCategories, setSelectedCategories] = useState<UserCategory[]>([])
const [selectedOS, setSelectedOS] = useState<string>(detectedOS)
const [experienceLevel, setExperienceLevel] = useState<ExperienceLevel>('beginner')
const { t } = useLocale()
const [step, setStep] = useState(1)
const [selectedCategories, setSelectedCategories] = useState<UserCategory[]>([])
const [selectedOS, setSelectedOS] = useState<string>(detectedOS)
const [experienceLevel, setExperienceLevel] = useState<ExperienceLevel>('beginner')
if (!isOpen) return null
if (!isOpen) return null
const handleCategoryToggle = (category: UserCategory) => {
setSelectedCategories(prev => {
if (prev.includes(category)) {
return prev.filter(c => c !== category)
}
// Limit to 3 categories
if (prev.length >= 3) {
return [...prev.slice(1), category]
}
return [...prev, category]
})
}
const handleNext = () => {
if (step < 3) {
setStep(step + 1)
const handleCategoryToggle = (category: UserCategory) => {
setSelectedCategories(prev => {
if (prev.includes(category)) {
return prev.filter(c => c !== category)
}
// Limit to 3 categories
if (prev.length >= 3) {
return [...prev.slice(1), category]
}
return [...prev, category]
})
}
}
const handleBack = () => {
if (step > 1) {
setStep(step - 1)
const handleNext = () => {
if (step < 3) {
setStep(step + 1)
}
}
}
const handleComplete = () => {
if (selectedCategories.length === 0) {
return
const handleBack = () => {
if (step > 1) {
setStep(step - 1)
}
}
onComplete({
categories: selectedCategories,
selectedOS: selectedOS !== detectedOS ? selectedOS : undefined,
experienceLevel
})
onClose()
}
const canProceed = () => {
if (step === 1) return selectedCategories.length > 0
if (step === 2) return selectedOS !== 'unknown'
if (step === 3) return true
return false
}
const handleComplete = () => {
if (selectedCategories.length === 0) {
return
}
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, 3].map(i => (
<div
key={i}
className={`h-2 flex-1 rounded-full transition-colors ${
i <= step ? 'bg-primary' : 'bg-secondary'
}`}
/>
))}
</div>
</CardHeader>
onComplete({
categories: selectedCategories,
selectedOS: selectedOS !== detectedOS ? selectedOS : undefined,
experienceLevel
})
onClose()
}
<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>
const canProceed = () => {
if (step === 1) return selectedCategories.length > 0
if (step === 2) return selectedOS !== 'unknown'
if (step === 3) return true
return false
}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{RECOMMENDATION_PRESETS.map(preset => (
<button
key={preset.category}
onClick={() => handleCategoryToggle(preset.category)}
className={`p-4 rounded-lg border-2 text-left transition-all hover:scale-105 ${
selectedCategories.includes(preset.category)
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}
>
<div className="flex items-start gap-3">
<span className="text-3xl">{preset.icon}</span>
<div className="flex-1 min-w-0">
<h4 className="font-semibold capitalize">
{t(`categories.${preset.category}.name`)}
</h4>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{t(`categories.${preset.category}.description`)}
</p>
</div>
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>
</button>
))}
</div>
<CardDescription>
{t('onboarding.subtitle')}
</CardDescription>
{selectedCategories.length > 0 && (
<p className="text-sm text-muted-foreground text-center">
{t('onboarding.step1.selected', { count: selectedCategories.length })}
</p>
)}
</div>
)}
{/* Progress indicator */}
<div className="flex gap-2 mt-4">
{[1, 2, 3].map(i => (
<div
key={i}
className={`h-2 flex-1 rounded-full transition-colors ${i <= step ? 'bg-primary' : 'bg-secondary'
}`}
/>
))}
</div>
</CardHeader>
{/* 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>
<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-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="text-4xl mb-2">{platform.icon}</div>
<div className="font-semibold text-sm">{platform.name}</div>
</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{RECOMMENDATION_PRESETS.map(preset => (
<button
key={preset.category}
onClick={() => handleCategoryToggle(preset.category)}
className={`p-4 rounded-lg border-2 text-left transition-all hover:scale-105 ${selectedCategories.includes(preset.category)
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}
>
<div className="flex items-start gap-3">
<span className="text-3xl">{preset.icon}</span>
<div className="flex-1 min-w-0">
<h4 className="font-semibold capitalize">
{t(`categories.${preset.category}.name`)}
</h4>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{t(`categories.${preset.category}.description`)}
</p>
</div>
</div>
</button>
))}
</div>
{/* Step 3: Experience Level */}
{step === 3 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2">
{t('onboarding.step3.title')}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('onboarding.step3.description')}
</p>
</div>
{selectedCategories.length > 0 && (
<p className="text-sm text-muted-foreground text-center">
{t('onboarding.step1.selected', { count: selectedCategories.length })}
</p>
)}
</div>
)}
<div className="space-y-3">
{(['beginner', 'intermediate', 'advanced'] as ExperienceLevel[]).map(level => (
<button
key={level}
onClick={() => setExperienceLevel(level)}
className={`w-full p-4 rounded-lg border-2 text-left transition-all hover:scale-[1.02] ${
experienceLevel === level
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}
>
<h4 className="font-semibold capitalize mb-1">
{t(`onboarding.step3.levels.${level}.name`)}
</h4>
<p className="text-sm text-muted-foreground">
{t(`onboarding.step3.levels.${level}.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>
{/* 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 < 3 ? (
<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"
>
{t('onboarding.complete')}
<Sparkles className="h-4 w-4 ml-2" />
</Button>
)}
</div>
</CardContent>
</Card>
</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="text-4xl mb-2">{platform.icon}</div>
<div className="font-semibold text-sm">{platform.name}</div>
</button>
))}
</div>
</div>
)}
{/* Step 3: Experience Level */}
{step === 3 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2">
{t('onboarding.step3.title')}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('onboarding.step3.description')}
</p>
</div>
<div className="space-y-3">
{(['beginner', 'intermediate', 'advanced'] as ExperienceLevel[]).map(level => (
<button
key={level}
onClick={() => setExperienceLevel(level)}
className={`w-full p-4 rounded-lg border-2 text-left transition-all hover:scale-[1.02] ${experienceLevel === level
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}
>
<h4 className="font-semibold capitalize mb-1">
{t(`onboarding.step3.levels.${level}.name`)}
</h4>
<p className="text-sm text-muted-foreground">
{t(`onboarding.step3.levels.${level}.description`)}
</p>
</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 < 3 ? (
<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"
>
{t('onboarding.complete')}
<Sparkles className="h-4 w-4 ml-2" />
</Button>
)}
</div>
</CardContent>
</Card>
</div>
)
}
+211 -212
View File
@@ -10,232 +10,231 @@ import { useLocale } from '@/contexts/LocaleContext'
import { useRecommendationProfile } from '@/hooks/useRecommendationProfile'
interface RecommendationsSectionProps {
onPackageToggle: (pkg: Package) => void
selectedPackages: Package[]
onCustomizeClick: () => void
onPackageToggle: (pkg: Package) => void
selectedPackages: Package[]
onCustomizeClick: () => void
}
export function RecommendationsSection({
onPackageToggle,
selectedPackages,
onCustomizeClick
onPackageToggle,
selectedPackages,
onCustomizeClick
}: RecommendationsSectionProps) {
const { t } = useLocale()
const { profile, getEffectiveOS, isProfileComplete } = useRecommendationProfile()
const [recommendations, setRecommendations] = useState<RecommendedPackage[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { t } = useLocale()
const { profile, getEffectiveOS, isProfileComplete } = useRecommendationProfile()
const [recommendations, setRecommendations] = useState<RecommendedPackage[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
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,
experienceLevel: profile.experienceLevel,
limit: 12
})
})
if (!response.ok) {
throw new Error('Failed to fetch recommendations')
}
const data = await response.json()
setRecommendations(data.recommendations || [])
} catch (err) {
console.error('Error fetching recommendations:', err)
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}
// Fetch recommendations on mount and when profile changes
useEffect(() => {
if (isProfileComplete()) {
fetchRecommendations()
}
}, [profile.categories, profile.selectedOS, profile.experienceLevel])
const isPackageSelected = (pkg: RecommendedPackage) => {
return selectedPackages.some(selected => selected.id === pkg.id)
}
const fetchRecommendations = async () => {
if (!isProfileComplete()) {
return
return null
}
setLoading(true)
setError(null)
return (
<Card className="w-full">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-lg">
{t('recommendations.title')}
</CardTitle>
<CardDescription className="text-sm">
{t('recommendations.subtitle')}
</CardDescription>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchRecommendations}
disabled={loading}
>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
{t('recommendations.refresh')}
</Button>
<Button
variant="outline"
size="sm"
onClick={onCustomizeClick}
>
<Settings className="h-4 w-4 mr-2" />
{t('recommendations.customize')}
</Button>
</div>
</div>
try {
const response = await fetch('/api/recommendations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
platform_id: getEffectiveOS(),
categories: profile.categories,
experienceLevel: profile.experienceLevel,
limit: 12
})
})
if (!response.ok) {
throw new Error('Failed to fetch recommendations')
}
const data = await response.json()
setRecommendations(data.recommendations || [])
} catch (err) {
console.error('Error fetching recommendations:', err)
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}
// Fetch recommendations on mount and when profile changes
useEffect(() => {
if (isProfileComplete()) {
fetchRecommendations()
}
}, [profile.categories, profile.selectedOS, profile.experienceLevel])
const isPackageSelected = (pkg: RecommendedPackage) => {
return selectedPackages.some(selected => selected.id === pkg.id)
}
if (!isProfileComplete()) {
return null
}
return (
<Card className="w-full">
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<div>
<CardTitle className="text-lg">
{t('recommendations.title')}
</CardTitle>
<CardDescription className="text-sm">
{t('recommendations.subtitle')}
</CardDescription>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchRecommendations}
disabled={loading}
>
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
{t('recommendations.refresh')}
</Button>
<Button
variant="outline"
size="sm"
onClick={onCustomizeClick}
>
<Settings className="h-4 w-4 mr-2" />
{t('recommendations.customize')}
</Button>
</div>
</div>
{/* Show user profile info */}
<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>
</CardHeader>
<CardContent>
{loading && (
<div className="text-center py-12">
<RefreshCw 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 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recommendations.map(pkg => (
<Card
key={pkg.id}
className={`relative overflow-hidden transition-all hover:shadow-lg cursor-pointer ${
isPackageSelected(pkg) ? 'ring-2 ring-primary' : ''
}`}
onClick={() => onPackageToggle(pkg)}
>
{pkg.presetMatch && (
<div className="absolute top-2 right-2">
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold bg-primary text-primary-foreground">
<Star className="h-3 w-3" />
{t('recommendations.preset_badge')}
{/* Show user profile info */}
<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>
</div>
{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>
</CardHeader>
<CardContent>
{loading && (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4 text-primary" />
<p className="text-muted-foreground">{t('recommendations.loading')}</p>
</div>
)}
<CardContent className="p-4">
<div className="flex items-start gap-3 mb-3">
<PackageIcon className="h-8 w-8 text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{pkg.name}</h3>
<p className="text-xs text-muted-foreground">{pkg.version}</p>
{error && (
<div className="text-center py-12">
<p className="text-destructive mb-4">{error}</p>
<Button onClick={fetchRecommendations} variant="outline">
Try Again
</Button>
</div>
</div>
)}
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{pkg.description}
</p>
{/* Recommendation score and reason */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t('recommendations.score')}
</span>
<div className="flex items-center gap-1">
<div className="h-2 w-16 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${pkg.recommendationScore}%` }}
/>
</div>
<span className="text-xs font-semibold">{pkg.recommendationScore}%</span>
</div>
</div>
{pkg.recommendationReason && (
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground">
<span className="font-semibold">{t('recommendations.reason')}</span>
{' '}
{pkg.recommendationReason}
{!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>
</div>
)}
</div>
<Button onClick={onCustomizeClick} variant="outline">
<Settings className="h-4 w-4 mr-2" />
{t('recommendations.customize')}
</Button>
</div>
)}
<Button
variant={isPackageSelected(pkg) ? 'default' : 'outline'}
size="sm"
className="w-full mt-3"
onClick={(e) => {
e.stopPropagation()
onPackageToggle(pkg)
}}
>
{isPackageSelected(pkg) ? '✓ Selected' : t('recommendations.add_to_selection')}
</Button>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
)
{!loading && !error && recommendations.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{recommendations.map(pkg => (
<Card
key={pkg.id}
className={`relative overflow-hidden transition-all hover:shadow-lg cursor-pointer ${isPackageSelected(pkg) ? 'ring-2 ring-primary' : ''
}`}
onClick={() => onPackageToggle(pkg)}
>
{pkg.presetMatch && (
<div className="absolute top-2 right-2">
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold bg-primary text-primary-foreground">
<Star className="h-3 w-3" />
{t('recommendations.preset_badge')}
</span>
</div>
)}
<CardContent className="p-4">
<div className="flex items-start gap-3 mb-3">
<PackageIcon className="h-8 w-8 text-primary flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{pkg.name}</h3>
<p className="text-xs text-muted-foreground">{pkg.version}</p>
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{pkg.description}
</p>
{/* Recommendation score and reason */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t('recommendations.score')}
</span>
<div className="flex items-center gap-1">
<div className="h-2 w-16 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all"
style={{ width: `${pkg.recommendationScore}%` }}
/>
</div>
<span className="text-xs font-semibold">{pkg.recommendationScore}%</span>
</div>
</div>
{pkg.recommendationReason && (
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground">
<span className="font-semibold">{t('recommendations.reason')}</span>
{' '}
{pkg.recommendationReason}
</p>
</div>
)}
</div>
<Button
variant={isPackageSelected(pkg) ? 'default' : 'outline'}
size="sm"
className="w-full mt-3"
onClick={(e) => {
e.stopPropagation()
onPackageToggle(pkg)
}}
>
{isPackageSelected(pkg) ? '✓ Selected' : t('recommendations.add_to_selection')}
</Button>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
)
}
+2 -2
View File
@@ -20,7 +20,7 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
const [selectedPlatform, setSelectedPlatform] = useState<Platform | null>(null)
const [selectedPackages, setSelectedPackages] = useState<SelectedPackage[]>([])
const [generatedScript, setGeneratedScript] = useState<GeneratedScript | null>(null)
// Recommendation profile management
const {
profile,
@@ -30,7 +30,7 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
saveProfile,
detectedOS
} = useRecommendationProfile()
const [showOnboarding, setShowOnboarding] = useState(false)
// Show onboarding modal on first visit
+2 -2
View File
@@ -396,7 +396,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
if (browserLang.startsWith('tr')) {
return 'tr'
}
return 'en'
}
@@ -415,7 +415,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]
}
+227 -225
View File
@@ -1,4 +1,4 @@
import { CategoryPreset } from '@/types/recommendations'
import { CategoryPreset } from "@/types/recommendations";
/**
* Curated package recommendations for each user category
@@ -6,364 +6,366 @@ import { CategoryPreset } from '@/types/recommendations'
*/
export const RECOMMENDATION_PRESETS: CategoryPreset[] = [
{
category: 'development',
description: 'Essential tools for software development',
icon: '💻',
category: "development",
description: "Essential tools for software development",
icon: "💻",
packages: [
{
packageName: 'git',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "git",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 10,
reason: 'Version control system essential for all developers',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Version control system essential for all developers",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'code',
platforms: ['windows', 'macos', 'ubuntu', 'debian'],
packageName: "code",
platforms: ["windows", "macos", "ubuntu", "debian"],
priority: 9,
reason: 'Visual Studio Code - Popular code editor',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Visual Studio Code - Popular code editor",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'visual-studio-code',
platforms: ['arch', 'fedora'],
packageName: "visual-studio-code",
platforms: ["arch", "fedora"],
priority: 9,
reason: 'Visual Studio Code - Popular code editor',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Visual Studio Code - Popular code editor",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'nodejs',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "nodejs",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 8,
reason: 'JavaScript runtime for modern web development',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "JavaScript runtime for modern web development",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'python3',
platforms: ['ubuntu', 'debian', 'arch', 'fedora'],
packageName: "python3",
platforms: ["ubuntu", "debian", "arch", "fedora"],
priority: 8,
reason: 'Python programming language',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Python programming language",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'python',
platforms: ['windows', 'macos'],
packageName: "python",
platforms: ["windows", "macos"],
priority: 8,
reason: 'Python programming language',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Python programming language",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'docker',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "docker",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: 'Containerization platform for development',
experienceLevel: ['intermediate', 'advanced']
reason: "Containerization platform for development",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: 'curl',
platforms: ['ubuntu', 'debian', 'arch', 'fedora', 'macos'],
packageName: "curl",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
priority: 7,
reason: 'Command-line tool for transferring data',
experienceLevel: ['intermediate', 'advanced']
reason: "Command-line tool for transferring data",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: 'vim',
platforms: ['ubuntu', 'debian', 'arch', 'fedora', 'macos'],
packageName: "vim",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
priority: 6,
reason: 'Powerful text editor',
experienceLevel: ['intermediate', 'advanced']
reason: "Powerful text editor",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: 'postman',
platforms: ['windows', 'macos', 'ubuntu', 'debian'],
packageName: "postman",
platforms: ["windows", "macos", "ubuntu", "debian"],
priority: 6,
reason: 'API development and testing tool',
experienceLevel: ['beginner', 'intermediate', 'advanced']
}
]
reason: "API development and testing tool",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
],
},
{
category: 'design',
description: 'Tools for graphic design, UI/UX, and creative work',
icon: '🎨',
category: "design",
description: "Tools for graphic design, UI/UX, and creative work",
icon: "🎨",
packages: [
{
packageName: 'gimp',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "gimp",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 9,
reason: 'Free and open-source image editor',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Free and open-source image editor",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'inkscape',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "inkscape",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 8,
reason: 'Professional vector graphics editor',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Professional vector graphics editor",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'blender',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "blender",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 8,
reason: '3D creation suite',
experienceLevel: ['intermediate', 'advanced']
reason: "3D creation suite",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: 'figma',
platforms: ['windows', 'macos'],
packageName: "figma",
platforms: ["windows", "macos"],
priority: 9,
reason: 'Collaborative interface design tool',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Collaborative interface design tool",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'krita',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "krita",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: 'Digital painting application',
experienceLevel: ['beginner', 'intermediate', 'advanced']
}
]
reason: "Digital painting application",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
],
},
{
category: 'multimedia',
description: 'Audio, video editing and media management tools',
icon: '🎬',
category: "multimedia",
description: "Audio, video editing and media management tools",
icon: "🎬",
packages: [
{
packageName: 'vlc',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "vlc",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 10,
reason: 'Versatile media player',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Versatile media player",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'obs-studio',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "obs-studio",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 9,
reason: 'Video recording and live streaming',
experienceLevel: ['intermediate', 'advanced']
reason: "Video recording and live streaming",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: 'audacity',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "audacity",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 8,
reason: 'Audio editing software',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Audio editing software",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'ffmpeg',
platforms: ['ubuntu', 'debian', 'arch', 'fedora', 'macos'],
packageName: "ffmpeg",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
priority: 8,
reason: 'Complete multimedia framework',
experienceLevel: ['intermediate', 'advanced']
reason: "Complete multimedia framework",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: 'handbrake',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "handbrake",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: 'Video transcoder',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Video transcoder",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'kdenlive',
platforms: ['ubuntu', 'debian', 'arch', 'fedora'],
packageName: "kdenlive",
platforms: ["ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: 'Video editing software',
experienceLevel: ['intermediate', 'advanced']
}
]
reason: "Video editing software",
experienceLevel: ["intermediate", "advanced"],
},
],
},
{
category: 'system-tools',
description: 'System administration, security and utilities',
icon: '⚙️',
category: "system-tools",
description: "System administration, security and utilities",
icon: "⚙️",
packages: [
{
packageName: 'htop',
platforms: ['ubuntu', 'debian', 'arch', 'fedora', 'macos'],
packageName: "htop",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
priority: 9,
reason: 'Interactive process viewer',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Interactive process viewer",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'tmux',
platforms: ['ubuntu', 'debian', 'arch', 'fedora', 'macos'],
packageName: "tmux",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
priority: 8,
reason: 'Terminal multiplexer',
experienceLevel: ['intermediate', 'advanced']
reason: "Terminal multiplexer",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: 'wget',
platforms: ['ubuntu', 'debian', 'arch', 'fedora', 'macos'],
packageName: "wget",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
priority: 8,
reason: 'Network downloader',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Network downloader",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'neofetch',
platforms: ['ubuntu', 'debian', 'arch', 'fedora', 'macos'],
packageName: "neofetch",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
priority: 6,
reason: 'System information tool',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "System information tool",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'wireshark',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "wireshark",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: 'Network protocol analyzer',
experienceLevel: ['advanced']
reason: "Network protocol analyzer",
experienceLevel: ["advanced"],
},
{
packageName: 'gparted',
platforms: ['ubuntu', 'debian', 'arch', 'fedora'],
packageName: "gparted",
platforms: ["ubuntu", "debian", "arch", "fedora"],
priority: 6,
reason: 'Partition editor',
experienceLevel: ['intermediate', 'advanced']
}
]
reason: "Partition editor",
experienceLevel: ["intermediate", "advanced"],
},
],
},
{
category: 'gaming',
description: 'Gaming platforms and related tools',
icon: '🎮',
category: "gaming",
description: "Gaming platforms and related tools",
icon: "🎮",
packages: [
{
packageName: 'steam',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "steam",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 10,
reason: 'Gaming platform',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Gaming platform",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'discord',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "discord",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 9,
reason: 'Voice and chat for gamers',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Voice and chat for gamers",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'lutris',
platforms: ['ubuntu', 'debian', 'arch', 'fedora'],
packageName: "lutris",
platforms: ["ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: 'Open gaming platform',
experienceLevel: ['intermediate', 'advanced']
reason: "Open gaming platform",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: 'wine',
platforms: ['ubuntu', 'debian', 'arch', 'fedora', 'macos'],
packageName: "wine",
platforms: ["ubuntu", "debian", "arch", "fedora", "macos"],
priority: 6,
reason: 'Windows compatibility layer',
experienceLevel: ['advanced']
}
]
reason: "Windows compatibility layer",
experienceLevel: ["advanced"],
},
],
},
{
category: 'productivity',
description: 'Office, note-taking and productivity tools',
icon: '📝',
category: "productivity",
description: "Office, note-taking and productivity tools",
icon: "📝",
packages: [
{
packageName: 'libreoffice',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "libreoffice",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 10,
reason: 'Free office suite',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Free office suite",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'thunderbird',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "thunderbird",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 8,
reason: 'Email client',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Email client",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'notion',
platforms: ['windows', 'macos'],
packageName: "notion",
platforms: ["windows", "macos"],
priority: 9,
reason: 'All-in-one workspace',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "All-in-one workspace",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'obsidian',
platforms: ['windows', 'macos', 'ubuntu', 'debian'],
packageName: "obsidian",
platforms: ["windows", "macos", "ubuntu", "debian"],
priority: 8,
reason: 'Knowledge base and note-taking',
experienceLevel: ['intermediate', 'advanced']
reason: "Knowledge base and note-taking",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: 'keepassxc',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "keepassxc",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: 'Password manager',
experienceLevel: ['beginner', 'intermediate', 'advanced']
}
]
reason: "Password manager",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
],
},
{
category: 'education',
description: 'Educational and scientific software',
icon: '🎓',
category: "education",
description: "Educational and scientific software",
icon: "🎓",
packages: [
{
packageName: 'anki',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "anki",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 9,
reason: 'Flashcard application for learning',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Flashcard application for learning",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'stellarium',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "stellarium",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: 'Planetarium software',
experienceLevel: ['beginner', 'intermediate', 'advanced']
reason: "Planetarium software",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
{
packageName: 'octave',
platforms: ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'],
packageName: "octave",
platforms: ["windows", "macos", "ubuntu", "debian", "arch", "fedora"],
priority: 7,
reason: 'Scientific programming language',
experienceLevel: ['intermediate', 'advanced']
reason: "Scientific programming language",
experienceLevel: ["intermediate", "advanced"],
},
{
packageName: 'geogebra',
platforms: ['windows', 'macos', 'ubuntu', 'debian'],
packageName: "geogebra",
platforms: ["windows", "macos", "ubuntu", "debian"],
priority: 8,
reason: 'Interactive mathematics software',
experienceLevel: ['beginner', 'intermediate', 'advanced']
}
]
}
]
reason: "Interactive mathematics software",
experienceLevel: ["beginner", "intermediate", "advanced"],
},
],
},
];
/**
* Get presets for specific categories
*/
export function getPresetsForCategories(categories: string[]): CategoryPreset[] {
return RECOMMENDATION_PRESETS.filter(preset =>
export function getPresetsForCategories(
categories: string[]
): CategoryPreset[] {
return RECOMMENDATION_PRESETS.filter((preset) =>
categories.includes(preset.category)
)
);
}
/**
* Get all package names from presets for a specific platform
*/
export function getPresetPackageNames(
categories: string[],
categories: string[],
platformId: string
): string[] {
const presets = getPresetsForCategories(categories)
const packageNames = new Set<string>()
presets.forEach(preset => {
preset.packages.forEach(pkg => {
const presets = getPresetsForCategories(categories);
const packageNames = new Set<string>();
presets.forEach((preset) => {
preset.packages.forEach((pkg) => {
if (pkg.platforms.includes(platformId)) {
packageNames.add(pkg.packageName)
packageNames.add(pkg.packageName);
}
})
})
return Array.from(packageNames)
});
});
return Array.from(packageNames);
}
/**
@@ -374,18 +376,18 @@ export function getPresetPriority(
categories: string[],
platformId: string
): number | null {
const presets = getPresetsForCategories(categories)
const presets = getPresetsForCategories(categories);
for (const preset of presets) {
const pkg = preset.packages.find(
p => p.packageName === packageName && p.platforms.includes(platformId)
)
(p) => p.packageName === packageName && p.platforms.includes(platformId)
);
if (pkg) {
return pkg.priority
return pkg.priority;
}
}
return null
return null;
}
/**
@@ -395,14 +397,14 @@ export function getRecommendationReason(
packageName: string,
categories: string[]
): string | null {
const presets = getPresetsForCategories(categories)
const presets = getPresetsForCategories(categories);
for (const preset of presets) {
const pkg = preset.packages.find(p => p.packageName === packageName)
const pkg = preset.packages.find((p) => p.packageName === packageName);
if (pkg) {
return pkg.reason
return pkg.reason;
}
}
return null
return null;
}
+98 -82
View File
@@ -1,56 +1,60 @@
"use client"
"use client";
import { useState, useEffect, useCallback } from 'react'
import { UserProfile, UserCategory, ExperienceLevel } from '@/types/recommendations'
import { useState, useEffect, useCallback } from "react";
import {
UserProfile,
UserCategory,
ExperienceLevel,
} from "@/types/recommendations";
const STORAGE_KEY = 'repohub_user_profile'
const STORAGE_KEY = "repohub_user_profile";
/**
* Detect user's operating system from browser
*/
function detectOS(): string {
if (typeof window === 'undefined') {
return 'unknown'
if (typeof window === "undefined") {
return "unknown";
}
const userAgent = window.navigator.userAgent.toLowerCase()
const platform = window.navigator.platform.toLowerCase()
const userAgent = window.navigator.userAgent.toLowerCase();
const platform = window.navigator.platform.toLowerCase();
// Windows
if (userAgent.indexOf('win') !== -1 || platform.indexOf('win') !== -1) {
return '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
userAgent.indexOf("mac") !== -1 ||
platform.indexOf("mac") !== -1 ||
userAgent.indexOf("darwin") !== -1
) {
return 'macos'
return "macos";
}
// Linux distros
if (userAgent.indexOf('linux') !== -1 || platform.indexOf('linux') !== -1) {
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("ubuntu") !== -1) {
return "ubuntu";
}
if (userAgent.indexOf('fedora') !== -1) {
return 'fedora'
if (userAgent.indexOf("fedora") !== -1) {
return "fedora";
}
if (userAgent.indexOf('arch') !== -1) {
return 'arch'
if (userAgent.indexOf("arch") !== -1) {
return "arch";
}
if (userAgent.indexOf('debian') !== -1) {
return 'debian'
if (userAgent.indexOf("debian") !== -1) {
return "debian";
}
// Default to Ubuntu for generic Linux
return 'ubuntu'
return "ubuntu";
}
return 'unknown'
return "unknown";
}
/**
@@ -61,106 +65,118 @@ function getDefaultProfile(): UserProfile {
categories: [],
detectedOS: detectOS(),
selectedOS: undefined,
experienceLevel: 'beginner',
experienceLevel: "beginner",
hasCompletedOnboarding: false,
createdAt: new Date().toISOString(),
lastUpdated: 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)
const [profile, setProfile] = useState<UserProfile>(getDefaultProfile());
const [isLoading, setIsLoading] = useState(true);
// Load profile from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as UserProfile
const parsed = JSON.parse(stored) as UserProfile;
// Update detectedOS if it changed
const currentOS = detectOS()
const currentOS = detectOS();
if (parsed.detectedOS !== currentOS) {
parsed.detectedOS = currentOS
parsed.detectedOS = currentOS;
}
setProfile(parsed)
setProfile(parsed);
} else {
// First time user - save default profile
const defaultProfile = getDefaultProfile()
setProfile(defaultProfile)
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultProfile))
const defaultProfile = getDefaultProfile();
setProfile(defaultProfile);
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultProfile));
}
} catch (error) {
console.error('Error loading user profile:', error)
console.error("Error loading user profile:", error);
} finally {
setIsLoading(false)
setIsLoading(false);
}
}, [])
}, []);
// Save profile to localStorage
const saveProfile = useCallback((newProfile: Partial<UserProfile>) => {
try {
const updated: UserProfile = {
...profile,
...newProfile,
lastUpdated: new Date().toISOString()
const saveProfile = useCallback(
(newProfile: Partial<UserProfile>) => {
try {
const updated: UserProfile = {
...profile,
...newProfile,
lastUpdated: new Date().toISOString(),
};
setProfile(updated);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return true;
} catch (error) {
console.error("Error saving user profile:", error);
return false;
}
setProfile(updated)
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))
return true
} catch (error) {
console.error('Error saving user profile:', error)
return false
}
}, [profile])
},
[profile]
);
// Update categories
const updateCategories = useCallback((categories: UserCategory[]) => {
return saveProfile({ categories })
}, [saveProfile])
const updateCategories = useCallback(
(categories: UserCategory[]) => {
return saveProfile({ categories });
},
[saveProfile]
);
// Update selected OS (manual override)
const updateSelectedOS = useCallback((os: string) => {
return saveProfile({ selectedOS: os })
}, [saveProfile])
const updateSelectedOS = useCallback(
(os: string) => {
return saveProfile({ selectedOS: os });
},
[saveProfile]
);
// Update experience level
const updateExperienceLevel = useCallback((level: ExperienceLevel) => {
return saveProfile({ experienceLevel: level })
}, [saveProfile])
const updateExperienceLevel = useCallback(
(level: ExperienceLevel) => {
return saveProfile({ experienceLevel: level });
},
[saveProfile]
);
// Mark onboarding as completed
const completeOnboarding = useCallback(() => {
return saveProfile({ hasCompletedOnboarding: true })
}, [saveProfile])
return saveProfile({ hasCompletedOnboarding: true });
}, [saveProfile]);
// Reset profile
const resetProfile = useCallback(() => {
try {
const defaultProfile = getDefaultProfile()
setProfile(defaultProfile)
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultProfile))
return true
const defaultProfile = getDefaultProfile();
setProfile(defaultProfile);
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultProfile));
return true;
} catch (error) {
console.error('Error resetting user profile:', error)
return false
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])
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.categories.length > 0 && getEffectiveOS() !== "unknown";
}, [profile, getEffectiveOS]);
return {
profile,
@@ -174,6 +190,6 @@ export function useRecommendationProfile() {
getEffectiveOS,
isProfileComplete,
detectedOS: profile.detectedOS,
hasCompletedOnboarding: profile.hasCompletedOnboarding
}
hasCompletedOnboarding: profile.hasCompletedOnboarding,
};
}
+79 -53
View File
@@ -1,101 +1,127 @@
import { Platform, Package, FilterOptions } from '@/types'
import { RecommendationRequest, RecommendationResponse } from '@/types/recommendations'
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',
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();
+120 -110
View File
@@ -1,16 +1,16 @@
import { PackageService } from './packageService'
import { Package } from '@/models/Package'
import {
RecommendationRequest,
RecommendedPackage,
import { PackageService } from "./packageService";
import { Package } from "@/models/Package";
import {
RecommendationRequest,
RecommendedPackage,
UserCategory,
ExperienceLevel
} from '@/types/recommendations'
import {
getPresetPackageNames,
getPresetPriority,
getRecommendationReason
} from '@/data/recommendationPresets'
ExperienceLevel,
} from "@/types/recommendations";
import {
getPresetPackageNames,
getPresetPriority,
getRecommendationReason,
} from "@/data/recommendationPresets";
/**
* Recommendation scoring weights
@@ -19,8 +19,8 @@ const SCORING_WEIGHTS = {
CATEGORY_MATCH: 0.4,
POPULARITY: 0.3,
OS_COMPATIBILITY: 0.2,
PRESET_BOOST: 0.1
}
PRESET_BOOST: 0.1,
};
export class RecommendationService {
/**
@@ -29,40 +29,48 @@ export class RecommendationService {
static async generateRecommendations(
request: RecommendationRequest
): Promise<RecommendedPackage[]> {
const { platform_id, categories, experienceLevel, limit = 20 } = request
const { platform_id, categories, experienceLevel, limit = 20 } = request;
// Step 1: Get preset package names for the user's categories and platform
const presetPackageNames = getPresetPackageNames(categories, platform_id)
const presetPackageNames = getPresetPackageNames(categories, platform_id);
// Step 2: Fetch packages from database
// First, get preset packages
const presetPackages = await this.fetchPresetPackages(
presetPackageNames,
presetPackageNames,
platform_id
)
);
// Then, get additional packages from categories
const categoryPackages = await this.fetchCategoryPackages(
categories,
platform_id,
categories,
platform_id,
limit * 2 // Fetch more to ensure we have enough after filtering
)
);
// Step 3: Combine and deduplicate
const allPackages = this.deduplicatePackages([
...presetPackages,
...categoryPackages
])
...categoryPackages,
]);
// Step 4: Score and rank packages
const scoredPackages = allPackages.map(pkg =>
this.scorePackage(pkg, categories, platform_id, presetPackageNames, experienceLevel)
)
const scoredPackages = allPackages.map((pkg) =>
this.scorePackage(
pkg,
categories,
platform_id,
presetPackageNames,
experienceLevel
)
);
// Step 5: Sort by score and limit results
scoredPackages.sort((a, b) => b.recommendationScore - a.recommendationScore)
scoredPackages.sort(
(a, b) => b.recommendationScore - a.recommendationScore
);
return scoredPackages.slice(0, limit)
return scoredPackages.slice(0, limit);
}
/**
@@ -73,32 +81,32 @@ export class RecommendationService {
platformId: string
): Promise<Package[]> {
if (packageNames.length === 0) {
return []
return [];
}
try {
// Fetch packages by exact name match
const packages: Package[] = []
const packages: Package[] = [];
for (const name of packageNames) {
const result = await PackageService.getMany({
platform_id: platformId,
search: name,
limit: 1,
sort_by: 'popularity_score',
sort_order: 'desc'
})
sort_by: "popularity_score",
sort_order: "desc",
});
// Only add if exact match
if (result.packages.length > 0 && result.packages[0].name === name) {
packages.push(result.packages[0])
packages.push(result.packages[0]);
}
}
return packages
return packages;
} catch (error) {
console.error('Error fetching preset packages:', error)
return []
console.error("Error fetching preset packages:", error);
return [];
}
}
@@ -113,38 +121,38 @@ export class RecommendationService {
try {
// Map user categories to database categories
const categoryMap: Record<UserCategory, string[]> = {
'development': ['Development', 'Internet'],
'design': ['Graphics', 'Multimedia'],
'multimedia': ['Multimedia', 'Graphics'],
'system-tools': ['System', 'Utilities'],
'gaming': ['Games'],
'productivity': ['Office', 'Utilities'],
'education': ['Science', 'Education']
}
development: ["Development", "Internet"],
design: ["Graphics", "Multimedia"],
multimedia: ["Multimedia", "Graphics"],
"system-tools": ["System", "Utilities"],
gaming: ["Games"],
productivity: ["Office", "Utilities"],
education: ["Science", "Education"],
};
// Get all matching packages
const packages: Package[] = []
const packages: Package[] = [];
for (const category of categories) {
const dbCategories = categoryMap[category] || []
const dbCategories = categoryMap[category] || [];
// Note: Since we don't have category filtering in current API,
// we'll fetch by popularity and filter client-side
// This is a limitation of current schema - categories are not well-utilized
const result = await PackageService.getMany({
platform_id: platformId,
limit: Math.ceil(limit / categories.length),
sort_by: 'popularity_score',
sort_order: 'desc'
})
packages.push(...result.packages)
sort_by: "popularity_score",
sort_order: "desc",
});
packages.push(...result.packages);
}
return packages
return packages;
} catch (error) {
console.error('Error fetching category packages:', error)
return []
console.error("Error fetching category packages:", error);
return [];
}
}
@@ -152,14 +160,14 @@ export class RecommendationService {
* Remove duplicate packages (by ID)
*/
private static deduplicatePackages(packages: Package[]): Package[] {
const seen = new Set<string>()
return packages.filter(pkg => {
const seen = new Set<string>();
return packages.filter((pkg) => {
if (seen.has(pkg.id)) {
return false
return false;
}
seen.add(pkg.id)
return true
})
seen.add(pkg.id);
return true;
});
}
/**
@@ -172,65 +180,67 @@ export class RecommendationService {
presetPackageNames: string[],
experienceLevel?: ExperienceLevel
): RecommendedPackage {
let score = 0
let reason = ''
const isPresetMatch = presetPackageNames.includes(pkg.name)
let score = 0;
let reason = "";
const isPresetMatch = presetPackageNames.includes(pkg.name);
// 1. Category Match Score (40%)
// For preset packages, this is always high
const categoryScore = isPresetMatch ? 1.0 : 0.5
score += categoryScore * SCORING_WEIGHTS.CATEGORY_MATCH
const categoryScore = isPresetMatch ? 1.0 : 0.5;
score += categoryScore * SCORING_WEIGHTS.CATEGORY_MATCH;
// 2. Popularity Score (30%)
// Normalize popularity_score (0-100) to 0-1
const popularityScore = (pkg.popularity_score || 0) / 100
score += popularityScore * SCORING_WEIGHTS.POPULARITY
const popularityScore = (pkg.popularity_score || 0) / 100;
score += popularityScore * SCORING_WEIGHTS.POPULARITY;
// 3. OS Compatibility Score (20%)
// All packages from DB should be compatible, so this is always 1.0
const osScore = 1.0
score += osScore * SCORING_WEIGHTS.OS_COMPATIBILITY
const osScore = 1.0;
score += osScore * SCORING_WEIGHTS.OS_COMPATIBILITY;
// 4. Preset Boost (10%)
// Extra boost for preset packages based on priority
let presetBoost = 0
let presetBoost = 0;
if (isPresetMatch) {
const priority = getPresetPriority(pkg.name, categories, platformId)
const priority = getPresetPriority(pkg.name, categories, platformId);
if (priority !== null) {
presetBoost = priority / 10 // Normalize 1-10 to 0.1-1.0
presetBoost = priority / 10; // Normalize 1-10 to 0.1-1.0
// Get recommendation reason from preset
const presetReason = getRecommendationReason(pkg.name, categories)
const presetReason = getRecommendationReason(pkg.name, categories);
if (presetReason) {
reason = presetReason
reason = presetReason;
}
}
}
score += presetBoost * SCORING_WEIGHTS.PRESET_BOOST
score += presetBoost * SCORING_WEIGHTS.PRESET_BOOST;
// Default reason if not from preset
if (!reason) {
if (pkg.popularity_score && pkg.popularity_score > 70) {
reason = 'Popular choice in the community'
reason = "Popular choice in the community";
} else {
reason = 'Recommended for your selected categories'
reason = "Recommended for your selected categories";
}
}
// Normalize final score to 0-100
const finalScore = Math.round(score * 100)
const finalScore = Math.round(score * 100);
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',
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,
platform_id: pkg.platform_id,
repository: pkg.repository || 'official',
repository: pkg.repository || "official",
download_url: pkg.download_url,
lastUpdated: pkg.last_updated ? pkg.last_updated.toString() : undefined,
downloads: pkg.downloads_count,
@@ -239,8 +249,8 @@ export class RecommendationService {
tags: pkg.tags,
recommendationScore: finalScore,
recommendationReason: reason,
presetMatch: isPresetMatch
}
presetMatch: isPresetMatch,
};
}
/**
@@ -253,8 +263,8 @@ export class RecommendationService {
return this.generateRecommendations({
platform_id: platformId,
categories: [primaryCategory],
limit: 5
})
limit: 5,
});
}
/**
@@ -265,30 +275,30 @@ export class RecommendationService {
categories: UserCategory[],
totalLimit: number = 20
): Promise<RecommendedPackage[]> {
const perCategory = Math.ceil(totalLimit / categories.length)
const allRecommendations: 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)
limit: perCategory,
});
allRecommendations.push(...recommendations);
}
// Deduplicate by ID and re-sort
const seen = new Set<string>()
const deduplicated = allRecommendations.filter(pkg => {
const seen = new Set<string>();
const deduplicated = allRecommendations.filter((pkg) => {
if (seen.has(pkg.id)) {
return false
return false;
}
seen.add(pkg.id)
return true
})
deduplicated.sort((a, b) => b.recommendationScore - a.recommendationScore)
seen.add(pkg.id);
return true;
});
return deduplicated.slice(0, totalLimit)
deduplicated.sort((a, b) => b.recommendationScore - a.recommendationScore);
return deduplicated.slice(0, totalLimit);
}
}
+54 -54
View File
@@ -1,99 +1,99 @@
import { Package, Platform } from './index'
import { Package, Platform } from "./index";
/**
* User category types for package recommendations
*/
export type UserCategory =
| 'development'
| 'design'
| 'multimedia'
| 'system-tools'
| 'gaming'
| 'productivity'
| 'education'
export type UserCategory =
| "development"
| "design"
| "multimedia"
| "system-tools"
| "gaming"
| "productivity"
| "education";
/**
* User experience level
*/
export type ExperienceLevel = 'beginner' | 'intermediate' | 'advanced'
export type ExperienceLevel = "beginner" | "intermediate" | "advanced";
/**
* User profile stored in localStorage
*/
export interface UserProfile {
categories: UserCategory[]
detectedOS?: string
selectedOS?: string // Manual override
experienceLevel?: ExperienceLevel
hasCompletedOnboarding: boolean
createdAt: string
lastUpdated: string
categories: UserCategory[];
detectedOS?: string;
selectedOS?: string; // Manual override
experienceLevel?: ExperienceLevel;
hasCompletedOnboarding: boolean;
createdAt: string;
lastUpdated: string;
}
/**
* Request payload for recommendation API
*/
export interface RecommendationRequest {
platform_id: string
categories: UserCategory[]
experienceLevel?: ExperienceLevel
limit?: number
platform_id: string;
categories: UserCategory[];
experienceLevel?: ExperienceLevel;
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?: string | any
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
id: string;
name: string;
description: string;
version: string;
category?: string;
license?: string;
type: "gui" | "cli";
platform?: string | any;
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;
}
/**
* 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
experienceLevel?: ExperienceLevel[] // Target experience levels
packageName: string;
platforms: string[]; // ['windows', 'macos', 'ubuntu', 'arch', 'fedora']
priority: number; // 1-10, higher = more important
reason: string; // Why this package is recommended
experienceLevel?: ExperienceLevel[]; // Target experience levels
}
/**
* Category preset configuration
*/
export interface CategoryPreset {
category: UserCategory
packages: PackagePreset[]
description: string
icon: string
category: UserCategory;
packages: PackagePreset[];
description: string;
icon: string;
}
/**
* Recommendation response
*/
export interface RecommendationResponse {
recommendations: RecommendedPackage[]
total: number
recommendations: RecommendedPackage[];
total: number;
userProfile: {
categories: UserCategory[]
platform: string
}
categories: UserCategory[];
platform: string;
};
}