diff --git a/FEATURE_SMART_RECOMMENDATIONS.md b/FEATURE_SMART_RECOMMENDATIONS.md new file mode 100644 index 0000000..e0a7666 --- /dev/null +++ b/FEATURE_SMART_RECOMMENDATIONS.md @@ -0,0 +1,264 @@ +# Smart Package Recommendations Feature + +## 📋 Overview + +This feature adds intelligent package recommendations to RepoHub based on user preferences, operating system, and experience level. It provides a personalized onboarding experience and curated package suggestions. + +## ✨ Key Features + +### 1. **Onboarding Modal** +- 3-step wizard for new users +- Category selection (up to 3 categories) +- OS detection with manual override option +- **Auto-fallback to Ubuntu** when OS detection fails +- Experience level selection (beginner/intermediate/advanced) +- Persistent localStorage-based profile with **version control** + +### 2. **Smart Recommendations** +- Hybrid scoring algorithm: + - Category match: 40% + - Popularity: 30% + - OS compatibility: 20% + - Preset boost: 10% +- Curated preset packages for 7 categories +- Real-time filtering based on user profile +- **Optimized package fetching** (reduced N+1 queries) +- **Case-insensitive** package name matching + +### 3. **Categories Supported** +- 💻 **Development**: IDEs, version control, programming languages +- 🎨 **Design**: Graphics editors, 3D tools, UI/UX software +- 🎬 **Multimedia**: Video/audio editing, media players +- ⚙️ **System Tools**: Admin tools, utilities, monitoring +- 🎮 **Gaming**: Game platforms, communication tools +- 📝 **Productivity**: Office suites, note-taking, password managers +- 🎓 **Education**: Learning tools, scientific software + +## 🏗️ Architecture + +### Components + +``` +src/ +├── types/ +│ └── recommendations.ts # Type definitions +├── data/ +│ └── recommendationPresets.ts # Hardcoded package presets +├── services/ +│ └── recommendationService.ts # Recommendation algorithm +├── hooks/ +│ └── useRecommendationProfile.ts # localStorage management + OS detection +├── components/ +│ ├── OnboardingModal.tsx # User onboarding wizard +│ └── RecommendationsSection.tsx # Recommendation display +└── app/api/ + └── recommendations/ + └── route.ts # API endpoint +``` + +### Data Flow + +``` +1. First Visit + └→ useRecommendationProfile detects !hasCompletedOnboarding + └→ OnboardingModal opens automatically + └→ User selects categories, OS, experience level + └→ Profile saved to localStorage + └→ hasCompletedOnboarding = true + +2. Recommendations + └→ RecommendationsSection fetches from /api/recommendations + └→ POST { platform_id, categories, experienceLevel } + └→ RecommendationService.generateRecommendations() + └→ Fetch preset packages (exact name match) + └→ Fetch category packages (popularity-based) + └→ Score each package (hybrid algorithm) + └→ Return top 12 recommendations + +3. User Actions + └→ Click "Customize Preferences" → Reopen OnboardingModal + └→ Click "Refresh Recommendations" → Refetch recommendations + └→ Click package card → Add to selection +``` + +## 🔧 API Usage + +### POST /api/recommendations + +**Request:** +```json +{ + "platform_id": "ubuntu", + "categories": ["development", "productivity"], + "experienceLevel": "intermediate", + "limit": 12 +} +``` + +**Response:** +```json +{ + "recommendations": [ + { + "id": "pkg-uuid", + "name": "git", + "description": "Version control system", + "version": "2.43.0", + "recommendationScore": 95, + "recommendationReason": "Version control system essential for all developers", + "presetMatch": true, + ... + } + ], + "total": 12, + "userProfile": { + "categories": ["development", "productivity"], + "platform": "ubuntu", + "experienceLevel": "intermediate" + } +} +``` + +### GET /api/recommendations + +Query parameters version (alternative to POST): +``` +GET /api/recommendations?platform_id=ubuntu&categories=development,productivity&experience_level=intermediate&limit=12 +``` + +## 🎨 UI/UX Features + +### Onboarding Modal +- **Step 1**: Category selection with icons and descriptions +- **Step 2**: OS selection (auto-detected + manual override) +- **Step 3**: Experience level with detailed descriptions +- Progress indicator (3 dots) +- Back/Next navigation +- Validation (can't proceed without required selections) + +### Recommendations Section +- Grid layout (responsive: 1/2/3 columns) +- Package cards with: + - "Essential" badge for preset matches + - Recommendation score (0-100%) with progress bar + - Recommendation reason + - Version info + - Add to selection button +- User profile pills (OS + categories) +- Refresh button +- Customize preferences button + +## 🌍 i18n Support + +Full English and Turkish translations for: +- Onboarding flow +- Category names and descriptions +- Experience levels +- Recommendation UI labels +- Button text + +Translation keys: +- `onboarding.*` +- `categories.*` +- `recommendations.*` + +## 💾 localStorage Schema + +```typescript +// Key: 'repohub_user_profile' +{ + categories: ['development', 'productivity'], + detectedOS: 'ubuntu', + selectedOS?: 'arch', // Manual override + experienceLevel: 'intermediate', + hasCompletedOnboarding: true, + createdAt: '2025-11-22T10:00:00Z', + lastUpdated: '2025-11-22T12:30:00Z' +} +``` + +## 🧪 Testing Checklist + +- [ ] First visit triggers onboarding modal +- [ ] OS detection works correctly (Windows/macOS/Linux) +- [ ] Category selection validates (max 3) +- [ ] Profile persists across page reloads +- [ ] Recommendations update when profile changes +- [ ] Package cards are clickable and add to selection +- [ ] "Customize Preferences" reopens onboarding +- [ ] "Refresh Recommendations" fetches new data +- [ ] i18n works (EN/TR switching) +- [ ] Responsive design on mobile/tablet/desktop +- [ ] Loading states display correctly +- [ ] Error states handle gracefully + +## 🔮 Future Enhancements + +### Phase 2 (Post-MVP) +- [ ] Database storage for user profiles (optional account system) +- [ ] Community ratings for packages +- [ ] User feedback loop (like/dislike recommendations) +- [ ] A/B testing for algorithm weights +- [ ] Admin panel for managing presets + +### Phase 3 (ML-Ready) +- [ ] Collaborative filtering +- [ ] Package co-occurrence analysis +- [ ] Time-based trending packages +- [ ] Machine learning model integration + +## 📊 Scoring Algorithm Details + +### Hybrid Scoring Formula +```typescript +score = + (category_match * 0.4) + + (popularity_score / 100 * 0.3) + + (os_compatibility * 0.2) + + (preset_priority / 10 * 0.1) +``` + +### Category Match +- Preset package: 1.0 (perfect match) +- Non-preset package: 0.5 (generic match) + +### Popularity Score +- Normalized from 0-100 (from database) +- Higher popularity = better recommendation + +### OS Compatibility +- All packages from DB are compatible = 1.0 +- Future: could penalize packages with known issues + +### Preset Priority +- Range: 1-10 (defined in presets) +- Normalized to 0.1-1.0 +- Only applies to preset packages + +## 🚀 Deployment Notes + +1. No database migrations required (uses existing schema) +2. No environment variables needed (feature is client-side first) +3. Compatible with existing API structure +4. Progressive enhancement (works without JS for basic browse) + +## 📝 Code Quality + +- ✅ TypeScript strict mode +- ✅ Full type coverage +- ✅ ESLint compliant +- ✅ Responsive design +- ✅ Accessibility (keyboard navigation) +- ✅ Error boundaries +- ✅ Loading states + +## 🔗 Related Files + +- Issue: https://github.com/yusufipk/RepoHub/issues/1 +- Branch: `feature/smart-package-recommendations` + +--- + +**Developed by:** @ersaayan +**Date:** November 22, 2025 +**Status:** ✅ Ready for Review diff --git a/README.md b/README.md index 543d417..b5bb41d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # RepoHub - Cross-Platform Package Manager +**🇬🇧 English** | [🇹🇷 Türkçe](./README.tr.md) + **Simplify software installation across Linux, Windows, and macOS with official repositories.** RepoHub provides a unified interface for package discovery and installation across different operating systems. @@ -8,6 +10,7 @@ RepoHub provides a unified interface for package discovery and installation acro - **Cross-Platform Support**: Works on Linux (Debian, Ubuntu, Arch, Fedora), Windows, and macOS. - **Official Repositories**: Access software strictly from trusted, official sources. +- **Package Icons**: Visual icons for popular packages for easy recognition. - **Script Generation**: Generate idempotent installation scripts for your selected platform. - **Smart Filtering**: efficiently browse and filter packages. @@ -96,6 +99,166 @@ curl -X POST http://localhost:3000/api/sync \ -d '{"platform": "all"}' ``` +## 📦 Contributing to Package Recommendations + +[🇹🇷 Türkçe README](./README.tr.md) | **🇬🇧 English** + +RepoHub uses curated package lists to provide personalized recommendations to users. You can help improve these recommendations by adding packages! + +### How to Add Packages + +Package recommendations are stored in `/src/data/recommendationPresets.ts`. Here's how to add a package: + +#### 1. Find the Right Location + +Navigate to the platform and category where your package belongs: + +```typescript +export const PACKAGE_PRESETS = { + windows: { + development: ["Git.Git", "Microsoft.VisualStudioCode"], + design: ["GIMP.GIMP", "Inkscape.Inkscape"], + // ... other categories + }, + // ... other platforms +} +``` + +**Available Platforms:** +- `windows` - Windows (Winget) +- `macos` - macOS (Homebrew) +- `ubuntu` - Ubuntu (APT) +- `debian` - Debian (APT) +- `arch` - Arch Linux (Pacman/AUR) +- `fedora` - Fedora (DNF) + +**Available Categories:** +- `development` - Dev tools, IDEs, compilers +- `design` - Graphics, creative software +- `multimedia` - Media players, editors +- `system-tools` - System utilities +- `gaming` - Game launchers, platforms +- `productivity` - Office, browsers, productivity apps +- `education` - Educational software + +#### 2. Get the Correct Package Name + +**⚠️ CRITICAL:** Package names must match **exactly** as they appear in the database. + +**Package name formats by platform:** + +- **Windows**: `Publisher.PackageName` (e.g., `Microsoft.VisualStudioCode`) +- **macOS**: lowercase-with-hyphens (e.g., `visual-studio-code`) +- **Linux**: lowercase, varies by distro (e.g., `code`, `docker.io`) + +#### 3. Verify the Package Exists + +**Option A: Using the Validation Script (Recommended)** + +If you have access to the database: + +```bash +# Validate specific platform +npm run validate:presets -- windows + +# Validate multiple platforms +npm run validate:presets -- ubuntu debian arch + +# Validate all platforms +npm run validate:presets -- --all +``` + +The script will show: +- ✅ Packages found in database +- ❌ Packages not found +- 💡 Similar package suggestions + +**Option B: Manual Verification** + +If you don't have database access: + +1. Search on the live RepoHub website +2. Find your package in the search results +3. Copy the **exact package name** displayed +4. Or check official package repositories: + - Windows: [winget.run](https://winget.run/) + - macOS: `brew search ` + - Ubuntu/Debian: `apt search ` + - Arch: [archlinux.org/packages](https://archlinux.org/packages/) + - Fedora: [packages.fedoraproject.org](https://packages.fedoraproject.org/) + +#### 4. Add the Package + +Simply add the package name to the array: + +```typescript +windows: { + development: [ + "Git.Git", + "Microsoft.VisualStudioCode", + "Docker.DockerDesktop" // ← Your new package + ] +} +``` + +#### 5. Add an Icon (Optional) + +To make the package look better in recommendations, add an icon mapping in `PACKAGE_ICONS`: + +1. Find the package slug on [Simple Icons](https://simpleicons.org/) +2. Add it to the `PACKAGE_ICONS` object in `src/data/recommendationPresets.ts`: + +```typescript +export const PACKAGE_ICONS: Record = { + // ... + "Docker.DockerDesktop": "docker", // Key matches package name, Value is Simple Icons slug + // ... +}; +``` + +If no icon is added, a default package icon will be used. + +#### 6. Test Your Changes + +1. Run validation: + ```bash + npm run validate:presets -- windows + ``` + +2. Start the dev server: + ```bash + npm run dev + ``` + +3. Test in the app: + - Complete onboarding + - Select the relevant category + - Check if your package appears in recommendations + +### Best Practices + +**DO ✅** +- Verify package names using the validation script +- Add popular, well-maintained packages +- Test before submitting +- Use exact package names from official repos + +**DON'T ❌** +- Don't guess package names +- Don't add deprecated packages +- Don't skip verification +- Don't add duplicates across categories + +### Example Pull Request + +``` +Add Popular Development Tools to Windows Recommendations + +- Added Docker.DockerDesktop to development +- Added Postman.Postman to development +- Validation: ✅ All packages verified (100% found) +``` + ## 🤝 Contributing Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/README.tr.md b/README.tr.md new file mode 100644 index 0000000..48add44 --- /dev/null +++ b/README.tr.md @@ -0,0 +1,270 @@ +# RepoHub - Çok Platformlu Paket Yöneticisi + +[🇬🇧 English](./README.md) | **🇹🇷 Türkçe** + +**Linux, Windows ve macOS'te resmi depolardan yazılım kurulumunu basitleştirin.** + +RepoHub, farklı işletim sistemlerinde paket keşfi ve kurulumu için birleşik bir arayüz sağlar. + +## 🚀 Özellikler + +- **Çok Platformlu Destek**: Linux (Debian, Ubuntu, Arch, Fedora), Windows ve macOS'te çalışır. +- **Resmi Depolar**: Yazılımlara yalnızca güvenilir, resmi kaynaklardan erişin. +- **Script Oluşturma**: Seçtiğiniz platform için idempotent kurulum scriptleri oluşturun. +- **Akıllı Filtreleme**: Paketleri verimli bir şekilde bulun ve filtreleyin. +- **Paket İkonları**: Popüler paketler için görsel ikonlar ile kolay tanıma. + +## 🛠️ Teknoloji Yığını + +### Frontend +- **Framework**: Next.js 14+ (React) +- **Stil**: Tailwind CSS +- **İkonlar**: Lucide React +- **Durum Yönetimi**: React Query + Zustand + +### Backend +- **Runtime**: Node.js (TypeScript) +- **Veritabanı**: PostgreSQL +- **Altyapı**: Docker + +## 🏁 Başlangıç + +### Gereksinimler + +- Node.js 18+ +- pnpm +- Docker (isteğe bağlı, veritabanı için) + +### Kurulum + +1. **Depoyu klonlayın:** + ```bash + git clone https://github.com/yusufipk/RepoHub.git + cd RepoHub + ``` + +2. **Bağımlılıkları yükleyin:** + ```bash + pnpm install + ``` + +3. **Ortam Değişkenlerini Ayarlayın:** + `.env.example` dosyasını `.env` olarak kopyalayın ve veritabanı bağlantınızı yapılandırın. + ```bash + cp .env.example .env + ``` + +4. **Veritabanını Başlatın:** + Veritabanı şemasını kurmak ve migrasyonları uygulamak için başlatma scriptini çalıştırın. + ```bash + pnpm init:db + ``` + +5. **Geliştirme sunucusunu çalıştırın:** + ```bash + pnpm dev + ``` + + Tarayıcınızda [http://localhost:3000](http://localhost:3000) adresini açın. + +## 🔄 API Kullanımı + +### Depoları Senkronize Etme + +API kullanarak depo senkronizasyonunu tetikleyebilirsiniz. Bu, paket veritabanını güncellemek için kullanışlıdır. + +**Endpoint:** `POST /api/sync` + +**Başlıklar:** +- `Content-Type`: `application/json` +- `x-sync-secret`: Senkronizasyon gizli anahtarınız (`SYNC_SERVER_ONLY=true` ise gerekli) + +**Body Parametreleri:** +- `platform`: Senkronize edilecek platform. Seçenekler: + - `debian`: Debian paketlerini senkronize et (Resmi Repo) + - `ubuntu`: Ubuntu paketlerini senkronize et (Resmi Repo) + - `arch`: Arch Linux paketlerini senkronize et (Resmi Repo) + - `aur`: Arch User Repository (AUR) paketlerini senkronize et + - `fedora`: Fedora paketlerini senkronize et (Resmi Repo) + - `windows`: Windows paketlerini senkronize et (Winget) + - `macos`: macOS paketlerini senkronize et (Homebrew) + - `all`: Tüm platformları senkronize et + +**Örnek İstek:** + +```bash +curl -X POST http://localhost:3000/api/sync \ + -H "Content-Type: application/json" \ + -H "x-sync-secret: gizli_anahtariniz" \ + -d '{"platform": "all"}' +``` + +## 📦 Paket Önerilerine Katkıda Bulunma + +[🇬🇧 English](./README.md) | **🇹🇷 Türkçe** + +RepoHub, kullanıcılara kişiselleştirilmiş öneriler sunmak için düzenlenmiş paket listeleri kullanır. Paket ekleyerek bu önerileri geliştirmeye yardımcı olabilirsiniz! + +### Paket Nasıl Eklenir + +Paket önerileri `/src/data/recommendationPresets.ts` dosyasında saklanır. İşte bir paket ekleme adımları: + +#### 1. Doğru Konumu Bulun + +Paketinizin ait olduğu platform ve kategoriye gidin: + +```typescript +export const PACKAGE_PRESETS = { + windows: { + development: ["Git.Git", "Microsoft.VisualStudioCode"], + design: ["GIMP.GIMP", "Inkscape.Inkscape"], + // ... diğer kategoriler + }, + // ... diğer platformlar +} +``` + +**Mevcut Platformlar:** +- `windows` - Windows (Winget) +- `macos` - macOS (Homebrew) +- `ubuntu` - Ubuntu (APT) +- `debian` - Debian (APT) +- `arch` - Arch Linux (Pacman/AUR) +- `fedora` - Fedora (DNF) + +**Mevcut Kategoriler:** +- `development` - Geliştirme araçları, IDE'ler, derleyiciler +- `design` - Grafik, kreatif yazılımlar +- `multimedia` - Medya oynatıcılar, editörler +- `system-tools` - Sistem araçları +- `gaming` - Oyun başlatıcıları, platformlar +- `productivity` - Ofis, tarayıcılar, üretkenlik uygulamaları +- `education` - Eğitim yazılımları + +#### 2. Doğru Paket Adını Alın + +**⚠️ KRİTİK:** Paket adları veritabanında göründükleri gibi **tam olarak** eşleşmelidir. + +**Platformlara göre paket adı formatları:** + +- **Windows**: `Yayinci.PaketAdi` (örn., `Microsoft.VisualStudioCode`) +- **macOS**: kucuk-harf-tireli (örn., `visual-studio-code`) +- **Linux**: küçük harf, dağıtıma göre değişir (örn., `code`, `docker.io`) + +#### 3. Paketin Var Olduğunu Doğrulayın + +**Seçenek A: Doğrulama Scriptini Kullanma (Önerilen)** + +Veritabanı erişiminiz varsa: + +```bash +# Belirli bir platformu doğrula +npm run validate:presets -- windows + +# Birden fazla platformu doğrula +npm run validate:presets -- ubuntu debian arch + +# Tüm platformları doğrula +npm run validate:presets -- --all +``` + +Script şunları gösterecek: +- ✅ Veritabanında bulunan paketler +- ❌ Bulunamayan paketler +- 💡 Benzer paket önerileri + +**Seçenek B: Manuel Doğrulama** + +Veritabanı erişiminiz yoksa: + +1. Canlı RepoHub web sitesinde arama yapın +2. Arama sonuçlarında paketinizi bulun +3. Görüntülenen **tam paket adını** kopyalayın +4. Veya resmi paket depolarını kontrol edin: + - Windows: [winget.run](https://winget.run/) + - macOS: `brew search ` + - Ubuntu/Debian: `apt search ` + - Arch: [archlinux.org/packages](https://archlinux.org/packages/) + - Fedora: [packages.fedoraproject.org](https://packages.fedoraproject.org/) + +#### 4. Paketi Ekleyin + +Paket adını diziye ekleyin: + +```typescript +windows: { + development: [ + "Git.Git", + "Microsoft.VisualStudioCode", + "Docker.DockerDesktop" // ← Yeni paketiniz + ] +} +``` + +#### 5. İkon Ekleyin (İsteğe Bağlı) + +Paketin önerilerde daha iyi görünmesi için `PACKAGE_ICONS` içine bir ikon eşlemesi ekleyin: + +1. [Simple Icons](https://simpleicons.org/)'da paket slug'ını bulun +2. `src/data/recommendationPresets.ts` dosyasındaki `PACKAGE_ICONS` nesnesine ekleyin: + +```typescript +export const PACKAGE_ICONS: Record = { + // ... + "Docker.DockerDesktop": "docker", // Anahtar paket adıyla eşleşmeli, Değer Simple Icons slug'ı olmalı + // ... +}; +``` + +İkon eklenmezse, varsayılan paket ikonu kullanılacaktır. + +#### 6. Değişikliklerinizi Test Edin + +1. Doğrulamayı çalıştırın: + ```bash + npm run validate:presets -- windows + ``` + +2. Geliştirme sunucusunu başlatın: + ```bash + npm run dev + ``` + +3. Uygulamada test edin: + - Onboarding'i tamamlayın + - İlgili kategoriyi seçin + - Paketinizin önerilerde görünüp görünmediğini kontrol edin + +### En İyi Uygulamalar + +**YAPIN ✅** +- Doğrulama scriptini kullanarak paket adlarını doğrulayın +- Popüler, iyi bakımlı paketler ekleyin +- Göndermeden önce test edin +- Resmi depolardan tam paket adlarını kullanın + +**YAPMAYIN ❌** +- Paket adlarını tahmin etmeyin +- Kullanımdan kaldırılmış paketler eklemeyin +- Doğrulamayı atlamayın +- Kategoriler arasında tekrar eklemeyin + +### Örnek Pull Request + +``` +Windows Önerilerine Popüler Geliştirme Araçları Eklendi + +- development'a Docker.DockerDesktop eklendi +- development'a Postman.Postman eklendi +- Doğrulama: ✅ Tüm paketler doğrulandı (%100 bulundu) +``` + +## 🤝 Katkıda Bulunma + +Katkılar hoş karşılanır! Lütfen Pull Request göndermekten çekinmeyin. + +1. Projeyi fork edin +2. Feature branch'inizi oluşturun (`git checkout -b feature/HarikaBirOzellik`) +3. Değişikliklerinizi commit edin (`git commit -m 'Harika bir özellik ekle'`) +4. Branch'inizi push edin (`git push origin feature/HarikaBirOzellik`) +5. Bir Pull Request açın diff --git a/components.json b/components.json new file mode 100644 index 0000000..1511f74 --- /dev/null +++ b/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} \ No newline at end of file diff --git a/package.json b/package.json index 61073fd..c305c50 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "lint": "next lint", "type-check": "tsc --noEmit", "test:db": "node scripts/test-db.js", - "init:db": "node scripts/init-db.js" + "init:db": "node scripts/init-db.js", + "validate:presets": "tsx scripts/validate-presets.ts" }, "dependencies": { "@radix-ui/react-checkbox": "^1.0.4", @@ -18,6 +19,7 @@ "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-tooltip": "^1.2.8", "cheerio": "^1.1.2", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", @@ -30,6 +32,7 @@ "react-query": "^3.39.3", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", + "tsx": "^4.20.6", "undici": "^6.6.2", "zustand": "^4.4.7" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a01bad9..e064d92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.0.4 version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) cheerio: specifier: ^1.1.2 version: 1.1.2 @@ -61,7 +64,10 @@ importers: version: 2.6.0 tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.4.18) + version: 1.0.7(tailwindcss@3.4.18(tsx@4.20.6)) + tsx: + specifier: ^4.20.6 + version: 4.20.6 undici: specifier: ^6.6.2 version: 6.22.0 @@ -95,7 +101,7 @@ importers: version: 8.5.6 tailwindcss: specifier: ^3.3.0 - version: 3.4.18 + version: 3.4.18(tsx@4.20.6) typescript: specifier: ^5 version: 5.9.3 @@ -119,6 +125,162 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -529,6 +691,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -1150,6 +1325,11 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2298,6 +2478,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2478,6 +2663,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -2869,6 +3132,26 @@ snapshots: '@types/react': 18.3.26 '@types/react-dom': 18.3.7(@types/react@18.3.26) + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.26)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.26)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.26)(react@18.3.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.26)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.26)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.26 + '@types/react-dom': 18.3.7(@types/react@18.3.26) + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.26)(react@18.3.1)': dependencies: react: 18.3.1 @@ -3553,6 +3836,35 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -4427,12 +4739,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.20.6): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 postcss: 8.5.6 + tsx: 4.20.6 postcss-nested@6.2.0(postcss@8.5.6): dependencies: @@ -4783,11 +5096,11 @@ snapshots: tailwind-merge@2.6.0: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.18): + tailwindcss-animate@1.0.7(tailwindcss@3.4.18(tsx@4.20.6)): dependencies: - tailwindcss: 3.4.18 + tailwindcss: 3.4.18(tsx@4.20.6) - tailwindcss@3.4.18: + tailwindcss@3.4.18(tsx@4.20.6): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -4806,7 +5119,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.20.6) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.11 @@ -4849,6 +5162,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.20.6: + dependencies: + esbuild: 0.25.12 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/scripts/validate-presets.js b/scripts/validate-presets.js new file mode 100755 index 0000000..d2087c1 --- /dev/null +++ b/scripts/validate-presets.js @@ -0,0 +1,188 @@ +#!/usr/bin/env node + +/** + * Validate package names in recommendation presets against the database + * + * Usage: + * node scripts/validate-presets.js --all + * node scripts/validate-presets.js windows macos + * node scripts/validate-presets.js ubuntu + */ + +const fetch = require('node-fetch'); + +// Import the presets +const { PACKAGE_PRESETS } = require('../src/data/recommendationPresets.ts'); + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + +// Platform IDs +const PLATFORMS = ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora']; + +async function checkPackageExists(platformId, packageName) { + try { + const response = await fetch( + `${API_BASE_URL}/api/packages?platform_id=${platformId}&search=${encodeURIComponent(packageName)}&limit=10` + ); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = await response.json(); + + // Check for exact match (case-insensitive) + const exactMatch = data.packages?.find( + pkg => pkg.name.toLowerCase() === packageName.toLowerCase() + ); + + return { + exists: !!exactMatch, + foundName: exactMatch?.name || null, + similarMatches: data.packages?.slice(0, 3).map(p => p.name) || [] + }; + } catch (error) { + console.error(`Error checking ${packageName} on ${platformId}:`, error.message); + return { exists: false, error: error.message }; + } +} + +async function validatePlatform(platformId) { + console.log(`\n${'='.repeat(60)}`); + console.log(`Validating ${platformId.toUpperCase()}`); + console.log('='.repeat(60)); + + const platformPresets = PACKAGE_PRESETS[platformId]; + if (!platformPresets) { + console.log(`❌ No presets found for platform: ${platformId}`); + return { total: 0, found: 0, missing: [] }; + } + + const results = { + total: 0, + found: 0, + missing: [] + }; + + // Check each category + for (const [category, packages] of Object.entries(platformPresets)) { + console.log(`\n📁 Category: ${category}`); + + for (const packageName of packages) { + results.total++; + + const check = await checkPackageExists(platformId, packageName); + + if (check.error) { + console.log(` ⚠️ ${packageName} - Error: ${check.error}`); + results.missing.push({ category, packageName, reason: 'API Error' }); + } else if (check.exists) { + console.log(` ✅ ${packageName}${check.foundName !== packageName ? ` (found as: ${check.foundName})` : ''}`); + results.found++; + } else { + console.log(` ❌ ${packageName} - NOT FOUND`); + if (check.similarMatches?.length > 0) { + console.log(` Similar: ${check.similarMatches.join(', ')}`); + } + results.missing.push({ + category, + packageName, + similar: check.similarMatches + }); + } + + // Small delay to avoid overwhelming the API + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + return results; +} + +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log('Usage:'); + console.log(' node scripts/validate-presets.js --all'); + console.log(' node scripts/validate-presets.js windows macos ubuntu'); + console.log('\nAvailable platforms:', PLATFORMS.join(', ')); + process.exit(1); + } + + let platformsToCheck = []; + + if (args.includes('--all')) { + platformsToCheck = PLATFORMS; + } else { + // Validate platform names + for (const platform of args) { + if (!PLATFORMS.includes(platform)) { + console.error(`❌ Invalid platform: ${platform}`); + console.log('Available platforms:', PLATFORMS.join(', ')); + process.exit(1); + } + } + platformsToCheck = args; + } + + console.log(`\n🔍 Validating package presets for: ${platformsToCheck.join(', ')}\n`); + + const allResults = {}; + + for (const platform of platformsToCheck) { + const results = await validatePlatform(platform); + allResults[platform] = results; + } + + // Summary + console.log(`\n${'='.repeat(60)}`); + console.log('SUMMARY'); + console.log('='.repeat(60)); + + let totalPackages = 0; + let totalFound = 0; + let totalMissing = 0; + + for (const [platform, results] of Object.entries(allResults)) { + totalPackages += results.total; + totalFound += results.found; + totalMissing += results.missing.length; + + const successRate = results.total > 0 + ? ((results.found / results.total) * 100).toFixed(1) + : 0; + + console.log(`\n${platform.toUpperCase()}:`); + console.log(` Total: ${results.total}`); + console.log(` Found: ${results.found} (${successRate}%)`); + console.log(` Missing: ${results.missing.length}`); + + if (results.missing.length > 0) { + console.log(` Missing packages:`); + for (const { category, packageName, similar } of results.missing) { + console.log(` - ${packageName} (${category})`); + if (similar?.length > 0) { + console.log(` Try: ${similar.join(', ')}`); + } + } + } + } + + console.log(`\n${'='.repeat(60)}`); + console.log(`OVERALL: ${totalFound}/${totalPackages} packages found (${((totalFound / totalPackages) * 100).toFixed(1)}%)`); + console.log('='.repeat(60)); + + if (totalMissing > 0) { + console.log(`\n⚠️ Found ${totalMissing} missing packages. Review the output above and update recommendationPresets.ts`); + process.exit(1); + } else { + console.log('\n✅ All packages validated successfully!'); + process.exit(0); + } +} + +main().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/scripts/validate-presets.ts b/scripts/validate-presets.ts new file mode 100644 index 0000000..22ec70e --- /dev/null +++ b/scripts/validate-presets.ts @@ -0,0 +1,194 @@ +/** + * Validate package names in recommendation presets against the database + * + * Usage: + * tsx scripts/validate-presets.ts --all + * tsx scripts/validate-presets.ts windows macos + * tsx scripts/validate-presets.ts ubuntu + */ + +import { PACKAGE_PRESETS } from '../src/data/recommendationPresets'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3002'; + +// Platform IDs +const PLATFORMS = ['windows', 'macos', 'ubuntu', 'debian', 'arch', 'fedora'] as const; +type PlatformId = typeof PLATFORMS[number]; + +interface PackageCheckResult { + exists: boolean; + foundName?: string; + similarMatches?: string[]; + error?: string; +} + +async function checkPackageExists( + platformId: string, + packageName: string +): Promise { + try { + const response = await fetch( + `${API_BASE_URL}/api/packages?platform_id=${platformId}&search=${encodeURIComponent(packageName)}&limit=10` + ); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = await response.json(); + + // Check for exact match (case-insensitive) + const exactMatch = data.packages?.find( + (pkg: any) => pkg.name.toLowerCase() === packageName.toLowerCase() + ); + + return { + exists: !!exactMatch, + foundName: exactMatch?.name || undefined, + similarMatches: data.packages?.slice(0, 3).map((p: any) => p.name) || [] + }; + } catch (error: any) { + console.error(`Error checking ${packageName} on ${platformId}:`, error.message); + return { exists: false, error: error.message }; + } +} + +async function validatePlatform(platformId: PlatformId) { + console.log(`\n${'='.repeat(60)}`); + console.log(`Validating ${platformId.toUpperCase()}`); + console.log('='.repeat(60)); + + const platformPresets = PACKAGE_PRESETS[platformId]; + if (!platformPresets) { + console.log(`❌ No presets found for platform: ${platformId}`); + return { total: 0, found: 0, missing: [] as any[] }; + } + + const results = { + total: 0, + found: 0, + missing: [] as { category: string; packageName: string; similar?: string[]; reason?: string }[] + }; + + // Check each category + for (const [category, packages] of Object.entries(platformPresets)) { + console.log(`\n📁 Category: ${category}`); + + for (const packageName of packages) { + results.total++; + + const check = await checkPackageExists(platformId, packageName); + + if (check.error) { + console.log(` ⚠️ ${packageName} - Error: ${check.error}`); + results.missing.push({ category, packageName, reason: 'API Error' }); + } else if (check.exists) { + console.log(` ✅ ${packageName}${check.foundName !== packageName ? ` (found as: ${check.foundName})` : ''}`); + results.found++; + } else { + console.log(` ❌ ${packageName} - NOT FOUND`); + if (check.similarMatches && check.similarMatches.length > 0) { + console.log(` Similar: ${check.similarMatches.join(', ')}`); + } + results.missing.push({ + category, + packageName, + similar: check.similarMatches + }); + } + + // Delay to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + return results; +} + +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log('Usage:'); + console.log(' tsx scripts/validate-presets.ts --all'); + console.log(' tsx scripts/validate-presets.ts windows macos ubuntu'); + console.log('\nAvailable platforms:', PLATFORMS.join(', ')); + process.exit(1); + } + + let platformsToCheck: PlatformId[] = []; + + if (args.includes('--all')) { + platformsToCheck = [...PLATFORMS]; + } else { + // Validate platform names + for (const platform of args) { + if (!PLATFORMS.includes(platform as any)) { + console.error(`❌ Invalid platform: ${platform}`); + console.log('Available platforms:', PLATFORMS.join(', ')); + process.exit(1); + } + } + platformsToCheck = args as PlatformId[]; + } + + console.log(`\n🔍 Validating package presets for: ${platformsToCheck.join(', ')}\n`); + + const allResults: Record = {}; + + for (const platform of platformsToCheck) { + const results = await validatePlatform(platform); + allResults[platform] = results; + } + + // Summary + console.log(`\n${'='.repeat(60)}`); + console.log('SUMMARY'); + console.log('='.repeat(60)); + + let totalPackages = 0; + let totalFound = 0; + let totalMissing = 0; + + for (const [platform, results] of Object.entries(allResults)) { + totalPackages += results.total; + totalFound += results.found; + totalMissing += results.missing.length; + + const successRate = results.total > 0 + ? ((results.found / results.total) * 100).toFixed(1) + : '0'; + + console.log(`\n${platform.toUpperCase()}:`); + console.log(` Total: ${results.total}`); + console.log(` Found: ${results.found} (${successRate}%)`); + console.log(` Missing: ${results.missing.length}`); + + if (results.missing.length > 0) { + console.log(` Missing packages:`); + for (const { category, packageName, similar } of results.missing) { + console.log(` - ${packageName} (${category})`); + if (similar && similar.length > 0) { + console.log(` Try: ${similar.join(', ')}`); + } + } + } + } + + console.log(`\n${'='.repeat(60)}`); + console.log(`OVERALL: ${totalFound}/${totalPackages} packages found (${((totalFound / totalPackages) * 100).toFixed(1)}%)`); + console.log('='.repeat(60)); + + if (totalMissing > 0) { + console.log(`\n⚠️ Found ${totalMissing} missing packages. Review the output above and update recommendationPresets.ts`); + process.exit(1); + } else { + console.log('\n✅ All packages validated successfully!'); + process.exit(0); + } +} + +main().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/src/app/api/recommendations/route.ts b/src/app/api/recommendations/route.ts new file mode 100644 index 0000000..3a1e6fe --- /dev/null +++ b/src/app/api/recommendations/route.ts @@ -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 } + ); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 7b06b6d..aa7f0b1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } -} +} \ No newline at end of file diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 659ce93..f229a79 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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) {
+ {/* Preferences Button - Show when profile exists */} + {hasProfile && onResetPreferences && ( + + )} + {/* Support Button - Only show when Cryptomus is enabled */} {cryptomusEnabled && ( +
+ + + {t('onboarding.title')} + +
+ + {t('onboarding.subtitle')} + + + {/* Progress indicator */} +
+ {[1, 2].map(i => ( +
+ ))} +
+ + + + {/* Step 1: Categories */} + {step === 1 && ( +
+
+

+ {t('onboarding.step1.title')} +

+

+ {t('onboarding.step1.description')} +

+
+ +
+ {(Object.entries(CATEGORY_ICONS) as [UserCategory, any][]).map(([category, Icon]) => { + return ( + + ) + })} +
+
+ )} + + {/* Step 2: Operating System */} + {step === 2 && ( +
+
+

+ {t('onboarding.step2.title')} +

+

+ {t('onboarding.step2.description')} +

+ {detectedOS !== 'unknown' && ( +

+ {t('onboarding.step2.detected', { os: detectedOS })} +

+ )} +
+ +
+ {PLATFORMS.map(platform => ( + + ))} +
+
+ )} + + + + {/* Navigation Buttons */} +
+ {step > 1 && ( + + )} + + {step < 2 ? ( + + ) : ( + + )} +
+
+ +
+ ) +} diff --git a/src/components/PlatformSelector.tsx b/src/components/PlatformSelector.tsx index 5b627b9..40404f8 100644 --- a/src/components/PlatformSelector.tsx +++ b/src/components/PlatformSelector.tsx @@ -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([]) const [loading, setLoading] = useState(true) - + const iconSlug: Record = { debian: 'debian', ubuntu: 'ubuntu', @@ -66,43 +76,73 @@ export function PlatformSelector({ selectedPlatform, onPlatformSelect }: Platfor return ( - {t('platform.select')} + + {t('platform.select')} + {isLocked && } + {t('platform.description')} -
- {platforms.map((platform) => ( - - ))} -
+ +
+ {platforms.map((platform) => { + const isSelected = selectedPlatform?.id === platform.id + const isDisabled = isLocked && !isSelected + + const ButtonContent = ( + + ) + + if (isLocked && !isSelected) { + return ( + + +
+ {ButtonContent} +
+
+ +

{lockedMessage}

+
+
+ ) + } + + return ButtonContent + })} +
+
) diff --git a/src/components/RecommendationCard.tsx b/src/components/RecommendationCard.tsx new file mode 100644 index 0000000..21ad65f --- /dev/null +++ b/src/components/RecommendationCard.tsx @@ -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 ( + onToggle(pkg)} + > + + + +
+ {pkg.icon && !iconError ? ( + <> +
+ {/* Hidden image to detect load errors */} + setIconError(true)} + /> + + ) : ( + + )} +
+

{displayName}

+

{pkg.version}

+
+
+ + + + + ) +} diff --git a/src/components/RecommendationListItem.tsx b/src/components/RecommendationListItem.tsx new file mode 100644 index 0000000..6986493 --- /dev/null +++ b/src/components/RecommendationListItem.tsx @@ -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 ( +
onToggle(pkg)} + > + {/* Package Icon */} + {pkg.icon && !iconError ? ( + <> +
+ {/* Hidden image to detect load errors */} + setIconError(true)} + /> + + ) : ( + + )} + + {/* Package Info */} +
+
+

