Merge pull request #7 from yusufipk/pr-4

Created a recommendation system for all platforms.
This commit is contained in:
Yusuf İpek
2025-11-23 15:00:13 +03:00
committed by GitHub
29 changed files with 4454 additions and 183 deletions
+264
View File
@@ -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
+163
View File
@@ -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 <package>`
- Ubuntu/Debian: `apt search <package>`
- 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<string, string> = {
// ...
"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.
+270
View File
@@ -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 <paket>`
- Ubuntu/Debian: `apt search <paket>`
- 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<string, string> = {
// ...
"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
+22
View File
@@ -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": {}
}
+4 -1
View File
@@ -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"
},
+327 -7
View File
@@ -26,6 +26,9 @@ importers:
'@radix-ui/react-tabs':
specifier: ^1.0.4
version: 1.1.13(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
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([email protected])
version: 1.0.7([email protected]([email protected]))
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([email protected])
typescript:
specifier: ^5
version: 5.9.3
@@ -119,6 +125,162 @@ packages:
'@emnapi/[email protected]':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
'@esbuild/[email protected]':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/[email protected]':
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/[email protected]':
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/[email protected]':
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/[email protected]':
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/[email protected]':
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@eslint-community/[email protected]':
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/[email protected]':
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/[email protected]':
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'}
[email protected]:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -2298,6 +2478,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
[email protected]:
resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==}
engines: {node: '>=18.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -2478,6 +2663,84 @@ snapshots:
tslib: 2.8.1
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@eslint-community/[email protected]([email protected])':
dependencies:
eslint: 8.57.1
@@ -2869,6 +3132,26 @@ snapshots:
'@types/react': 18.3.26
'@types/react-dom': 18.3.7(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-dismissable-layer': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-popper': 1.2.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-portal': 1.1.9(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-slot': 1.2.3(@types/[email protected])([email protected])
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
'@radix-ui/react-visually-hidden': 1.2.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
react: 18.3.1
react-dom: 18.3.1([email protected])
optionalDependencies:
'@types/react': 18.3.26
'@types/react-dom': 18.3.7(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected])([email protected])':
dependencies:
react: 18.3.1
@@ -3553,6 +3836,35 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
[email protected]:
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
[email protected]: {}
[email protected]: {}
@@ -4427,12 +4739,13 @@ snapshots:
camelcase-css: 2.0.1
postcss: 8.5.6
[email protected]([email protected])([email protected]):
[email protected]([email protected])([email protected])([email protected]):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 1.21.7
postcss: 8.5.6
tsx: 4.20.6
[email protected]([email protected]):
dependencies:
@@ -4783,11 +5096,11 @@ snapshots:
[email protected]: {}
[email protected]([email protected]):
[email protected]([email protected]([email protected])):
dependencies:
tailwindcss: 3.4.18
tailwindcss: 3.4.18([email protected])
[email protected]:
[email protected]([email protected]):
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([email protected])
postcss-js: 4.1.0([email protected])
postcss-load-config: 6.0.1([email protected])([email protected])
postcss-load-config: 6.0.1([email protected])([email protected])([email protected])
postcss-nested: 6.2.0([email protected])
postcss-selector-parser: 6.1.2
resolve: 1.22.11
@@ -4849,6 +5162,13 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
esbuild: 0.25.12
get-tsconfig: 4.13.0
optionalDependencies:
fsevents: 2.3.3
[email protected]:
dependencies:
prelude-ls: 1.2.1
+188
View File
@@ -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);
});
+194
View File
@@ -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<PackageCheckResult> {
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<string, any> = {};
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);
});
+207
View File
@@ -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 }
);
}
}
+21 -10
View File
@@ -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,6 +63,7 @@
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
+19 -2
View File
@@ -5,14 +5,16 @@ import { Button } from '@/components/ui/button'
import { useTheme } from '@/hooks/useTheme'
import { useLocale } from '@/contexts/LocaleContext'
import { SupportModal } from '@/components/SupportModal'
import { Sun, Moon, Monitor, Globe, Heart, Github } from 'lucide-react'
import { Sun, Moon, Monitor, Globe, Heart, Github, Settings } from 'lucide-react'
import Image from 'next/image'
export interface HeaderProps {
cryptomusEnabled: boolean
onResetPreferences?: () => void
hasProfile?: boolean
}
export function Header({ cryptomusEnabled }: HeaderProps) {
export function Header({ cryptomusEnabled, onResetPreferences, hasProfile }: HeaderProps) {
const { theme, isDark, toggleTheme } = useTheme()
const { locale, toggleLocale, t } = useLocale()
const [isSupportModalOpen, setIsSupportModalOpen] = useState(false)
@@ -58,6 +60,21 @@ export function Header({ cryptomusEnabled }: HeaderProps) {
</div>
<div className="flex items-center space-x-2">
{/* Preferences Button - Show when profile exists */}
{hasProfile && onResetPreferences && (
<Button
variant="ghost"
size="sm"
onClick={onResetPreferences}
className="w-full justify-start"
>
<Settings className="h-4 w-4" />
<span className="ml-2 hidden sm:inline">
{t('recommendations.customize')}
</span>
</Button>
)}
{/* Support Button - Only show when Cryptomus is enabled */}
{cryptomusEnabled && (
<Button
+267
View File
@@ -0,0 +1,267 @@
"use client"
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { X, ChevronRight, ChevronLeft, Sparkles } from 'lucide-react'
import { UserCategory } from '@/types/recommendations'
import { useLocale } from '@/contexts/LocaleContext'
import { CATEGORY_ICONS } from '@/constants/categoryIcons'
interface OnboardingModalProps {
isOpen: boolean
onClose: () => void
onComplete: (data: {
categories: UserCategory[]
selectedOS?: string
}) => void
detectedOS: string
}
const PLATFORMS = [
{ id: 'windows', name: 'Windows' },
{ id: 'macos', name: 'macOS' },
{ id: 'ubuntu', name: 'Ubuntu' },
{ id: 'debian', name: 'Debian' },
{ id: 'arch', name: 'Arch Linux' },
{ id: 'fedora', name: 'Fedora' }
]
const iconSlug: Record<string, string> = {
debian: 'debian',
ubuntu: 'ubuntu',
fedora: 'fedora',
arch: 'archlinux',
windows: 'windows',
macos: 'apple'
}
const iconBase = (slug: string) => `https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${slug}.svg`
export function OnboardingModal({
isOpen,
onClose,
onComplete,
detectedOS
}: OnboardingModalProps) {
const { t } = useLocale()
const [step, setStep] = useState(1)
const [selectedCategories, setSelectedCategories] = useState<UserCategory[]>([])
// Default to ubuntu if OS detection fails
const [selectedOS, setSelectedOS] = useState<string>(
detectedOS !== 'unknown' ? detectedOS : 'ubuntu'
)
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setStep(1)
// Keep current OS selection or use detected
setSelectedOS(detectedOS !== 'unknown' ? detectedOS : 'ubuntu')
}
}, [isOpen, detectedOS])
if (!isOpen) return null
const handleCategoryToggle = (category: UserCategory) => {
setSelectedCategories(prev => {
if (prev.includes(category)) {
return prev.filter(c => c !== category)
}
return [...prev, category]
})
}
const handleNext = () => {
if (step < 2) {
setStep(step + 1)
}
}
const handleBack = () => {
if (step > 1) {
setStep(step - 1)
}
}
const handleComplete = () => {
if (selectedCategories.length === 0) {
return
}
onComplete({
categories: selectedCategories,
selectedOS: selectedOS
})
// Close modal
onClose()
}
const canProceed = () => {
if (step === 1) return selectedCategories.length > 0
if (step === 2) return true
return false
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<Card className="w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
<CardHeader className="relative">
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 transition-opacity"
>
<X className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<Sparkles className="h-6 w-6 text-primary" />
<CardTitle className="text-2xl">
{t('onboarding.title')}
</CardTitle>
</div>
<CardDescription>
{t('onboarding.subtitle')}
</CardDescription>
{/* Progress indicator */}
<div className="flex gap-2 mt-4">
{[1, 2].map(i => (
<div
key={i}
className={`h-2 flex-1 rounded-full transition-colors ${i <= step ? 'bg-primary' : 'bg-secondary'
}`}
/>
))}
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Step 1: Categories */}
{step === 1 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2">
{t('onboarding.step1.title')}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('onboarding.step1.description')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{(Object.entries(CATEGORY_ICONS) as [UserCategory, any][]).map(([category, Icon]) => {
return (
<button
key={category}
onClick={() => handleCategoryToggle(category)}
className={`p-4 rounded-lg border-2 text-left transition-all hover:scale-105 ${selectedCategories.includes(category)
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}
>
<div className="flex items-center gap-3 mb-2">
<Icon className="h-6 w-6 text-primary" />
<h4 className="font-semibold capitalize">
{t(`categories.${category}.name`)}
</h4>
</div>
<p className="text-sm text-muted-foreground">
{t(`categories.${category}.description`)}
</p>
</button>
)
})}
</div>
</div>
)}
{/* Step 2: Operating System */}
{step === 2 && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2">
{t('onboarding.step2.title')}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{t('onboarding.step2.description')}
</p>
{detectedOS !== 'unknown' && (
<p className="text-sm text-primary mb-4">
{t('onboarding.step2.detected', { os: detectedOS })}
</p>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{PLATFORMS.map(platform => (
<button
key={platform.id}
onClick={() => setSelectedOS(platform.id)}
className={`p-4 rounded-lg border-2 text-center transition-all hover:scale-105 ${selectedOS === platform.id
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
}`}
>
<div
className={`h-12 w-12 mx-auto mb-3 ${selectedOS === platform.id ? 'text-foreground' : 'text-muted-foreground'}`}
style={{
WebkitMaskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
maskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
WebkitMaskSize: 'contain',
maskSize: 'contain',
WebkitMaskPosition: 'center',
maskPosition: 'center',
backgroundColor: 'currentColor'
} as React.CSSProperties}
/>
<div className="font-semibold text-sm">{platform.name}</div>
</button>
))}
</div>
</div>
)}
{/* Navigation Buttons */}
<div className="flex gap-3 pt-4">
{step > 1 && (
<Button
variant="outline"
onClick={handleBack}
className="flex-1"
>
<ChevronLeft className="h-4 w-4 mr-2" />
{t('common.back')}
</Button>
)}
{step < 2 ? (
<Button
onClick={handleNext}
disabled={!canProceed()}
className="flex-1"
>
{t('common.next')}
<ChevronRight className="h-4 w-4 ml-2" />
</Button>
) : (
<Button
onClick={handleComplete}
disabled={!canProceed()}
className="flex-1"
>
<Sparkles className="h-4 w-4 mr-2" />
{t('common.finish')}
</Button>
)}
</div>
</CardContent>
</Card>
</div>
)
}
+73 -33
View File
@@ -7,12 +7,22 @@ import { apiClient } from '@/lib/api/client'
import { useLocale } from '@/contexts/LocaleContext'
import { Platform } from '@/types'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Lock } from 'lucide-react'
interface PlatformSelectorProps {
selectedPlatform: Platform | null
onPlatformSelect: (platform: Platform) => void
isLocked?: boolean
lockedMessage?: string
}
export function PlatformSelector({ selectedPlatform, onPlatformSelect }: PlatformSelectorProps) {
export function PlatformSelector({
selectedPlatform,
onPlatformSelect,
isLocked = false,
lockedMessage = "Platform selection is locked"
}: PlatformSelectorProps) {
const { t } = useLocale()
const [platforms, setPlatforms] = useState<Platform[]>([])
const [loading, setLoading] = useState(true)
@@ -66,43 +76,73 @@ export function PlatformSelector({ selectedPlatform, onPlatformSelect }: Platfor
return (
<Card className="w-full">
<CardHeader className="pb-4">
<CardTitle className="text-lg">{t('platform.select')}</CardTitle>
<CardTitle className="text-lg flex items-center gap-2">
{t('platform.select')}
{isLocked && <Lock className="h-4 w-4 text-muted-foreground" />}
</CardTitle>
<CardDescription className="text-sm">
{t('platform.description')}
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2">
{platforms.map((platform) => (
<Button
key={platform.id}
variant="outline"
className={`h-12 px-2 py-1 flex flex-col items-center justify-center gap-1 rounded-md border transition-colors
${selectedPlatform?.id === platform.id
? 'border-primary ring-2 ring-primary/60 bg-primary/5'
: 'border-border hover:bg-secondary/60'}`}
onClick={() => onPlatformSelect(platform)}
>
<div
className={`h-5 w-5 ${selectedPlatform?.id === platform.id ? 'text-foreground' : 'text-muted-foreground'}`}
style={{
WebkitMaskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
maskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
WebkitMaskSize: 'contain',
maskSize: 'contain',
WebkitMaskPosition: 'center',
maskPosition: 'center',
backgroundColor: 'currentColor'
} as React.CSSProperties}
/>
<div className="text-center">
<div className="font-medium text-xs leading-tight truncate max-w-[90px]">{platform.name}</div>
</div>
</Button>
))}
</div>
<TooltipProvider>
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2">
{platforms.map((platform) => {
const isSelected = selectedPlatform?.id === platform.id
const isDisabled = isLocked && !isSelected
const ButtonContent = (
<Button
key={platform.id}
variant="outline"
className={`h-12 px-2 py-1 flex flex-col items-center justify-center gap-1 rounded-md border transition-colors w-full
${isSelected
? 'border-primary ring-2 ring-primary/60 bg-primary/5'
: 'border-border hover:bg-secondary/60'}
${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}
`}
onClick={() => !isLocked && onPlatformSelect(platform)}
disabled={isDisabled}
>
<div
className={`h-5 w-5 ${isSelected ? 'text-foreground' : 'text-muted-foreground'}`}
style={{
WebkitMaskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
maskImage: `url(${iconBase(iconSlug[platform.id] || 'linux')})`,
WebkitMaskRepeat: 'no-repeat',
maskRepeat: 'no-repeat',
WebkitMaskSize: 'contain',
maskSize: 'contain',
WebkitMaskPosition: 'center',
maskPosition: 'center',
backgroundColor: 'currentColor'
} as React.CSSProperties}
/>
<div className="text-center">
<div className="font-medium text-xs leading-tight truncate max-w-[90px]">{platform.name}</div>
</div>
</Button>
)
if (isLocked && !isSelected) {
return (
<Tooltip key={platform.id}>
<TooltipTrigger asChild>
<div className="w-full cursor-not-allowed">
{ButtonContent}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{lockedMessage}</p>
</TooltipContent>
</Tooltip>
)
}
return ButtonContent
})}
</div>
</TooltipProvider>
</CardContent>
</Card>
)
+89
View File
@@ -0,0 +1,89 @@
import { useState } from 'react'
import { Package as PackageIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { RecommendedPackage } from '@/types/recommendations'
import { useLocale } from '@/contexts/LocaleContext'
interface RecommendationCardProps {
pkg: RecommendedPackage
isSelected: boolean
onToggle: (pkg: RecommendedPackage) => void
}
export function RecommendationCard({ pkg, isSelected, onToggle }: RecommendationCardProps) {
const { t } = useLocale()
const [iconError, setIconError] = useState(false)
// Helper to format package name for display
const getDisplayName = (name: string, platformId?: string) => {
if (platformId === 'windows' && name.includes('.')) {
// Handle winget IDs like "Microsoft.VisualStudioCode" or "Git.Git"
const parts = name.split('.')
const appName = parts[parts.length - 1] // Take the last part
// Add spaces to CamelCase (e.g. "VisualStudioCode" -> "Visual Studio Code")
return appName.replace(/([A-Z])/g, ' $1').trim()
}
return name
}
const displayName = getDisplayName(pkg.name, pkg.platform_id)
return (
<Card
className={`relative overflow-hidden transition-all hover:shadow-lg cursor-pointer ${isSelected ? 'ring-2 ring-primary' : ''
}`}
onClick={() => onToggle(pkg)}
>
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
{pkg.icon && !iconError ? (
<>
<div
className="h-8 w-8 flex-shrink-0 bg-foreground"
style={{
maskImage: `url(https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${pkg.icon}.svg)`,
WebkitMaskImage: `url(https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${pkg.icon}.svg)`,
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskSize: 'contain',
WebkitMaskSize: 'contain',
maskPosition: 'center',
WebkitMaskPosition: 'center'
}}
/>
{/* Hidden image to detect load errors */}
<img
src={`https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${pkg.icon}.svg`}
alt=""
className="hidden"
onError={() => setIconError(true)}
/>
</>
) : (
<PackageIcon className="h-8 w-8 text-foreground flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate" title={displayName}>{displayName}</h3>
<p className="text-xs text-muted-foreground">{pkg.version}</p>
</div>
</div>
<Button
variant={isSelected ? 'default' : 'outline'}
size="sm"
className="w-full mt-3"
onClick={(e) => {
e.stopPropagation()
onToggle(pkg)
}}
>
{isSelected ? '✓ Selected' : t('recommendations.add_to_selection')}
</Button>
</CardContent>
</Card>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react'
import { Package as PackageIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { RecommendedPackage } from '@/types/recommendations'
interface RecommendationListItemProps {
pkg: RecommendedPackage
isSelected: boolean
onToggle: (pkg: RecommendedPackage) => void
}
export function RecommendationListItem({ pkg, isSelected, onToggle }: RecommendationListItemProps) {
const [iconError, setIconError] = useState(false)
// Helper to format package name for display
const getDisplayName = (name: string, platformId?: string) => {
if (platformId === 'windows' && name.includes('.')) {
// Handle winget IDs like "Microsoft.VisualStudioCode" or "Git.Git"
const parts = name.split('.')
const appName = parts[parts.length - 1] // Take the last part
// Add spaces to CamelCase (e.g. "VisualStudioCode" -> "Visual Studio Code")
return appName.replace(/([A-Z])/g, ' $1').trim()
}
return name
}
const displayName = getDisplayName(pkg.name, pkg.platform_id)
return (
<div
className={`flex items-center gap-3 p-3 rounded-lg border-2 transition-all cursor-pointer hover:shadow-md ${isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
onClick={() => onToggle(pkg)}
>
{/* Package Icon */}
{pkg.icon && !iconError ? (
<>
<div
className="h-6 w-6 flex-shrink-0 bg-foreground"
style={{
maskImage: `url(https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${pkg.icon}.svg)`,
WebkitMaskImage: `url(https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${pkg.icon}.svg)`,
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskSize: 'contain',
WebkitMaskSize: 'contain',
maskPosition: 'center',
WebkitMaskPosition: 'center'
}}
/>
{/* Hidden image to detect load errors */}
<img
src={`https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${pkg.icon}.svg`}
alt=""
className="hidden"
onError={() => setIconError(true)}
/>
</>
) : (
<PackageIcon className="h-6 w-6 text-foreground flex-shrink-0" />
)}
{/* Package Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-sm truncate" title={displayName}>{displayName}</h4>
<span className="text-xs text-muted-foreground">{pkg.version}</span>
</div>
</div>
{/* Select Button */}
<Button
variant={isSelected ? 'default' : 'outline'}
size="sm"
className="ml-2"
onClick={(e) => {
e.stopPropagation()
onToggle(pkg)
}}
>
{isSelected ? '✓' : '+'}
</Button>
</div>
)
}
+368
View File
@@ -0,0 +1,368 @@
"use client"
import { useState, useEffect, useMemo } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Sparkles, Settings, Package as PackageIcon, Star, Grid3x3, List, TrendingUp, Award, ChevronDown, ChevronUp } from 'lucide-react'
import { RecommendedPackage, UserCategory } from '@/types/recommendations'
import { Package } from '@/types'
import { useLocale } from '@/contexts/LocaleContext'
import { useRecommendationProfile } from '@/hooks/useRecommendationProfile'
import { CATEGORY_ICONS } from '@/constants/categoryIcons'
import { RecommendationCard } from './RecommendationCard'
import { RecommendationListItem } from './RecommendationListItem'
import { UserProfile } from '@/types/recommendations'
interface RecommendationsSectionProps {
onPackageToggle: (pkg: Package) => void
selectedPackages: Package[]
onCustomizeClick: () => void
profile: UserProfile
}
type ViewMode = 'grid' | 'compact'
type SortMode = 'recommended' | 'popularity' | 'preset'
type FilterCategory = 'all' | string
export function RecommendationsSection({
onPackageToggle,
selectedPackages,
onCustomizeClick,
profile
}: RecommendationsSectionProps) {
const { t } = useLocale()
// Derived state from props
const getEffectiveOS = () => profile.selectedOS || profile.detectedOS || "ubuntu"
const isProfileComplete = () => profile.categories.length > 0 && getEffectiveOS() !== "unknown"
const [recommendations, setRecommendations] = useState<RecommendedPackage[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>('grid')
const [filterCategory, setFilterCategory] = useState<FilterCategory>('all')
const [isExpanded, setIsExpanded] = useState(true)
const fetchRecommendations = async () => {
if (!isProfileComplete()) {
return
}
setLoading(true)
setError(null)
try {
const response = await fetch('/api/recommendations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
platform_id: getEffectiveOS(),
categories: profile.categories,
limit: 1000
})
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
if (response.status === 400) {
throw new Error(errorData.error || 'Invalid request parameters')
} else if (response.status === 500) {
throw new Error('Server error. Please try again later.')
} else if (response.status === 404) {
throw new Error('Recommendation service not available')
} else {
throw new Error(`Request failed: ${response.statusText}`)
}
}
const data = await response.json()
const recs = data.recommendations || []
setRecommendations(recs)
} catch (err) {
console.error('Error fetching recommendations:', err)
// User-friendly error messages
if (err instanceof TypeError && err.message.includes('fetch')) {
setError('Network error. Please check your internet connection.')
} else {
setError(err instanceof Error ? err.message : 'Failed to load recommendations')
}
} finally {
setLoading(false)
}
}
// Fetch recommendations on mount and when profile changes
useEffect(() => {
if (isProfileComplete()) {
// If profile just changed (e.g. from customization), we might want to force refresh
// But for now, let's rely on the cache key changing which includes profile data
fetchRecommendations()
}
}, [profile.categories, profile.selectedOS])
const isPackageSelected = (pkg: RecommendedPackage) => {
return selectedPackages.some(selected => selected.id === pkg.id)
}
// Get package count per category
const getCategoryCount = (category: string): number => {
return recommendations.filter(pkg => pkg.matchedCategory === category).length
}
// Filter recommendations
const filteredRecommendations = useMemo(() => {
let result = [...recommendations]
// Filter by category
if (filterCategory !== 'all') {
result = result.filter(pkg => pkg.matchedCategory === filterCategory)
}
return result
}, [recommendations, filterCategory])
if (!isProfileComplete()) {
return null
}
return (
<Card className="w-full">
<CardHeader className="cursor-pointer hover:bg-accent/50 transition-colors" onClick={() => setIsExpanded(!isExpanded)}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-lg">
{t('recommendations.title')}
</CardTitle>
{!isExpanded && recommendations.length > 0 && (
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
{recommendations.length} {t('recommendations.packages') || 'packages'}
</span>
)}
</div>
<CardDescription className="text-sm">
{t('recommendations.subtitle')}
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
{isExpanded && (
<>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
// Toggle: if all recommendations are selected, deselect them, otherwise select all
const allSelected = recommendations.every(rec =>
selectedPackages.some(sel => sel.id === rec.id)
)
if (allSelected) {
// Deselect all recommendations
recommendations.forEach(rec => {
if (isPackageSelected(rec)) {
onPackageToggle(rec)
}
})
} else {
// Select all recommendations
recommendations.forEach(rec => {
if (!isPackageSelected(rec)) {
onPackageToggle(rec)
}
})
}
}}
disabled={loading || recommendations.length === 0}
>
{recommendations.every(rec => selectedPackages.some(sel => sel.id === rec.id))
? (t('common.deselect_all') || 'Deselect All')
: (t('common.select_all') || 'Select All')}
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
onCustomizeClick()
}}
>
<Settings className="h-4 w-4 mr-2" />
{t('recommendations.customize')}
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Show user profile info */}
{isExpanded && (
<div className="flex flex-wrap gap-2 mt-3">
<span className="text-xs px-2 py-1 rounded-full bg-primary/10 text-primary">
{getEffectiveOS()}
</span>
{profile.categories.map(cat => (
<span
key={cat}
className="text-xs px-2 py-1 rounded-full bg-secondary text-secondary-foreground"
>
{t(`categories.${cat}.name`)}
</span>
))}
</div>
)}
{/* Filters and View Controls */}
{isExpanded && !loading && !error && recommendations.length > 0 && (
<div
className="flex flex-wrap items-center gap-3 mt-4 pt-4 border-t"
onClick={(e) => e.stopPropagation()} // Prevent collapse when clicking filter area
>
{/* Category Filter Tabs */}
<div className="flex flex-wrap gap-2">
<Button
variant={filterCategory === 'all' ? 'default' : 'outline'}
size="sm"
onClick={(e) => {
e.stopPropagation()
setFilterCategory('all')
}}
className="h-8 text-xs"
>
All ({recommendations.length})
</Button>
{profile.categories.map(cat => {
const count = getCategoryCount(cat)
const Icon = CATEGORY_ICONS[cat]
return (
<Button
key={cat}
variant={filterCategory === cat ? 'default' : 'outline'}
size="sm"
onClick={(e) => {
e.stopPropagation()
setFilterCategory(cat)
}}
className="h-8 text-xs"
disabled={count === 0}
>
{Icon && <Icon className="h-3 w-3 mr-1.5" />}
{t(`categories.${cat}.name`)} ({count})
</Button>
)
})}
</div>
<div className="flex items-center gap-2 ml-auto">
{/* View Mode Toggle */}
<div className="flex items-center border rounded-md">
<Button
variant={viewMode === 'grid' ? 'default' : 'ghost'}
size="sm"
onClick={(e) => {
e.stopPropagation()
setViewMode('grid')
}}
className="h-8 w-8 p-0 rounded-r-none"
>
<Grid3x3 className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'compact' ? 'default' : 'ghost'}
size="sm"
onClick={(e) => {
e.stopPropagation()
setViewMode('compact')
}}
className="h-8 w-8 p-0 rounded-l-none border-l"
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)}
</CardHeader>
{isExpanded && (
<CardContent>
{loading && (
<div className="text-center py-12">
<Sparkles className="h-8 w-8 animate-spin mx-auto mb-4 text-primary" />
<p className="text-muted-foreground">{t('recommendations.loading')}</p>
</div>
)}
{error && (
<div className="text-center py-12">
<p className="text-destructive mb-4">{error}</p>
<Button onClick={() => fetchRecommendations()} variant="outline">
Try Again
</Button>
</div>
)}
{!loading && !error && recommendations.length === 0 && (
<div className="text-center py-12">
<PackageIcon className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground mb-4">
{t('recommendations.no_recommendations')}
</p>
<Button onClick={onCustomizeClick} variant="outline">
<Settings className="h-4 w-4 mr-2" />
{t('recommendations.customize')}
</Button>
</div>
)}
{!loading && !error && recommendations.length > 0 && viewMode === 'grid' && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredRecommendations.map(pkg => (
<RecommendationCard
key={pkg.id}
pkg={pkg}
isSelected={isPackageSelected(pkg)}
onToggle={onPackageToggle}
/>
))}
</div>
)}
{/* Compact View Mode */}
{!loading && !error && recommendations.length > 0 && viewMode === 'compact' && (
<div className="space-y-2">
{filteredRecommendations.map(pkg => (
<RecommendationListItem
key={pkg.id}
pkg={pkg}
isSelected={isPackageSelected(pkg)}
onToggle={onPackageToggle}
/>
))}
</div>
)}
</CardContent>
)}
</Card>
)
}
+172 -5
View File
@@ -1,21 +1,95 @@
"use client"
import { useState } from 'react'
import { useState, useEffect, useRef } from 'react'
import { LocaleProvider } from '@/contexts/LocaleContext'
import { Header } from './Header'
import { PlatformSelector } from './PlatformSelector'
import { PackageBrowserV2 } from './PackageBrowserV2'
import { SelectionManager } from './SelectionManager'
import { ScriptPreview } from '@/components/ScriptPreview'
import { OnboardingModal } from '@/components/OnboardingModal'
import { RecommendationsSection } from '@/components/RecommendationsSection'
import { generateScript } from '@/lib/scriptGenerator'
import { useLocale } from '@/contexts/LocaleContext'
import { useRecommendationProfile } from '@/hooks/useRecommendationProfile'
import { Platform, Package, SelectedPackage, FilterOptions, GeneratedScript } from '@/types'
import { UserCategory } from '@/types/recommendations'
import { Sparkles, Settings, Package as PackageIcon } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean }) {
const { t, locale } = useLocale()
const [selectedPlatform, setSelectedPlatform] = useState<Platform | null>(null)
const [selectedPackages, setSelectedPackages] = useState<SelectedPackage[]>([])
const [generatedScript, setGeneratedScript] = useState<GeneratedScript | null>(null)
const [availablePlatforms, setAvailablePlatforms] = useState<Platform[]>([])
// Recommendation profile management
const {
profile,
isLoading: isProfileLoading,
hasCompletedOnboarding,
saveProfile,
detectedOS,
getEffectiveOS
} = useRecommendationProfile()
const [showOnboarding, setShowOnboarding] = useState(false)
// Load platforms on mount
useEffect(() => {
const loadPlatforms = async () => {
try {
const response = await fetch('/api/platforms')
if (response.ok) {
const platforms = await response.json()
setAvailablePlatforms(platforms)
}
} catch (error) {
console.error('Failed to load platforms:', error)
}
}
loadPlatforms()
}, [])
// Show onboarding modal on first visit - DISABLED as per user request
/*
useEffect(() => {
if (!isProfileLoading && !hasCompletedOnboarding) {
// Delay to allow page to render first
const timer = setTimeout(() => {
setShowOnboarding(true)
}, 500)
return () => clearTimeout(timer)
}
}, [isProfileLoading, hasCompletedOnboarding])
*/
const handleOnboardingComplete = (data: {
categories: UserCategory[]
selectedOS?: string
}) => {
console.log('🎯 Onboarding completed with data:', data)
const success = saveProfile({
categories: data.categories,
selectedOS: data.selectedOS,
hasCompletedOnboarding: true
})
console.log('💾 Profile save result:', success)
// Don't call completeOnboarding() - it causes a second save with empty state!
// completeOnboarding()
// Force close modal
setShowOnboarding(false)
}
const handleCustomizePreferences = () => {
setShowOnboarding(true)
}
const handlePlatformSelect = (platform: Platform) => {
setSelectedPlatform(platform)
@@ -44,8 +118,28 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
}
const handleGenerateScript = () => {
if (selectedPlatform && selectedPackages.length > 0) {
const script = generateScript(selectedPackages, selectedPlatform)
if (selectedPackages.length === 0) {
return
}
// Use selected platform, or if not selected, find platform from available platforms
let platformToUse = selectedPlatform
if (!platformToUse && hasCompletedOnboarding) {
// Get effective OS from profile and find matching platform from loaded platforms
const effectiveOS = profile.selectedOS || detectedOS
if (effectiveOS && availablePlatforms.length > 0) {
platformToUse = availablePlatforms.find(p => p.id === effectiveOS) || null
if (!platformToUse) {
console.warn(`Platform not found for OS: ${effectiveOS}`)
}
}
}
if (platformToUse) {
const script = generateScript(selectedPackages, platformToUse)
setGeneratedScript(script)
}
}
@@ -58,10 +152,31 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
const handleCloseScriptPreview = () => {
setGeneratedScript(null)
}
// Track previous effective OS to detect profile changes
const prevEffectiveOS = useRef<string | null>(null)
// Calculate current effective OS
const effectiveOS = getEffectiveOS()
// Sync platform selection with profile changes
useEffect(() => {
if (hasCompletedOnboarding && availablePlatforms.length > 0) {
const platform = availablePlatforms.find(p => p.id === effectiveOS)
// If profile OS changed, or if no platform is selected yet, update selection
if (platform && (effectiveOS !== prevEffectiveOS.current || !selectedPlatform)) {
setSelectedPlatform(platform)
prevEffectiveOS.current = effectiveOS
}
}
}, [hasCompletedOnboarding, effectiveOS, availablePlatforms, selectedPlatform])
return (
<div key={locale} className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800">
<Header cryptomusEnabled={cryptomusEnabled} />
<Header
cryptomusEnabled={cryptomusEnabled}
onResetPreferences={handleCustomizePreferences}
hasProfile={hasCompletedOnboarding}
/>
<div className="container mx-auto px-4 py-8">
{/* Header */}
@@ -79,10 +194,45 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
{/* Main Content */}
<div className="space-y-8">
{/* Recommendations Section - Show if profile is complete */}
{hasCompletedOnboarding && profile.categories.length > 0 ? (
<RecommendationsSection
onPackageToggle={handlePackageToggle}
selectedPackages={selectedPackages}
onCustomizeClick={handleCustomizePreferences}
profile={profile}
/>
) : (
/* Empty State for Recommendations */
<Card className="w-full bg-gradient-to-r from-primary/5 to-secondary/5 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="bg-background p-4 rounded-full shadow-sm mb-4">
<Sparkles className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-semibold mb-2">
{t('recommendations.empty_title') || 'Get Personalized Recommendations'}
</h3>
<p className="text-muted-foreground max-w-md mb-6">
{t('recommendations.empty_description') || 'Tell us about your role and platform to get a curated list of essential packages.'}
</p>
<Button onClick={handleCustomizePreferences} size="lg">
<Sparkles className="h-4 w-4 mr-2" />
{t('recommendations.start') || 'Start Recommendation Wizard'}
</Button>
</CardContent>
</Card>
)}
{/* Platform Selector */}
<PlatformSelector
selectedPlatform={selectedPlatform}
onPlatformSelect={handlePlatformSelect}
isLocked={selectedPackages.length > 0}
lockedMessage="Clear your selection to switch platforms"
/>
{/* Package Browser */}
@@ -107,10 +257,27 @@ function RepoHubAppContent({ cryptomusEnabled }: { cryptomusEnabled: boolean })
<ScriptPreview
generatedScript={generatedScript}
selectedPackages={selectedPackages}
selectedPlatform={selectedPlatform}
selectedPlatform={selectedPlatform ||
availablePlatforms.find(p => p.id === generatedScript.platform) ||
{
id: generatedScript.platform,
name: generatedScript.platform.charAt(0).toUpperCase() + generatedScript.platform.slice(1),
description: '',
icon: '',
packageManager: ''
}
}
onClose={handleCloseScriptPreview}
/>
)}
{/* Onboarding Modal */}
<OnboardingModal
isOpen={showOnboarding}
onClose={() => setShowOnboarding(false)}
onComplete={handleOnboardingComplete}
detectedOS={detectedOS || 'unknown'}
/>
</div>
</div>
)
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+21
View File
@@ -0,0 +1,21 @@
import {
Code,
Palette,
Film,
Cpu,
Gamepad2,
CheckSquare,
GraduationCap,
LucideIcon
} from 'lucide-react';
import { UserCategory } from '@/types/recommendations';
export const CATEGORY_ICONS: Record<UserCategory, LucideIcon> = {
development: Code,
design: Palette,
multimedia: Film,
"system-tools": Cpu,
gaming: Gamepad2,
productivity: CheckSquare,
education: GraduationCap,
};
+182 -2
View File
@@ -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"
}
}
}
}
+785
View File
@@ -0,0 +1,785 @@
import { UserCategory } from "@/types/recommendations";
/**
* Curated package recommendations for each platform and category
* Simple structure: Platform → Category → Package names
*
* Edit this file to add/remove packages for each platform/category combination
*/
type PlatformId = "windows" | "macos" | "ubuntu" | "debian" | "arch" | "fedora";
export const PACKAGE_PRESETS: Record<PlatformId, Record<UserCategory, string[]>> = {
"windows": {
"development": [
"Git.Git",
"Microsoft.VisualStudioCode",
"Docker.DockerDesktop",
"Postman.Postman",
"Microsoft.WindowsTerminal",
"Microsoft.PowerShell",
"Notepad++.Notepad++",
"WinSCP.WinSCP",
"PuTTY.PuTTY",
"WinMerge.WinMerge",
"EclipseFoundation.Eclipse",
"Anysphere.Cursor"
],
"design": [
"GIMP.GIMP",
"Inkscape.Inkscape",
"BlenderFoundation.Blender",
"KDE.Krita",
"IrfanSkiljan.IrfanView",
"XnSoft.XnViewMP",
"FastStone.Viewer",
"Greenshot.Greenshot",
"ShareX.ShareX"
],
"multimedia": [
"VideoLAN.VLC",
"Audacity.Audacity",
"OBSProject.OBSStudio",
"Apple.iTunes",
"AIMP.AIMP",
"PeterPawlowski.foobar2000",
"Winamp.Winamp",
"GOMLab.GOMPlayer",
"Spotify.Spotify",
"VentisMedia.MediaMonkey",
"HandBrake.HandBrake"
],
"system-tools": [
"7zip.7zip",
"Microsoft.PowerToys",
"voidtools.Everything",
"RARLab.WinRAR",
"DominikReichl.KeePass",
"TeamViewer.TeamViewer",
"RealVNC.VNCViewer",
"CodeSector.TeraCopy",
"LIGHTNINGUK.ImgBurn",
"WinDirStat.WinDirStat",
"AntibodySoftware.WizTree",
"Glarysoft.GlaryUtilities",
"ChristianKindahl.InfraRecorder",
"Open-Shell.Open-Shell-Menu",
"Piriform.CCleaner",
"Rufus.Rufus",
"BleachBit.BleachBit",
"NVAccess.NVDA",
"Malwarebytes.Malwarebytes",
"SUPERAntiSpyware.SUPERAntiSpyware",
"qBittorrent.qBittorrent"
],
"gaming": [
"Valve.Steam",
"Discord.Discord",
"EpicGames.EpicGamesLauncher",
"GOG.Galaxy"
],
"productivity": [
"Notion.Notion",
"Obsidian.Obsidian",
"SlackTechnologies.Slack",
"Google.Chrome",
"Mozilla.Firefox",
"Microsoft.Edge",
"Brave.Brave",
"Opera.Opera",
"Zoom.Zoom",
"Microsoft.Teams",
"Pidgin.Pidgin",
"Mozilla.Thunderbird",
"Foxit.FoxitReader",
"TheDocumentFoundation.LibreOffice",
"SumatraPDF.SumatraPDF",
"AcroSoftware.CutePDFWriter",
"Apache.OpenOffice",
"Dropbox.Dropbox",
"Microsoft.OneDrive",
"Google.EarthPro",
"Evernote.Evernote"
],
"education": [
"Anki.Anki"
]
},
"macos": {
"development": [
"git",
"visual-studio-code",
"cursor",
"node",
"postman",
"iterm2",
"warp",
"sublime-text",
"cyberduck",
"meld",
"dotnet-sdk",
"temurin"
],
"design": [
"gimp",
"inkscape",
"blender",
"krita",
"xnviewmp"
],
"multimedia": [
"vlc",
"audacity",
"obs",
"spotify",
"handbrake",
"iina",
"foobar2000"
],
"system-tools": [
"rectangle",
"the-unarchiver",
"keka",
"appcleaner",
"keepassxc",
"teamviewer",
"anydesk",
"malwarebytes",
"raycast",
"alfred",
"qbittorrent"
],
"gaming": [
"steam",
"discord",
"epic-games"
],
"productivity": [
"notion",
"obsidian",
"slack",
"zoom",
"microsoft-teams",
"thunderbird",
"google-chrome",
"firefox",
"microsoft-edge",
"brave-browser",
"opera",
"libreoffice",
"foxitreader",
"adobe-acrobat-reader",
"dropbox",
"google-drive",
"onedrive"
],
"education": [
"anki",
"zotero"
]
},
"ubuntu": {
"development": [
"git",
"curl",
"wget",
"nodejs",
"npm",
"python3-pip",
"docker.io",
"dotnet-sdk-8.0"
],
"design": [
"gimp",
"inkscape",
"blender",
"krita",
"darktable"
],
"multimedia": [
"vlc",
"audacity",
"obs-studio",
"ffmpeg",
"mpv",
"handbrake",
"kdenlive"
],
"system-tools": [
"neofetch",
"timeshift",
"stacer",
"keepassxc",
"synaptic"
],
"gaming": [
"steam",
"lutris",
"mangohud"
],
"productivity": [
"libreoffice",
"chromium-browser",
"evolution",
"focuswriter"
],
"education": [
"anki"
]
},
"debian": {
"development": [
"git",
"build-essential",
"curl",
"wget",
"nodejs",
"npm",
"python3",
"python3-pip",
"docker.io"
],
"design": [
"gimp",
"inkscape",
"blender",
"krita"
],
"multimedia": [
"vlc",
"audacity",
"obs-studio",
"ffmpeg",
"handbrake"
],
"system-tools": [
"htop",
"fastfetch",
"tmux",
"zsh",
"gparted",
"timeshift",
"keepassxc"
],
"gaming": [
"steam",
"lutris",
"gamemode",
"mangohud"
],
"productivity": [
"libreoffice",
"thunderbird",
"firefox-esr",
"chromium"
],
"education": [
]
},
"arch": {
"development": [
"git",
"base-devel",
"code",
"nodejs",
"npm",
"python-pip",
"jdk17-openjdk"
],
"design": [
"gimp",
"inkscape",
"blender",
"krita"
],
"multimedia": [
"vlc",
"audacity",
"obs-studio",
"ffmpeg",
"mpv",
"handbrake"
],
"system-tools": [
"htop",
"fastfetch",
"tldr",
"tmux",
"zsh",
"gparted",
"timeshift",
"keepassxc",
"reflector",
"pacman-contrib"
],
"gaming": [
"steam",
"lutris",
"gamemode",
"mangohud",
"discord",
"wine",
"winetricks"
],
"productivity": [
"libreoffice-fresh",
"thunderbird",
"firefox",
"chromium",
"obsidian"
],
"education": [
"anki"
]
},
"fedora": {
"development": [
"git",
"curl",
"nodejs",
"python3",
"python3-pip",
"java-17-openjdk-devel",
"dotnet-sdk-8.0"
],
"design": [
"gimp",
"inkscape",
"blender",
"krita"
],
"multimedia": [
"vlc",
"audacity",
"obs-studio",
"mpv"
],
"system-tools": [
"htop",
"fastfetch",
"tldr",
"tmux",
"zsh",
"gparted",
"keepassxc",
"dnf-plugins-core"
],
"gaming": [
"lutris",
"gamemode",
"mangohud"
],
"productivity": [
"libreoffice",
"thunderbird",
"firefox",
"chromium"
],
"education": []
},
};
/**
* Get package names for a specific platform and categories
*/
export function getPackagesForPlatform(
platformId: string,
categories: UserCategory[]
): string[] {
const platform = PACKAGE_PRESETS[platformId as PlatformId];
if (!platform) return [];
const packages: string[] = [];
categories.forEach(category => {
const categoryPackages = platform[category] || [];
packages.push(...categoryPackages);
});
return packages;
}
/**
* Get package names with their categories for a specific platform
*/
export function getPackagesWithCategories(
platformId: string,
categories: UserCategory[]
): { name: string; category: UserCategory }[] {
const platform = PACKAGE_PRESETS[platformId as PlatformId];
if (!platform) return [];
const packages: { name: string; category: UserCategory }[] = [];
categories.forEach((category) => {
const categoryPackages = platform[category] || [];
categoryPackages.forEach((name) => {
packages.push({ name, category });
});
});
return packages;
}
export const PRESET_DESCRIPTIONS: Record<string, string> = {
// Development
"git": "Distributed version control system",
"Git.Git": "Distributed version control system",
"curl": "Command line tool for transferring data with URLs",
"wget": "Network utility to retrieve files from the Web",
"nodejs": "JavaScript runtime built on Chrome's V8 JavaScript engine",
"npm": "Package manager for the Node.js JavaScript platform",
"python3": "Interpreted, interactive, object-oriented programming language",
"python3-pip": "Python package installer",
"python-pip": "Python package installer",
"docker.io": "Linux container runtime",
"Docker.DockerDesktop": "Build, Share, and Run container applications",
"dotnet-sdk-8.0": ".NET 8.0 Software Development Kit",
"dotnet-sdk": ".NET Software Development Kit",
"Microsoft.VisualStudioCode": "Code editing. Redefined.",
"visual-studio-code": "Code editing. Redefined.",
"code": "The Open Source build of Visual Studio Code",
"Postman.Postman": "Platform for building and using APIs",
"postman": "Platform for building and using APIs",
"Microsoft.WindowsTerminal": "Modern terminal application for Windows",
"iterm2": "Terminal emulator for macOS",
"warp": "AI-powered terminal",
"sublime-text": "Sophisticated text editor for code, markup and prose",
"build-essential": "Informational list of build-essential packages",
"base-devel": "Basic tools to build Arch Linux packages",
"java-17-openjdk-devel": "OpenJDK 17 Development Kit",
"jdk17-openjdk": "OpenJDK 17 Development Kit",
"temurin": "Eclipse Temurin Java SE binaries",
// Design
"gimp": "GNU Image Manipulation Program",
"GIMP.GIMP": "GNU Image Manipulation Program",
"inkscape": "Vector-based drawing program",
"Inkscape.Inkscape": "Vector-based drawing program",
"blender": "Very fast and versatile 3D modeller/renderer",
"BlenderFoundation.Blender": "Very fast and versatile 3D modeller/renderer",
"krita": "Digital painting and sketching application",
"KDE.Krita": "Digital painting and sketching application",
"darktable": "Virtual lighttable and darkroom for photographers",
"xnviewmp": "Image viewer, browser and converter",
"XnSoft.XnViewMP": "Image viewer, browser and converter",
"IrfanSkiljan.IrfanView": "Fast and compact image viewer",
"FastStone.Viewer": "Image viewer, converter and editor",
"ShareX.ShareX": "Screen capture, file sharing and productivity tool",
"Greenshot.Greenshot": "Lightweight screenshot software tool",
// Multimedia
"vlc": "Multimedia player and streamer",
"VideoLAN.VLC": "Multimedia player and streamer",
"audacity": "Multi-track audio editor and recorder",
"Audacity.Audacity": "Multi-track audio editor and recorder",
"obs-studio": "Software for live streaming and screen recording",
"obs": "Software for live streaming and screen recording",
"OBSProject.OBSStudio": "Software for live streaming and screen recording",
"ffmpeg": "Tools for transcoding, streaming and playing of multimedia files",
"mpv": "Video player based on MPlayer/mplayer2",
"handbrake": "Open Source Video Transcoder",
"HandBrake.HandBrake": "Open Source Video Transcoder",
"kdenlive": "Non-linear video editor",
"spotify": "Music streaming service",
"Spotify.Spotify": "Music streaming service",
"Apple.iTunes": "Media player, media library, and mobile device management utility",
"foobar2000": "Advanced audio player",
"PeterPawlowski.foobar2000": "Advanced audio player",
"Winamp.Winamp": "Media player for Windows",
"AIMP.AIMP": "Free audio player",
"iina": "The modern video player for macOS",
// System Tools
"htop": "Interactive process viewer",
"fastfetch": "Like neofetch, but much faster",
"neofetch": "Command-line system information tool",
"tmux": "Terminal multiplexer",
"zsh": "Shell with lots of features",
"gparted": "GNOME Partition Editor",
"timeshift": "System restore utility",
"stacer": "Linux System Optimizer and Monitoring",
"keepassxc": "Cross Platform Password Manager",
"DominikReichl.KeePass": "Password manager",
"synaptic": "Graphical package manager",
"7zip.7zip": "File archiver with a high compression ratio",
"Microsoft.PowerToys": "Set of system utilities for power users",
"voidtools.Everything": "Locate files and folders by name instantly",
"RARLab.WinRAR": "Powerful archiver and archive manager",
"TeamViewer.TeamViewer": "Remote control and meeting software",
"teamviewer": "Remote control and meeting software",
"RealVNC.VNCViewer": "Remote control software",
"rufus": "Create bootable USB drives the easy way",
"Rufus.Rufus": "Create bootable USB drives the easy way",
"bleachbit": "Delete unnecessary files from the system",
"BleachBit.BleachBit": "Delete unnecessary files from the system",
"rectangle": "Move and resize windows in macOS using keyboard shortcuts",
"the-unarchiver": "Unpack any archive file",
"keka": "The macOS file archiver",
"appcleaner": "Uninstall unwanted apps",
"raycast": "Productivity tool that replaces Spotlight",
"alfred": "Productivity app for macOS",
"qbittorrent": "BitTorrent client",
"qBittorrent.qBittorrent": "BitTorrent client",
// Gaming
"steam": "Digital distribution platform for video games",
"Valve.Steam": "Digital distribution platform for video games",
"lutris": "Open Source gaming platform for Linux",
"gamemode": "Optimize Linux system performance for gaming",
"mangohud": "Vulkan and OpenGL overlay for monitoring FPS, temperatures, CPU/GPU load",
"discord": "All-in-one voice and text chat for gamers",
"Discord.Discord": "All-in-one voice and text chat for gamers",
"wine": "Run Windows applications on Linux",
"winetricks": "Workarounds for problems in Wine",
"EpicGames.EpicGamesLauncher": "Epic Games Store",
"epic-games": "Epic Games Store",
"GOG.Galaxy": "GOG Galaxy Client",
// Productivity
"libreoffice": "Office productivity suite",
"TheDocumentFoundation.LibreOffice": "Office productivity suite",
"libreoffice-fresh": "Office productivity suite (fresh version)",
"thunderbird": "Email, newsgroup and chat client",
"Mozilla.Thunderbird": "Email, newsgroup and chat client",
"firefox": "Mozilla Firefox web browser",
"Mozilla.Firefox": "Mozilla Firefox web browser",
"chromium": "Web browser",
"chromium-browser": "Web browser",
"Google.Chrome": "Web browser",
"google-chrome": "Web browser",
"Microsoft.Edge": "Web browser",
"microsoft-edge": "Web browser",
"Brave.Brave": "Secure, fast, and private web browser",
"brave-browser": "Secure, fast, and private web browser",
"Opera.Opera": "Web browser",
"opera": "Web browser",
"zoom": "Video conferencing",
"Zoom.Zoom": "Video conferencing",
"microsoft-teams": "Communication and collaboration platform",
"Microsoft.Teams": "Communication and collaboration platform",
"slack": "Collaboration hub for work",
"SlackTechnologies.Slack": "Collaboration hub for work",
"notion": "All-in-one workspace",
"Notion.Notion": "All-in-one workspace",
"obsidian": "Knowledge base that works on local Markdown files",
"Obsidian.Obsidian": "Knowledge base that works on local Markdown files",
"foxitreader": "PDF Reader",
"Foxit.FoxitReader": "PDF Reader",
"adobe-acrobat-reader": "PDF Reader",
"dropbox": "File hosting service",
"Dropbox.Dropbox": "File hosting service",
"onedrive": "File hosting service",
"Microsoft.OneDrive": "File hosting service",
"google-drive": "File hosting service",
"evernote": "Note taking app",
"Evernote.Evernote": "Note taking app",
"evolution": "Groupware suite",
"focuswriter": "Distraction-free word processor",
// Education
"anki": "Powerful, intelligent flash cards",
"Anki.Anki": "Powerful, intelligent flash cards",
"zotero": "Your personal research assistant"
};
export const PACKAGE_ICONS: Record<string, string> = {
// Development
"git": "git",
"Git.Git": "git",
"curl": "curl",
"wget": "gnu",
"nodejs": "nodedotjs",
"npm": "npm",
"python3": "python",
"python3-pip": "pypi",
"python-pip": "pypi",
"docker.io": "docker",
"Docker.DockerDesktop": "docker",
"dotnet-sdk-8.0": "dotnet",
"dotnet-sdk": "dotnet",
"Microsoft.VisualStudioCode": "visualstudiocode",
"visual-studio-code": "visualstudiocode",
"code": "visualstudiocode",
"Postman.Postman": "postman",
"postman": "postman",
"Microsoft.WindowsTerminal": "windows",
"iterm2": "iterm2",
"warp": "warp",
"sublime-text": "sublimetext",
"build-essential": "linux",
"base-devel": "archlinux",
"java-17-openjdk-devel": "openjdk",
"jdk17-openjdk": "openjdk",
"temurin": "eclipse",
"Anysphere.Cursor": "cursor",
"cursor": "cursor",
"EclipseFoundation.Eclipse": "eclipse",
"WinSCP.WinSCP": "winscp",
"PuTTY.PuTTY": "putty",
// Design
"gimp": "gimp",
"GIMP.GIMP": "gimp",
"inkscape": "inkscape",
"Inkscape.Inkscape": "inkscape",
"blender": "blender",
"BlenderFoundation.Blender": "blender",
"krita": "krita",
"KDE.Krita": "krita",
"darktable": "darktable",
"xnviewmp": "xnview",
"XnSoft.XnViewMP": "xnview",
"IrfanSkiljan.IrfanView": "irfanview",
"FastStone.Viewer": "imagej", // Placeholder, no icon
"ShareX.ShareX": "sharex",
"Greenshot.Greenshot": "greenshot",
// Multimedia
"vlc": "vlcmediaplayer",
"VideoLAN.VLC": "vlcmediaplayer",
"audacity": "audacity",
"Audacity.Audacity": "audacity",
"obs-studio": "obsstudio",
"obs": "obsstudio",
"OBSProject.OBSStudio": "obsstudio",
"ffmpeg": "ffmpeg",
"mpv": "mpv",
"handbrake": "handbrake",
"HandBrake.HandBrake": "handbrake",
"kdenlive": "kdenlive",
"spotify": "spotify",
"Spotify.Spotify": "spotify",
"Apple.iTunes": "itunes",
"foobar2000": "foobar2000",
"PeterPawlowski.foobar2000": "foobar2000",
"Winamp.Winamp": "winamp",
"AIMP.AIMP": "aimp",
"iina": "iina",
// System Tools
"htop": "htop",
"fastfetch": "linux",
"neofetch": "linux",
"tmux": "tmux",
"zsh": "zsh",
"gparted": "gparted",
"timeshift": "linux",
"stacer": "linux",
"keepassxc": "keepassxc",
"DominikReichl.KeePass": "keepass",
"synaptic": "debian",
"7zip.7zip": "7zip",
"Microsoft.PowerToys": "windows",
"voidtools.Everything": "windows",
"RARLab.WinRAR": "winrar",
"TeamViewer.TeamViewer": "teamviewer",
"teamviewer": "teamviewer",
"RealVNC.VNCViewer": "realvnc",
"rufus": "rufus",
"Rufus.Rufus": "rufus",
"bleachbit": "bleachbit",
"BleachBit.BleachBit": "bleachbit",
"rectangle": "macos",
"the-unarchiver": "macos",
"keka": "macos",
"appcleaner": "macos",
"raycast": "raycast",
"alfred": "alfred",
"qbittorrent": "qbittorrent",
"qBittorrent.qBittorrent": "qbittorrent",
"NVAccess.NVDA": "nvda",
"Malwarebytes.Malwarebytes": "malwarebytes",
// Gaming
"steam": "steam",
"Valve.Steam": "steam",
"lutris": "lutris",
"gamemode": "linux",
"mangohud": "opengl",
"discord": "discord",
"Discord.Discord": "discord",
"wine": "wine",
"winetricks": "wine",
"EpicGames.EpicGamesLauncher": "epicgames",
"epic-games": "epicgames",
"GOG.Galaxy": "gogdotcom",
// Productivity
"libreoffice": "libreoffice",
"TheDocumentFoundation.LibreOffice": "libreoffice",
"libreoffice-fresh": "libreoffice",
"thunderbird": "thunderbird",
"Mozilla.Thunderbird": "thunderbird",
"firefox": "firefox",
"Mozilla.Firefox": "firefox",
"chromium": "chromium",
"chromium-browser": "chromium",
"Google.Chrome": "googlechrome",
"google-chrome": "googlechrome",
"Microsoft.Edge": "microsoftedge",
"microsoft-edge": "microsoftedge",
"Brave.Brave": "brave",
"brave-browser": "brave",
"Opera.Opera": "opera",
"opera": "opera",
"zoom": "zoom",
"Zoom.Zoom": "zoom",
"microsoft-teams": "microsoftteams",
"Microsoft.Teams": "microsoftteams",
"slack": "slack",
"SlackTechnologies.Slack": "slack",
"notion": "notion",
"Notion.Notion": "notion",
"obsidian": "obsidian",
"Obsidian.Obsidian": "obsidian",
"foxitreader": "foxit",
"Foxit.FoxitReader": "foxit",
"adobe-acrobat-reader": "adobeacrobatreader",
"dropbox": "dropbox",
"Dropbox.Dropbox": "dropbox",
"onedrive": "microsoftonedrive",
"Microsoft.OneDrive": "microsoftonedrive",
"google-drive": "googledrive",
"evernote": "evernote",
"Evernote.Evernote": "evernote",
"evolution": "linux",
"focuswriter": "linux",
// Education
"anki": "anki",
"Anki.Anki": "anki",
"zotero": "zotero"
};
export function getPresetIcon(name: string): string | undefined {
// Try exact match
if (PACKAGE_ICONS[name]) {
return PACKAGE_ICONS[name];
}
// Try case insensitive
const lowerName = name.toLowerCase();
const key = Object.keys(PACKAGE_ICONS).find(k => k.toLowerCase() === lowerName);
if (key) {
return PACKAGE_ICONS[key];
}
return undefined;
}
export function getPresetDetails(name: string): { description: string } {
// Try exact match
if (PRESET_DESCRIPTIONS[name]) {
return { description: PRESET_DESCRIPTIONS[name] };
}
// Try case insensitive
const lowerName = name.toLowerCase();
const key = Object.keys(PRESET_DESCRIPTIONS).find(k => k.toLowerCase() === lowerName);
if (key) {
return { description: PRESET_DESCRIPTIONS[key] };
}
return { description: "Recommended package" };
}
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
UserProfile,
UserCategory,
} from "@/types/recommendations";
const STORAGE_KEY = "repohub_user_profile";
/**
* Detect user's operating system from browser
*/
function detectOS(): string {
if (typeof window === "undefined") {
return "unknown";
}
const userAgent = window.navigator.userAgent.toLowerCase();
const platform = window.navigator.platform.toLowerCase();
// Windows
if (userAgent.indexOf("win") !== -1 || platform.indexOf("win") !== -1) {
return "windows";
}
// macOS
if (
userAgent.indexOf("mac") !== -1 ||
platform.indexOf("mac") !== -1 ||
userAgent.indexOf("darwin") !== -1
) {
return "macos";
}
// Linux distros
if (userAgent.indexOf("linux") !== -1 || platform.indexOf("linux") !== -1) {
// Try to detect specific distro from user agent (rare but possible)
if (userAgent.indexOf("ubuntu") !== -1) {
return "ubuntu";
}
if (userAgent.indexOf("fedora") !== -1) {
return "fedora";
}
if (userAgent.indexOf("arch") !== -1) {
return "arch";
}
if (userAgent.indexOf("debian") !== -1) {
return "debian";
}
// Default to Ubuntu for generic Linux
return "ubuntu";
}
return "unknown";
}
const CURRENT_PROFILE_VERSION = 1;
/**
* Get default user profile
*/
function getDefaultProfile(): UserProfile {
return {
version: CURRENT_PROFILE_VERSION,
categories: [],
detectedOS: detectOS(),
selectedOS: undefined,
hasCompletedOnboarding: false,
createdAt: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
};
}
/**
* Hook for managing user recommendation profile in localStorage
*/
export function useRecommendationProfile() {
const [profile, setProfile] = useState<UserProfile>(getDefaultProfile());
const [isLoading, setIsLoading] = useState(true);
// Load profile from localStorage on mount
useEffect(() => {
// We intentionally do NOT load from localStorage anymore to reset on refresh
// as requested by user preference change.
// Initialize with default profile (detects OS)
const defaultProfile = getDefaultProfile();
setProfile(defaultProfile);
setIsLoading(false);
}, []);
// Save profile to state only (session persistence)
const saveProfile = useCallback(
(newProfile: Partial<UserProfile>) => {
try {
const updated: UserProfile = {
...profile,
...newProfile,
version: CURRENT_PROFILE_VERSION,
lastUpdated: new Date().toISOString(),
};
console.log("💾 Saving profile (Session only):", updated);
setProfile(updated);
// localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); // Disabled persistence
return true;
} catch (error) {
console.error("❌ Error saving user profile:", error);
return false;
}
},
[profile]
);
// Update categories
const updateCategories = useCallback(
(categories: UserCategory[]) => {
return saveProfile({ categories });
},
[saveProfile]
);
// Update selected OS (manual override)
const updateSelectedOS = useCallback(
(os: string) => {
return saveProfile({ selectedOS: os });
},
[saveProfile]
);
// Mark onboarding as completed
const completeOnboarding = useCallback(() => {
return saveProfile({ hasCompletedOnboarding: true });
}, [saveProfile]);
// Reset profile
const resetProfile = useCallback(() => {
try {
const defaultProfile = getDefaultProfile();
setProfile(defaultProfile);
// localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultProfile)); // Disabled persistence
return true;
} catch (error) {
console.error("Error resetting user profile:", error);
return false;
}
}, []);
// Get effective OS (selectedOS or detectedOS)
const getEffectiveOS = useCallback((): string => {
return profile.selectedOS || profile.detectedOS || "ubuntu";
}, [profile]);
// Check if profile is complete enough for recommendations
const isProfileComplete = useCallback((): boolean => {
return profile.categories.length > 0 && getEffectiveOS() !== "unknown";
}, [profile, getEffectiveOS]);
return {
profile,
isLoading,
saveProfile,
updateCategories,
updateSelectedOS,
completeOnboarding,
resetProfile,
getEffectiveOS,
isProfileComplete,
detectedOS: profile.detectedOS,
hasCompletedOnboarding: profile.hasCompletedOnboarding,
};
}
+81 -46
View File
@@ -1,92 +1,127 @@
import { Platform, Package, FilterOptions } from '@/types'
import { Platform, Package, FilterOptions } from "@/types";
import {
RecommendationRequest,
RecommendationResponse,
} from "@/types/recommendations";
const API_BASE_URL = (process.env.NEXT_PUBLIC_API_URL && process.env.NEXT_PUBLIC_API_URL.trim() !== '')
? process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, '')
: '/api'
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL &&
process.env.NEXT_PUBLIC_API_URL.trim() !== ""
? process.env.NEXT_PUBLIC_API_URL.replace(/\/$/, "")
: "/api";
class ApiClient {
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
...options.headers,
},
...options,
})
});
if (!response.ok) {
throw new Error(`API request failed: ${response.statusText}`)
throw new Error(`API request failed: ${response.statusText}`);
}
return response.json()
return response.json();
}
// Platform operations
async getPlatforms(): Promise<Platform[]> {
return this.request<Platform[]>('/platforms')
return this.request<Platform[]>("/platforms");
}
async getPlatform(id: string): Promise<Platform | null> {
try {
return await this.request<Platform>(`/platforms/${id}`)
return await this.request<Platform>(`/platforms/${id}`);
} catch (error) {
return null
return null;
}
}
// Package operations
async getPackages(filters: FilterOptions = {}): Promise<{ packages: Package[], total: number }> {
const params = new URLSearchParams()
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)
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}`)
const query = params.toString() ? `?${params.toString()}` : "";
return this.request<{ packages: Package[]; total: number }>(
`/packages${query}`
);
}
async getPackage(id: string): Promise<Package | null> {
try {
return await this.request<Package>(`/packages/${id}`)
return await this.request<Package>(`/packages/${id}`);
} catch (error) {
return null
return null;
}
}
// Sync operations
async syncDebianPackages(): Promise<{ message: string, timestamp: string }> {
return this.request<{ message: string, timestamp: string }>('/sync', {
method: 'POST',
body: JSON.stringify({ source: 'debian-official' }),
})
async syncDebianPackages(): Promise<{ message: string; timestamp: string }> {
return this.request<{ message: string; timestamp: string }>("/sync", {
method: "POST",
body: JSON.stringify({ source: "debian-official" }),
});
}
async syncUbuntuPackages(): Promise<{ message: string, timestamp: string }> {
return this.request<{ message: string, timestamp: string }>('/sync', {
method: 'POST',
body: JSON.stringify({ source: 'ubuntu-official' }),
})
async syncUbuntuPackages(): Promise<{ message: string; timestamp: string }> {
return this.request<{ message: string; timestamp: string }>("/sync", {
method: "POST",
body: JSON.stringify({ source: "ubuntu-official" }),
});
}
async syncAllDebianPackages(): Promise<{ message: string, timestamp: string }> {
return this.request<{ message: string, timestamp: string }>('/sync', {
method: 'POST',
body: JSON.stringify({ source: 'all-official' }),
})
async syncAllDebianPackages(): Promise<{
message: string;
timestamp: string;
}> {
return this.request<{ message: string; timestamp: string }>("/sync", {
method: "POST",
body: JSON.stringify({ source: "all-official" }),
});
}
async getSyncStatus(): Promise<{ status: string, last_sync: string | null, platforms: string[] }> {
return this.request<{ status: string, last_sync: string | null, platforms: string[] }>('/sync')
async getSyncStatus(): Promise<{
status: string;
last_sync: string | null;
platforms: string[];
}> {
return this.request<{
status: string;
last_sync: string | null;
platforms: string[];
}>("/sync");
}
// Recommendation operations
async getRecommendations(
request: RecommendationRequest
): Promise<RecommendationResponse> {
return this.request<RecommendationResponse>("/recommendations", {
method: "POST",
body: JSON.stringify(request),
});
}
}
export const apiClient = new ApiClient()
export const apiClient = new ApiClient();
+1 -1
View File
@@ -1,4 +1,4 @@
import { type ClassValue, clsx } from "clsx"
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
+10 -2
View File
@@ -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' },
+230
View File
@@ -0,0 +1,230 @@
import { Package } from "@/models/Package";
import {
RecommendationRequest,
RecommendedPackage,
UserCategory,
} from "@/types/recommendations";
import { getPackagesWithCategories, getPresetDetails, getPresetIcon } from "@/data/recommendationPresets";
export class RecommendationService {
/**
* Generate package recommendations based on user profile
*/
static async generateRecommendations(
request: RecommendationRequest
): Promise<RecommendedPackage[]> {
const { platform_id, categories, limit = 20 } = request;
// Step 1: Get preset package names for the user's categories and platform
const presetPackagesInfo = getPackagesWithCategories(platform_id, categories);
const presetPackageNames = presetPackagesInfo.map(p => p.name);
// Step 2: Generate packages from presets (no DB query)
const packageCategoryMap = new Map<string, UserCategory>();
// Fetch preset packages
const presetPackages = await this.fetchPresetPackages(
presetPackagesInfo,
platform_id,
packageCategoryMap
);
// Step 3: Score and rank packages
const scoredPackages = presetPackages.map((pkg) => {
const matchedCategory = packageCategoryMap.get(pkg.id) || categories[0];
return this.scorePackage(
pkg,
categories,
platform_id,
presetPackageNames,
matchedCategory
);
});
// Step 4: Sort by score and limit results
scoredPackages.sort(
(a, b) => b.recommendationScore - a.recommendationScore
);
return scoredPackages.slice(0, limit);
}
/**
* Fetch packages that match preset names
* optimized to use static data instead of DB queries
*/
private static async fetchPresetPackages(
packagesInfo: { name: string; category: UserCategory }[],
platformId: string,
categoryMap: Map<string, UserCategory>
): Promise<Package[]> {
if (packagesInfo.length === 0) {
return [];
}
const packages: Package[] = [];
for (const { name, category } of packagesInfo) {
// Get icon slug if available
const iconSlug = getPresetIcon(name);
// Create a mock package object to avoid database queries
// This ensures instant loading for recommendations
const mockPackage: Package = {
id: `${platformId}:${name.toLowerCase()}`,
name: name,
description: "", // Description removed as requested
version: "latest",
platform_id: platformId,
type: "cli",
repository: "official",
popularity_score: 100,
is_active: true,
created_at: new Date(),
updated_at: new Date(),
downloads_count: 10000,
platform: {
id: platformId,
name: platformId.charAt(0).toUpperCase() + platformId.slice(1),
package_manager: "unknown"
},
// We attach the icon slug to the tags temporarily or we can add a custom field if we extend the type
// But simpler is to pass it through the system.
// Actually, Package interface doesn't have icon.
// RecommendedPackage does (we added it).
// So we need to handle this in scorePackage or casting.
};
// Hack: Store icon slug in tags so it survives until scorePackage
if (iconSlug) {
mockPackage.tags = [`icon:${iconSlug}`];
}
packages.push(mockPackage);
categoryMap.set(mockPackage.id, category);
}
return packages;
}
/**
* Remove duplicate packages (by ID)
*/
private static deduplicatePackages(packages: Package[]): Package[] {
const seen = new Set<string>();
return packages.filter((pkg) => {
if (seen.has(pkg.id)) {
return false;
}
seen.add(pkg.id);
return true;
});
}
/**
* Score a package based on simplified factors (popularity & preset)
*/
private static scorePackage(
pkg: Package,
categories: UserCategory[],
platformId: string,
presetPackageNames: string[],
matchedCategory?: UserCategory
): RecommendedPackage {
const isPresetMatch = presetPackageNames.includes(pkg.name);
// Extract icon from tags if present
let icon: string | undefined;
if (pkg.tags) {
const iconTag = pkg.tags.find(tag => tag.startsWith('icon:'));
if (iconTag) {
icon = iconTag.replace('icon:', '');
}
}
// Simplified score: just use popularity score (0-100)
// Give a boost to preset packages so they appear first
let finalScore = pkg.popularity_score || 0;
if (isPresetMatch) {
finalScore += 100; // Ensure presets are always on top
}
return {
id: pkg.id,
name: pkg.name,
description: pkg.description || "No description available",
version: pkg.version || "latest",
category:
typeof pkg.category === "string" ? pkg.category : pkg.category?.name,
license:
typeof pkg.license === "string" ? pkg.license : pkg.license?.name,
type: pkg.type || "cli",
platform: pkg.platform as any,
platform_id: pkg.platform_id,
repository: pkg.repository || "official",
download_url: pkg.download_url,
lastUpdated: pkg.last_updated ? pkg.last_updated.toString() : undefined,
downloads: pkg.downloads_count,
popularity: pkg.popularity_score,
popularity_score: pkg.popularity_score,
tags: pkg.tags,
recommendationScore: finalScore,
recommendationReason: "", // Removed as requested
presetMatch: isPresetMatch,
matchedCategory: matchedCategory,
icon: icon
};
}
/**
* Get quick start recommendations (top 5 most essential)
*/
static async getQuickStartRecommendations(
platformId: string,
primaryCategory: UserCategory
): Promise<RecommendedPackage[]> {
return this.generateRecommendations({
platform_id: platformId,
categories: [primaryCategory],
limit: 5,
});
}
/**
* Get recommendations for multiple categories with balanced distribution
*/
static async getBalancedRecommendations(
platformId: string,
categories: UserCategory[],
totalLimit: number = 20
): Promise<RecommendedPackage[]> {
const perCategory = Math.ceil(totalLimit / categories.length);
const allRecommendations: RecommendedPackage[] = [];
for (const category of categories) {
const recommendations = await this.generateRecommendations({
platform_id: platformId,
categories: [category],
limit: perCategory,
});
allRecommendations.push(...recommendations);
}
// Deduplicate by ID and re-sort
const seen = new Set<string>();
const deduplicated = allRecommendations.filter((pkg) => {
if (seen.has(pkg.id)) {
return false;
}
seen.add(pkg.id);
return true;
});
deduplicated.sort((a, b) => b.recommendationScore - a.recommendationScore);
return deduplicated.slice(0, totalLimit);
}
}
+94
View File
@@ -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;
};
}
+78 -63
View File
@@ -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")],
}
+18 -4
View File
@@ -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"
]
}