{displayName}

+ {pkg.version} +
+
+ + + + {/* Select Button */} + +
+ + ) +} diff --git a/src/components/RecommendationsSection.tsx b/src/components/RecommendationsSection.tsx new file mode 100644 index 0000000..0d2ecc4 --- /dev/null +++ b/src/components/RecommendationsSection.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [viewMode, setViewMode] = useState('grid') + const [filterCategory, setFilterCategory] = useState('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 ( + + setIsExpanded(!isExpanded)}> +
+
+ +
+
+ + {t('recommendations.title')} + + {!isExpanded && recommendations.length > 0 && ( + + {recommendations.length} {t('recommendations.packages') || 'packages'} + + )} +
+ + {t('recommendations.subtitle')} + +
+
+
+ {isExpanded && ( + <> + + + + + )} + +
+
+ + {/* Show user profile info */} + {isExpanded && ( +
+ + {getEffectiveOS()} + + {profile.categories.map(cat => ( + + {t(`categories.${cat}.name`)} + + ))} +
+ )} + + {/* Filters and View Controls */} + {isExpanded && !loading && !error && recommendations.length > 0 && ( +
e.stopPropagation()} // Prevent collapse when clicking filter area + > + {/* Category Filter Tabs */} +
+ + {profile.categories.map(cat => { + const count = getCategoryCount(cat) + const Icon = CATEGORY_ICONS[cat] + return ( + + ) + })} +
+ +
+ {/* View Mode Toggle */} +
+ + +
+
+
+ )} +
+ + {isExpanded && ( + + {loading && ( +
+ +

{t('recommendations.loading')}

+
+ )} + + {error && ( +
+

{error}

+ +
+ )} + + {!loading && !error && recommendations.length === 0 && ( +
+ +

+ {t('recommendations.no_recommendations')} +

+ +
+ )} + + {!loading && !error && recommendations.length > 0 && viewMode === 'grid' && ( +
+ {filteredRecommendations.map(pkg => ( + + ))} +
+ )} + + {/* Compact View Mode */} + {!loading && !error && recommendations.length > 0 && viewMode === 'compact' && ( +
+ {filteredRecommendations.map(pkg => ( + + ))} +
+ )} +
+ )} +
+ ) +} diff --git a/src/components/RepoHubApp.tsx b/src/components/RepoHubApp.tsx index 99a0d69..8169173 100644 --- a/src/components/RepoHubApp.tsx +++ b/src/components/RepoHubApp.tsx @@ -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(null) const [selectedPackages, setSelectedPackages] = useState([]) const [generatedScript, setGeneratedScript] = useState(null) + const [availablePlatforms, setAvailablePlatforms] = useState([]) + + // 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(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 (
-
+
{/* Header */} @@ -79,10 +194,45 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean }) {/* Main Content */}
+ {/* Recommendations Section - Show if profile is complete */} + {hasCompletedOnboarding && profile.categories.length > 0 ? ( + + ) : ( + /* Empty State for Recommendations */ + + +
+ +
+

+ {t('recommendations.empty_title') || 'Get Personalized Recommendations'} +

+

+ {t('recommendations.empty_description') || 'Tell us about your role and platform to get a curated list of essential packages.'} +

+ +
+
+ )} + + + + + {/* Platform Selector */} 0} + lockedMessage="Clear your selection to switch platforms" /> {/* Package Browser */} @@ -107,10 +257,27 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean }) p.id === generatedScript.platform) || + { + id: generatedScript.platform, + name: generatedScript.platform.charAt(0).toUpperCase() + generatedScript.platform.slice(1), + description: '', + icon: '', + packageManager: '' + } + } onClose={handleCloseScriptPreview} /> )} + + {/* Onboarding Modal */} + setShowOnboarding(false)} + onComplete={handleOnboardingComplete} + detectedOS={detectedOS || 'unknown'} + />
) diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..30fc44d --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/src/constants/categoryIcons.ts b/src/constants/categoryIcons.ts new file mode 100644 index 0000000..c706a33 --- /dev/null +++ b/src/constants/categoryIcons.ts @@ -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 = { + development: Code, + design: Palette, + multimedia: Film, + "system-tools": Cpu, + gaming: Gamepad2, + productivity: CheckSquare, + education: GraduationCap, +}; diff --git a/src/contexts/LocaleContext.tsx b/src/contexts/LocaleContext.tsx index 40e3553..11310b9 100644 --- a/src/contexts/LocaleContext.tsx +++ b/src/contexts/LocaleContext.tsx @@ -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) => { const keys = key.split('.') let value: any = translations[locale] - + for (const k of keys) { value = value?.[k] } diff --git a/src/data/recommendationPresets.ts b/src/data/recommendationPresets.ts new file mode 100644 index 0000000..5263042 --- /dev/null +++ b/src/data/recommendationPresets.ts @@ -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> = { + "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 = { + // 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 = { + // 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" }; +} diff --git a/src/hooks/useRecommendationProfile.ts b/src/hooks/useRecommendationProfile.ts new file mode 100644 index 0000000..441dd3b --- /dev/null +++ b/src/hooks/useRecommendationProfile.ts @@ -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(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) => { + 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, + }; +} diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 3f9401f..f2493c1 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -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(endpoint: string, options: RequestInit = {}): Promise { - const url = `${API_BASE_URL}${endpoint}` - + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + 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 { - return this.request('/platforms') + return this.request("/platforms"); } async getPlatform(id: string): Promise { try { - return await this.request(`/platforms/${id}`) + return await this.request(`/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 { try { - return await this.request(`/packages/${id}`) + return await this.request(`/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 { + return this.request("/recommendations", { + method: "POST", + body: JSON.stringify(request), + }); } } -export const apiClient = new ApiClient() +export const apiClient = new ApiClient(); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d084cca..bd0c391 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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[]) { diff --git a/src/middleware.ts b/src/middleware.ts index 9c177bf..c30191f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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' }, diff --git a/src/services/recommendationService.ts b/src/services/recommendationService.ts new file mode 100644 index 0000000..eff1dda --- /dev/null +++ b/src/services/recommendationService.ts @@ -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 { + 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(); + + // 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 + ): Promise { + 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(); + 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 { + 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 { + 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(); + 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); + } +} diff --git a/src/types/recommendations.ts b/src/types/recommendations.ts new file mode 100644 index 0000000..0934882 --- /dev/null +++ b/src/types/recommendations.ts @@ -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; + }; +} diff --git a/tailwind.config.js b/tailwind.config.js index da23e8e..e660708 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,69 +8,84 @@ module.exports = { './src/**/*.{ts,tsx}', ], theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px", - }, - }, - extend: { - colors: { - border: "hsl(var(--border))", - input: "hsl(var(--input))", - ring: "hsl(var(--ring))", - background: "hsl(var(--background))", - foreground: "hsl(var(--foreground))", - primary: { - DEFAULT: "hsl(var(--primary))", - foreground: "hsl(var(--primary-foreground))", - }, - secondary: { - DEFAULT: "hsl(var(--secondary))", - foreground: "hsl(var(--secondary-foreground))", - }, - destructive: { - DEFAULT: "hsl(var(--destructive))", - foreground: "hsl(var(--destructive-foreground))", - }, - muted: { - DEFAULT: "hsl(var(--muted))", - foreground: "hsl(var(--muted-foreground))", - }, - accent: { - DEFAULT: "hsl(var(--accent))", - foreground: "hsl(var(--accent-foreground))", - }, - popover: { - DEFAULT: "hsl(var(--popover))", - foreground: "hsl(var(--popover-foreground))", - }, - card: { - DEFAULT: "hsl(var(--card))", - foreground: "hsl(var(--card-foreground))", - }, - }, - borderRadius: { - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)", - }, - keyframes: { - "accordion-down": { - from: { height: 0 }, - to: { height: "var(--radix-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--radix-accordion-content-height)" }, - to: { height: 0 }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - }, - }, + container: { + center: true, + padding: '2rem', + screens: { + '2xl': '1400px' + } + }, + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))' + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))' + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))' + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))' + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))' + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))' + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))' + }, + chart: { + '1': 'hsl(var(--chart-1))', + '2': 'hsl(var(--chart-2))', + '3': 'hsl(var(--chart-3))', + '4': 'hsl(var(--chart-4))', + '5': 'hsl(var(--chart-5))' + } + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)' + }, + keyframes: { + 'accordion-down': { + from: { + height: 0 + }, + to: { + height: 'var(--radix-accordion-content-height)' + } + }, + 'accordion-up': { + from: { + height: 'var(--radix-accordion-content-height)' + }, + to: { + height: 0 + } + } + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out' + } + } }, plugins: [require("tailwindcss-animate")], } diff --git a/tsconfig.json b/tsconfig.json index abb59dc..1203ef6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,12 @@ { "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "es6"], + "lib": [ + "dom", + "dom.iterable", + "es6", + "es2017" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -20,9 +25,18 @@ ], "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file