Initial commit: ArchStore package manager for Arch Linux

This commit is contained in:
2026-05-21 02:42:03 +05:30
commit 027847fbac
51 changed files with 6993 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# Node
node_modules/
dist/
.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Python
__pycache__/
*.py[cod]
*$py.class
venv/
.venv/
env/
.env/
pip-log.txt
pip-delete-this-directory.txt
# Databases
*.db
*.db-journal
*.db-shm
*.db-wal
# IDE / System
.vscode/
.idea/
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.swp
+166
View File
@@ -0,0 +1,166 @@
# CLAUDE.md
# Project Name
ArchStore
# Project Description
ArchStore is a lightweight modern package store for Arch Linux.
It combines official pacman repositories and the AUR into one clean interface similar to a Play Store.
Users can:
- Search packages
- Install packages
- View package details
- Check updates
- Browse categories
- Analyze package security
---
# Goals
- Fast and lightweight
- Modern UI
- Secure package installation
- Unified package ecosystem
- Beginner friendly
- Open source
---
# Core Features
## Package Search
Search packages from:
- pacman repositories
- AUR repositories
---
## Package Information
Show:
- package name
- description
- maintainer
- dependencies
- version
- popularity
- votes
- package size
- last updated
---
## One Click Install
Install packages using:
- pacman
- paru
---
## Update Center
Show available package updates.
---
## Security Scanner
Analyze PKGBUILD files for:
- dangerous bash commands
- suspicious scripts
- hidden downloads
- obfuscated code
- remote execution attempts
---
# Tech Stack
## Frontend
- React
- TailwindCSS
- Vite
## Backend
- Python
- FastAPI
## Database
- SQLite
---
# APIs
## AUR RPC
https://aur.archlinux.org/rpc/
---
# Backend Structure
backend/
├── api/
├── scanner/
├── services/
├── database/
├── main.py
---
# Frontend Structure
frontend/
├── src/
├── components/
├── pages/
├── layouts/
├── services/
---
# UI Style
- Dark theme
- Minimal interface
- Fast navigation
- Responsive design
---
# Future Features
- AI malware detection
- Verified packages
- Package reviews
- Package screenshots
- Dependency graph
- Flatpak support
- Snap support
- Electron desktop client
---
# Security Rules
- Never execute unknown scripts directly
- Always sanitize shell commands
- Validate package metadata
- Use sandboxed package analysis
- Prevent command injection
---
# Development Commands
## Backend
uvicorn main:app --reload
## Frontend
npm run dev
---
# Project Vision
Create the best lightweight package store experience for Arch Linux users.
---
# Maintainer
Aur & Arch 5t4l1n
github:0x5t4l1n
+235
View File
@@ -0,0 +1,235 @@
# SKILLS.md
# Required Skills for ArchStore
ArchStore is a lightweight package store for Arch Linux that combines pacman repositories and AUR packages into one modern interface.
---
# Core Skills
## Linux Skills
- Arch Linux basics
- pacman package manager
- AUR package system
- PKGBUILD understanding
- systemd basics
- terminal usage
---
# Backend Skills
## Python
Required for:
- API development
- package analysis
- backend services
Topics:
- FastAPI
- subprocess
- async programming
- REST APIs
- JSON handling
---
## FastAPI
Required for:
- backend API server
- frontend communication
Topics:
- routes
- API responses
- middleware
- async endpoints
---
# Frontend Skills
## HTML
Required for:
- page structure
---
## CSS
Required for:
- styling
- responsive design
---
## TailwindCSS
Required for:
- modern UI
- fast styling
---
## JavaScript
Required for:
- dynamic frontend
- API requests
Topics:
- fetch API
- async/await
- DOM manipulation
---
## React
Required for:
- scalable frontend
- reusable components
Topics:
- components
- hooks
- routing
- state management
---
# Database Skills
## SQLite
Required for:
- package cache
- saved metadata
Topics:
- CRUD operations
- indexing
- schema design
---
# Security Skills
## Bash Analysis
Required for:
- PKGBUILD scanning
- script analysis
Topics:
- shell commands
- bash syntax
- command injection detection
---
## Package Security
Required for:
- detecting suspicious packages
Topics:
- malicious scripts
- obfuscation
- unsafe downloads
- privilege escalation risks
---
# API Skills
## AUR RPC API
https://aur.archlinux.org/rpc/
Required for:
- searching AUR packages
- fetching metadata
---
# DevOps Skills
## Git
Required for:
- version control
Topics:
- commits
- branches
- pull requests
---
## Docker
Optional but useful for:
- sandbox builds
- isolated package analysis
---
# UI/UX Skills
Required for:
- modern package store experience
Topics:
- dark themes
- responsive layouts
- minimal UI
- accessibility
---
# Recommended Learning Order
1. Arch Linux basics
2. pacman and AUR
3. Python
4. FastAPI
5. HTML/CSS
6. JavaScript
7. React
8. TailwindCSS
9. Security scanning
10. Advanced package analysis
---
# Nice-to-Have Skills
- Electron
- Rust
- Go
- Redis
- PostgreSQL
- AI/ML
- Malware analysis
---
# Development Tools
## Editors
- VS Code
## API Testing
- Postman
- curl
## Browser Dev Tools
- Firefox Developer Tools
---
# Future Advanced Skills
- AI package risk analysis
- dependency graph visualization
- reproducible builds
- package signing verification
- CVE integration
- container sandboxing
---
# Final Goal
Build a modern lightweight Play Store experience for Arch Linux users.
+1
View File
@@ -0,0 +1 @@
# api package
+1
View File
@@ -0,0 +1 @@
# api.routes package
+33
View File
@@ -0,0 +1,33 @@
"""
Categories API routes for ArchStore.
Handles package category browsing.
"""
from fastapi import APIRouter, HTTPException
from services import package_service
router = APIRouter(prefix="/api/categories", tags=["categories"])
@router.get("")
async def list_categories():
"""Get all available package categories."""
try:
categories = await package_service.get_categories()
return {"results": categories, "count": len(categories)}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get categories: {str(e)}")
@router.get("/{name}")
async def get_category_packages(name: str):
"""Get packages in a specific category."""
try:
packages = await package_service.get_category_packages(name)
if not packages and name not in [c["name"] for c in await package_service.get_categories()]:
raise HTTPException(status_code=404, detail=f"Category '{name}' not found")
return {"category": name, "results": packages, "count": len(packages)}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get category: {str(e)}")
+109
View File
@@ -0,0 +1,109 @@
"""
Package API routes for ArchStore.
Handles search, info, install, and remove endpoints.
"""
from fastapi import APIRouter, HTTPException, Query
from services import package_service, aur_service
from scanner.security import scan_pkgbuild
from utils.sanitize import sanitize_package_name, sanitize_search_query
router = APIRouter(prefix="/api/packages", tags=["packages"])
@router.get("/search")
async def search_packages(
q: str = Query(..., min_length=1, max_length=128, description="Search query"),
source: str = Query("all", description="Source filter: all, pacman, aur"),
):
"""Search packages across pacman and AUR."""
try:
query = sanitize_search_query(q)
results = await package_service.search_packages(query, source)
return {"results": results, "count": len(results), "query": query, "source": source}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
@router.get("/installed")
async def list_installed():
"""List all installed packages."""
try:
packages = await package_service.list_installed()
return {"results": packages, "count": len(packages)}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to list packages: {str(e)}")
@router.get("/{name}")
async def get_package_info(name: str):
"""Get detailed info about a specific package."""
try:
name = sanitize_package_name(name)
info = await package_service.get_package_info(name)
if not info:
raise HTTPException(status_code=404, detail=f"Package '{name}' not found")
return info
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get package info: {str(e)}")
@router.get("/{name}/scan")
async def scan_package(name: str):
"""Scan an AUR package's PKGBUILD for security issues."""
try:
name = sanitize_package_name(name)
pkgbuild = await aur_service.get_pkgbuild(name)
if not pkgbuild:
return {
"package_name": name,
"risk_score": 0,
"findings": [],
"scanned": False,
"risk_level": "unknown",
"message": "PKGBUILD not found (may be a pacman package)",
}
result = scan_pkgbuild(name, pkgbuild)
return result.to_dict()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{name}/install")
async def install_package(name: str):
"""Install a package."""
try:
name = sanitize_package_name(name)
result = await package_service.install_package(name)
if not result["success"]:
raise HTTPException(status_code=500, detail=result["message"])
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Install failed: {str(e)}")
@router.post("/{name}/remove")
async def remove_package(name: str):
"""Remove an installed package."""
try:
name = sanitize_package_name(name)
result = await package_service.remove_package(name)
if not result["success"]:
raise HTTPException(status_code=500, detail=result["message"])
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Remove failed: {str(e)}")
+53
View File
@@ -0,0 +1,53 @@
"""
Updates API routes for ArchStore.
Handles update checking and applying updates.
"""
from fastapi import APIRouter, HTTPException
from services import package_service
router = APIRouter(prefix="/api/updates", tags=["updates"])
@router.get("/check")
async def check_updates():
"""Check for available package updates (pacman + AUR)."""
try:
updates = await package_service.check_updates()
return {
"results": updates,
"count": len(updates),
"pacman_count": sum(1 for u in updates if u["source"] == "pacman"),
"aur_count": sum(1 for u in updates if u["source"] == "aur"),
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Update check failed: {str(e)}")
@router.post("/apply")
async def apply_updates():
"""Apply all available updates. This is a long-running operation."""
try:
import asyncio
from utils.sanitize import sanitize_package_name
async def _run_command(cmd, timeout=600):
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
return stdout.decode(), stderr.decode(), proc.returncode
# Run system update via yay (handles both pacman and AUR)
stdout, stderr, code = await _run_command(
["yay", "-Syu", "--noconfirm"], timeout=600
)
return {
"success": code == 0,
"message": stdout if code == 0 else stderr,
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Update failed: {str(e)}")
+1
View File
@@ -0,0 +1 @@
# database package
+141
View File
@@ -0,0 +1,141 @@
"""
SQLite database manager for ArchStore.
Handles connection lifecycle, schema creation, and cache operations.
"""
import aiosqlite
import os
import time
import json
from typing import Optional
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "archstore.db")
CACHE_TTL = 900 # 15 minutes in seconds
class Database:
"""Async SQLite database manager."""
def __init__(self, db_path: str = DB_PATH):
self.db_path = db_path
self._db: Optional[aiosqlite.Connection] = None
async def connect(self):
"""Open database connection and create tables."""
self._db = await aiosqlite.connect(self.db_path)
self._db.row_factory = aiosqlite.Row
await self._db.execute("PRAGMA journal_mode=WAL")
await self._create_tables()
async def close(self):
"""Close database connection."""
if self._db:
await self._db.close()
self._db = None
async def _create_tables(self):
"""Create required tables if they don't exist."""
await self._db.executescript("""
CREATE TABLE IF NOT EXISTS search_cache (
query TEXT NOT NULL,
source TEXT NOT NULL,
results TEXT NOT NULL,
created_at REAL NOT NULL,
PRIMARY KEY (query, source)
);
CREATE TABLE IF NOT EXISTS package_cache (
name TEXT PRIMARY KEY,
data TEXT NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_search_cache_time
ON search_cache(created_at);
CREATE INDEX IF NOT EXISTS idx_package_cache_time
ON package_cache(created_at);
""")
await self._db.commit()
async def get_cached_search(self, query: str, source: str) -> Optional[list]:
"""Get cached search results if still fresh."""
cursor = await self._db.execute(
"SELECT results, created_at FROM search_cache WHERE query = ? AND source = ?",
(query, source)
)
row = await cursor.fetchone()
if row and (time.time() - row["created_at"]) < CACHE_TTL:
return json.loads(row["results"])
return None
async def set_cached_search(self, query: str, source: str, results: list):
"""Cache search results."""
await self._db.execute(
"""INSERT OR REPLACE INTO search_cache (query, source, results, created_at)
VALUES (?, ?, ?, ?)""",
(query, source, json.dumps(results), time.time())
)
await self._db.commit()
async def get_cached_package(self, name: str) -> Optional[dict]:
"""Get cached package info if still fresh."""
cursor = await self._db.execute(
"SELECT data, created_at FROM package_cache WHERE name = ?",
(name,)
)
row = await cursor.fetchone()
if row and (time.time() - row["created_at"]) < CACHE_TTL:
return json.loads(row["data"])
return None
async def set_cached_package(self, name: str, data: dict):
"""Cache package info."""
await self._db.execute(
"""INSERT OR REPLACE INTO package_cache (name, data, created_at)
VALUES (?, ?, ?)""",
(name, json.dumps(data), time.time())
)
await self._db.commit()
async def get_setting(self, key: str, default: str = "") -> str:
"""Get a setting value."""
cursor = await self._db.execute(
"SELECT value FROM settings WHERE key = ?", (key,)
)
row = await cursor.fetchone()
return row["value"] if row else default
async def set_setting(self, key: str, value: str):
"""Set a setting value."""
await self._db.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)",
(key, value)
)
await self._db.commit()
async def clear_cache(self):
"""Clear all cached data."""
await self._db.execute("DELETE FROM search_cache")
await self._db.execute("DELETE FROM package_cache")
await self._db.commit()
async def cleanup_expired(self):
"""Remove expired cache entries."""
cutoff = time.time() - CACHE_TTL
await self._db.execute(
"DELETE FROM search_cache WHERE created_at < ?", (cutoff,)
)
await self._db.execute(
"DELETE FROM package_cache WHERE created_at < ?", (cutoff,)
)
await self._db.commit()
# Global database instance
db = Database()
+82
View File
@@ -0,0 +1,82 @@
"""
ArchStore Backend — Main Application
A lightweight package store API for Arch Linux.
"""
import sys
import os
# Add backend directory to path for imports
sys.path.insert(0, os.path.dirname(__file__))
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from database.db import db
from api.routes import packages, updates, categories
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifecycle: startup and shutdown."""
# Startup
await db.connect()
print("✓ Database connected")
print("✓ ArchStore backend ready")
yield
# Shutdown
await db.close()
print("✗ Database disconnected")
app = FastAPI(
title="ArchStore API",
description="A lightweight package store API for Arch Linux combining pacman and AUR.",
version="1.0.0",
lifespan=lifespan,
)
# CORS — allow frontend dev server
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:3000",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount route modules
app.include_router(packages.router)
app.include_router(updates.router)
app.include_router(categories.router)
@app.get("/")
async def root():
"""Health check endpoint."""
return {
"name": "ArchStore API",
"version": "1.0.0",
"status": "running",
}
@app.get("/api/health")
async def health():
"""Detailed health check."""
return {
"status": "healthy",
"database": "connected",
"version": "1.0.0",
}
@app.post("/api/cache/clear")
async def clear_cache():
"""Clear all cached data."""
await db.clear_cache()
return {"status": "ok", "message": "Cache cleared"}
+5
View File
@@ -0,0 +1,5 @@
fastapi==0.115.12
uvicorn[standard]==0.34.3
httpx==0.28.1
aiosqlite==0.21.0
pydantic==2.11.3
+1
View File
@@ -0,0 +1 @@
# scanner package
+175
View File
@@ -0,0 +1,175 @@
"""
PKGBUILD Security Scanner for ArchStore.
Analyzes PKGBUILD files for suspicious or dangerous patterns.
"""
import re
from dataclasses import dataclass, field
@dataclass
class ScanFinding:
"""A single security finding."""
severity: str # "critical", "warning", "info"
category: str
description: str
line_number: int = 0
line_content: str = ""
@dataclass
class ScanResult:
"""Complete scan result."""
package_name: str
risk_score: int = 0 # 0-100
findings: list = field(default_factory=list)
scanned: bool = False
def to_dict(self) -> dict:
return {
"package_name": self.package_name,
"risk_score": self.risk_score,
"findings": [
{
"severity": f.severity,
"category": f.category,
"description": f.description,
"line_number": f.line_number,
"line_content": f.line_content,
}
for f in self.findings
],
"scanned": self.scanned,
"risk_level": _risk_level(self.risk_score),
}
def _risk_level(score: int) -> str:
if score >= 70:
return "critical"
elif score >= 40:
return "warning"
elif score >= 10:
return "low"
return "safe"
# --- Pattern Definitions ---
CRITICAL_PATTERNS = [
(r'curl\s+.*\|\s*(ba)?sh', "Remote code execution via curl pipe to shell"),
(r'wget\s+.*\|\s*(ba)?sh', "Remote code execution via wget pipe to shell"),
(r'curl\s+.*\|\s*python', "Remote code execution via curl pipe to python"),
(r'eval\s*\$\(', "Dynamic code evaluation with command substitution"),
(r'base64\s+(-d|--decode)', "Base64 decoding (possible obfuscation)"),
(r'\\x[0-9a-fA-F]{2}', "Hex-encoded characters (possible obfuscation)"),
(r'rm\s+-rf\s+(/\s|/\*|/home|/etc|/usr|/var)', "Dangerous recursive deletion of system paths"),
(r'chmod\s+[0-7]*777', "Setting world-writable permissions"),
(r'chmod\s+\+s', "Setting SUID/SGID bit"),
(r'/dev/tcp/', "Direct TCP socket access"),
(r'nc\s+-[el]', "Netcat listener (possible backdoor)"),
(r'mkfifo.*\|.*sh', "Named pipe shell redirect (possible backdoor)"),
]
WARNING_PATTERNS = [
(r'curl\s+', "Network request using curl"),
(r'wget\s+', "Network request using wget"),
(r'git\s+clone', "Git clone operation"),
(r'pip\s+install', "Python pip install (may bypass pacman)"),
(r'npm\s+install\s+-g', "Global npm install"),
(r'sudo\s+', "Sudo usage in PKGBUILD"),
(r'systemctl\s+(enable|start)', "Enabling/starting services"),
(r'dd\s+if=', "Direct disk write with dd"),
(r'mkfs\.', "Filesystem formatting command"),
(r'eval\s+', "Use of eval"),
(r'exec\s+', "Use of exec"),
]
INFO_PATTERNS = [
(r'source=\(', "Source file declarations"),
(r'makedepends=', "Build dependencies declared"),
(r'depends=', "Runtime dependencies declared"),
(r'sha256sums=|sha512sums=|md5sums=', "Checksum verification present"),
(r'check\(\)', "Check function present (good practice)"),
]
def scan_pkgbuild(package_name: str, content: str) -> ScanResult:
"""
Scan a PKGBUILD file for security issues.
Returns a ScanResult with findings and risk score.
"""
result = ScanResult(package_name=package_name, scanned=True)
if not content or not content.strip():
result.findings.append(ScanFinding(
severity="warning",
category="empty",
description="PKGBUILD is empty or could not be retrieved",
))
result.risk_score = 20
return result
lines = content.split("\n")
for i, line in enumerate(lines, 1):
stripped = line.strip()
# Skip comments
if stripped.startswith("#"):
continue
# Check critical patterns
for pattern, description in CRITICAL_PATTERNS:
if re.search(pattern, stripped, re.IGNORECASE):
result.findings.append(ScanFinding(
severity="critical",
category="dangerous_command",
description=description,
line_number=i,
line_content=stripped[:200],
))
result.risk_score += 25
# Check warning patterns
for pattern, description in WARNING_PATTERNS:
if re.search(pattern, stripped, re.IGNORECASE):
result.findings.append(ScanFinding(
severity="warning",
category="suspicious_command",
description=description,
line_number=i,
line_content=stripped[:200],
))
result.risk_score += 5
# Check info patterns
for pattern, description in INFO_PATTERNS:
if re.search(pattern, stripped, re.IGNORECASE):
result.findings.append(ScanFinding(
severity="info",
category="metadata",
description=description,
line_number=i,
line_content=stripped[:200],
))
# Check for missing checksums (security concern)
if not re.search(r'(sha256sums|sha512sums|b2sums)=', content):
result.findings.append(ScanFinding(
severity="warning",
category="missing_verification",
description="No strong checksum verification (sha256/sha512/b2) found",
))
result.risk_score += 10
# Check for missing check() function
if "check()" not in content:
result.findings.append(ScanFinding(
severity="info",
category="best_practice",
description="No check() function defined (testing not enforced)",
))
# Cap score at 100
result.risk_score = min(100, result.risk_score)
return result
+1
View File
@@ -0,0 +1 @@
# services package
+164
View File
@@ -0,0 +1,164 @@
"""
AUR RPC API service for ArchStore.
Handles all interactions with the Arch User Repository via the official RPC API
and yay for installations.
"""
import asyncio
import httpx
from typing import Optional
from utils.sanitize import sanitize_package_name, sanitize_search_query
AUR_RPC_BASE = "https://aur.archlinux.org/rpc/v5"
AUR_PACKAGE_URL = "https://aur.archlinux.org/packages"
async def _run_command(cmd: list[str], timeout: int = 30) -> tuple[str, str, int]:
"""Run a shell command safely."""
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
return (
stdout.decode("utf-8", errors="replace"),
stderr.decode("utf-8", errors="replace"),
proc.returncode,
)
except asyncio.TimeoutError:
proc.kill()
return "", "Command timed out", -1
except Exception as e:
return "", str(e), -1
async def search_packages(query: str) -> list[dict]:
"""Search AUR packages using the RPC API."""
query = sanitize_search_query(query)
async with httpx.AsyncClient(timeout=15) as client:
try:
response = await client.get(
f"{AUR_RPC_BASE}/search/{query}",
params={"by": "name-desc"},
)
response.raise_for_status()
data = response.json()
if data.get("type") != "search":
return []
packages = []
for pkg in data.get("results", []):
packages.append(_normalize_aur_package(pkg))
# Sort by popularity descending
packages.sort(key=lambda p: p.get("popularity", 0), reverse=True)
return packages[:100] # Limit results
except (httpx.HTTPError, Exception):
return []
async def get_package_info(name: str) -> Optional[dict]:
"""Get detailed info about an AUR package."""
name = sanitize_package_name(name)
async with httpx.AsyncClient(timeout=10) as client:
try:
response = await client.get(
f"{AUR_RPC_BASE}/info",
params={"arg[]": name},
)
response.raise_for_status()
data = response.json()
results = data.get("results", [])
if not results:
return None
pkg = _normalize_aur_package(results[0])
# Check if installed
_, _, code = await _run_command(["pacman", "-Q", name], timeout=5)
pkg["installed"] = code == 0
return pkg
except (httpx.HTTPError, Exception):
return None
async def install_package(name: str) -> dict:
"""Install an AUR package using yay."""
name = sanitize_package_name(name)
stdout, stderr, code = await _run_command(
["yay", "-S", "--noconfirm", name], timeout=600
)
return {
"success": code == 0,
"message": stdout if code == 0 else stderr,
"package": name,
}
async def check_updates() -> list[dict]:
"""Check for AUR package updates."""
stdout, _, code = await _run_command(
["yay", "-Qua"], timeout=30
)
if code != 0:
return []
updates = []
for line in stdout.strip().split("\n"):
if line.strip():
parts = line.split()
if len(parts) >= 4:
updates.append({
"name": parts[0],
"current_version": parts[1],
"new_version": parts[3],
"source": "aur",
})
return updates
async def get_pkgbuild(name: str) -> Optional[str]:
"""Fetch the PKGBUILD content for an AUR package."""
name = sanitize_package_name(name)
async with httpx.AsyncClient(timeout=15) as client:
try:
url = f"https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h={name}"
response = await client.get(url)
if response.status_code == 200:
return response.text
return None
except httpx.HTTPError:
return None
def _normalize_aur_package(pkg: dict) -> dict:
"""Convert AUR RPC response to our standard package format."""
return {
"name": pkg.get("Name", ""),
"version": pkg.get("Version", ""),
"description": pkg.get("Description", ""),
"maintainer": pkg.get("Maintainer", "Orphaned"),
"url": pkg.get("URL", ""),
"votes": pkg.get("NumVotes", 0),
"popularity": pkg.get("Popularity", 0),
"out_of_date": pkg.get("OutOfDate") is not None,
"first_submitted": pkg.get("FirstSubmitted", 0),
"last_modified": pkg.get("LastModified", 0),
"source": "aur",
"repository": "aur",
"aur_url": f"{AUR_PACKAGE_URL}/{pkg.get('Name', '')}",
"package_base": pkg.get("PackageBase", ""),
"installed": False,
}
+228
View File
@@ -0,0 +1,228 @@
"""
Unified package service for ArchStore.
Merges pacman and AUR results, handles deduplication and ranking.
"""
import asyncio
from typing import Optional
from services import pacman_service, aur_service
from database.db import db
from utils.sanitize import sanitize_search_query, sanitize_package_name, validate_source_filter
# Category definitions — maps friendly names to pacman groups
CATEGORIES = {
"Development": {
"icon": "code",
"description": "Programming tools, compilers, and IDEs",
"groups": ["base-devel"],
"keywords": ["gcc", "git", "python", "nodejs", "rust", "go", "vim", "neovim", "code"],
},
"System": {
"icon": "monitor",
"description": "Core system utilities and tools",
"groups": ["base", "sys-utils"],
"keywords": ["systemd", "kernel", "grub", "filesystem", "coreutils"],
},
"Network": {
"icon": "wifi",
"description": "Networking tools, browsers, and servers",
"groups": ["network"],
"keywords": ["firefox", "chromium", "curl", "wget", "nginx", "ssh"],
},
"Multimedia": {
"icon": "music",
"description": "Audio, video, and image tools",
"groups": ["multimedia"],
"keywords": ["vlc", "mpv", "ffmpeg", "gimp", "audacity", "obs"],
},
"Games": {
"icon": "gamepad-2",
"description": "Games and gaming tools",
"groups": ["games"],
"keywords": ["steam", "lutris", "wine", "gamemode"],
},
"Desktop": {
"icon": "layout-dashboard",
"description": "Desktop environments and window managers",
"groups": ["gnome", "kde-applications", "xfce4"],
"keywords": ["gnome", "kde", "xfce", "i3", "sway", "hyprland"],
},
"Fonts": {
"icon": "type",
"description": "Fonts and typography",
"groups": ["fonts"],
"keywords": ["ttf", "otf", "nerd-fonts", "noto"],
},
"Security": {
"icon": "shield",
"description": "Security and privacy tools",
"groups": [],
"keywords": ["firewall", "gpg", "openssl", "wireguard", "tor"],
},
}
async def search_packages(query: str, source: str = "all") -> list[dict]:
"""
Search packages from pacman, AUR, or both.
Results are deduplicated, merged, and ranked.
"""
query = sanitize_search_query(query)
source = validate_source_filter(source)
# Check cache first
cached = await db.get_cached_search(query, source)
if cached:
return cached
results = []
if source in ("all", "pacman"):
pacman_task = pacman_service.search_packages(query)
else:
pacman_task = asyncio.coroutine(lambda: [])()
if source in ("all", "aur"):
aur_task = aur_service.search_packages(query)
else:
aur_task = asyncio.coroutine(lambda: [])()
# Run both searches concurrently
if source == "all":
pacman_results, aur_results = await asyncio.gather(
pacman_service.search_packages(query),
aur_service.search_packages(query),
return_exceptions=True,
)
if isinstance(pacman_results, Exception):
pacman_results = []
if isinstance(aur_results, Exception):
aur_results = []
results = _merge_results(pacman_results, aur_results)
elif source == "pacman":
results = await pacman_service.search_packages(query)
elif source == "aur":
results = await aur_service.search_packages(query)
# Cache results
await db.set_cached_search(query, source, results)
return results
async def get_package_info(name: str) -> Optional[dict]:
"""Get detailed package info, trying pacman first then AUR."""
name = sanitize_package_name(name)
# Check cache
cached = await db.get_cached_package(name)
if cached:
return cached
# Try pacman first
info = await pacman_service.get_package_info(name)
if not info:
# Try AUR
info = await aur_service.get_package_info(name)
if not info:
# Check if installed locally
info = await pacman_service.get_installed_info(name)
if info:
await db.set_cached_package(name, info)
return info
async def install_package(name: str) -> dict:
"""Install a package, auto-detecting source."""
name = sanitize_package_name(name)
# Try pacman first
info = await pacman_service.get_package_info(name)
if info:
return await pacman_service.install_package(name)
# Fall back to AUR via yay
return await aur_service.install_package(name)
async def remove_package(name: str) -> dict:
"""Remove an installed package."""
name = sanitize_package_name(name)
return await pacman_service.remove_package(name)
async def list_installed() -> list[dict]:
"""List all installed packages."""
return await pacman_service.list_installed()
async def check_updates() -> list[dict]:
"""Check for all available updates (pacman + AUR)."""
pacman_updates, aur_updates = await asyncio.gather(
pacman_service.check_updates(),
aur_service.check_updates(),
return_exceptions=True,
)
if isinstance(pacman_updates, Exception):
pacman_updates = []
if isinstance(aur_updates, Exception):
aur_updates = []
return pacman_updates + aur_updates
async def get_categories() -> list[dict]:
"""Get list of package categories."""
return [
{"name": name, **data}
for name, data in CATEGORIES.items()
]
async def get_category_packages(category: str) -> list[dict]:
"""Get packages for a specific category using search."""
cat = CATEGORIES.get(category)
if not cat:
return []
# Search using category keywords
all_results = []
for keyword in cat.get("keywords", [])[:5]:
try:
results = await search_packages(keyword, "all")
all_results.extend(results)
except Exception:
continue
# Deduplicate by name
seen = set()
unique = []
for pkg in all_results:
if pkg["name"] not in seen:
seen.add(pkg["name"])
unique.append(pkg)
return unique[:50]
def _merge_results(pacman: list[dict], aur: list[dict]) -> list[dict]:
"""Merge and deduplicate pacman + AUR results."""
seen = {}
# Pacman results take priority
for pkg in pacman:
seen[pkg["name"]] = pkg
# Add AUR results if not already from pacman
for pkg in aur:
if pkg["name"] not in seen:
seen[pkg["name"]] = pkg
# Sort: installed first, then by name
results = list(seen.values())
results.sort(key=lambda p: (not p.get("installed", False), p.get("name", "")))
return results
+246
View File
@@ -0,0 +1,246 @@
"""
Pacman service for ArchStore.
Handles all interactions with the pacman package manager via subprocess.
"""
import asyncio
import re
from typing import Optional
from utils.sanitize import sanitize_package_name
async def _run_command(cmd: list[str], timeout: int = 30) -> tuple[str, str, int]:
"""
Run a shell command safely using argument list (no shell=True).
Returns (stdout, stderr, returncode).
"""
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=timeout
)
return (
stdout.decode("utf-8", errors="replace"),
stderr.decode("utf-8", errors="replace"),
proc.returncode,
)
except asyncio.TimeoutError:
proc.kill()
return "", "Command timed out", -1
except Exception as e:
return "", str(e), -1
def _parse_search_results(output: str) -> list[dict]:
"""Parse pacman -Ss output into structured results."""
packages = []
lines = output.strip().split("\n")
i = 0
while i < len(lines):
line = lines[i]
# Match: repo/name version [installed] or repo/name version
match = re.match(
r'^(\S+)/(\S+)\s+(\S+)(?:\s+\[installed(?::?\s*(\S+))?\])?\s*$',
line
)
if match:
repo = match.group(1)
name = match.group(2)
version = match.group(3)
installed = match.group(4) is not None or "[installed" in line
description = ""
if i + 1 < len(lines) and lines[i + 1].startswith(" "):
description = lines[i + 1].strip()
i += 1
packages.append({
"name": name,
"version": version,
"description": description,
"repository": repo,
"source": "pacman",
"installed": installed,
})
i += 1
return packages
def _parse_package_info(output: str) -> dict:
"""Parse pacman -Si or -Qi output into a dict."""
info = {}
current_key = None
current_value = []
for line in output.split("\n"):
if ":" in line and not line.startswith(" "):
if current_key:
info[current_key] = " ".join(current_value).strip()
parts = line.split(":", 1)
current_key = parts[0].strip().lower().replace(" ", "_")
current_value = [parts[1].strip()] if len(parts) > 1 else []
elif line.startswith(" ") and current_key:
current_value.append(line.strip())
if current_key:
info[current_key] = " ".join(current_value).strip()
return info
async def search_packages(query: str) -> list[dict]:
"""Search pacman repositories."""
stdout, stderr, code = await _run_command(
["pacman", "-Ss", query], timeout=15
)
if code != 0:
return []
return _parse_search_results(stdout)
async def get_package_info(name: str) -> Optional[dict]:
"""Get detailed info about a package from sync db."""
name = sanitize_package_name(name)
# Try sync database first
stdout, stderr, code = await _run_command(
["pacman", "-Si", name], timeout=10
)
if code == 0:
info = _parse_package_info(stdout)
info["source"] = "pacman"
info["installed"] = await is_installed(name)
return info
return None
async def get_installed_info(name: str) -> Optional[dict]:
"""Get info about an installed package."""
name = sanitize_package_name(name)
stdout, stderr, code = await _run_command(
["pacman", "-Qi", name], timeout=10
)
if code == 0:
info = _parse_package_info(stdout)
info["source"] = "pacman"
info["installed"] = True
return info
return None
async def is_installed(name: str) -> bool:
"""Check if a package is installed."""
name = sanitize_package_name(name)
_, _, code = await _run_command(["pacman", "-Q", name], timeout=5)
return code == 0
async def list_installed() -> list[dict]:
"""List all explicitly installed packages."""
stdout, _, code = await _run_command(
["pacman", "-Qe"], timeout=15
)
if code != 0:
return []
packages = []
for line in stdout.strip().split("\n"):
if line.strip():
parts = line.split()
if len(parts) >= 2:
packages.append({
"name": parts[0],
"version": parts[1],
"source": "pacman",
"installed": True,
})
return packages
async def check_updates() -> list[dict]:
"""Check for available updates using checkupdates."""
stdout, _, code = await _run_command(
["checkupdates"], timeout=60
)
# checkupdates returns 2 if no updates, 0 if updates available
if code not in (0,):
return []
updates = []
for line in stdout.strip().split("\n"):
if line.strip():
parts = line.split()
if len(parts) >= 4:
updates.append({
"name": parts[0],
"current_version": parts[1],
"new_version": parts[3],
"source": "pacman",
})
return updates
async def install_package(name: str) -> dict:
"""Install a package using pacman (requires pkexec)."""
name = sanitize_package_name(name)
stdout, stderr, code = await _run_command(
["pkexec", "pacman", "-S", "--noconfirm", name], timeout=300
)
return {
"success": code == 0,
"message": stdout if code == 0 else stderr,
"package": name,
}
async def remove_package(name: str) -> dict:
"""Remove a package using pacman (requires pkexec)."""
name = sanitize_package_name(name)
stdout, stderr, code = await _run_command(
["pkexec", "pacman", "-R", "--noconfirm", name], timeout=120
)
return {
"success": code == 0,
"message": stdout if code == 0 else stderr,
"package": name,
}
async def get_package_groups() -> list[str]:
"""Get list of all package groups."""
stdout, _, code = await _run_command(
["pacman", "-Sg"], timeout=10
)
if code != 0:
return []
groups = set()
for line in stdout.strip().split("\n"):
if line.strip():
groups.add(line.strip().split()[0])
return sorted(groups)
async def get_group_packages(group: str) -> list[dict]:
"""Get packages in a specific group."""
stdout, _, code = await _run_command(
["pacman", "-Sg", group], timeout=10
)
if code != 0:
return []
packages = []
for line in stdout.strip().split("\n"):
parts = line.strip().split()
if len(parts) >= 2:
is_inst = await is_installed(parts[1])
packages.append({
"name": parts[1],
"source": "pacman",
"installed": is_inst,
"group": parts[0],
})
return packages
+1
View File
@@ -0,0 +1 @@
# utils package
+69
View File
@@ -0,0 +1,69 @@
"""
Sanitization utilities for ArchStore.
Prevents command injection and validates all user-supplied input
before it reaches any shell command.
"""
import re
# Strict whitelist: only allow valid package name characters
# Arch package names: lowercase letters, digits, @, ., _, +, -
PACKAGE_NAME_PATTERN = re.compile(r'^[a-zA-Z0-9@._+\-]+$')
# Maximum lengths
MAX_PACKAGE_NAME_LENGTH = 256
MAX_SEARCH_QUERY_LENGTH = 128
def sanitize_package_name(name: str) -> str:
"""
Validate and sanitize a package name.
Raises ValueError if the name contains invalid characters.
"""
if not name or not isinstance(name, str):
raise ValueError("Package name cannot be empty")
name = name.strip()
if len(name) > MAX_PACKAGE_NAME_LENGTH:
raise ValueError(f"Package name too long (max {MAX_PACKAGE_NAME_LENGTH} chars)")
if not PACKAGE_NAME_PATTERN.match(name):
raise ValueError(
f"Invalid package name '{name}'. "
"Only letters, digits, @, ., _, +, - are allowed."
)
return name
def sanitize_search_query(query: str) -> str:
"""
Validate and sanitize a search query.
More permissive than package names but still safe.
"""
if not query or not isinstance(query, str):
raise ValueError("Search query cannot be empty")
query = query.strip()
if len(query) > MAX_SEARCH_QUERY_LENGTH:
raise ValueError(f"Search query too long (max {MAX_SEARCH_QUERY_LENGTH} chars)")
# Remove any shell metacharacters
dangerous_chars = set(';&|`$(){}[]!#~\\<>"\'\n\r\t')
sanitized = ''.join(c for c in query if c not in dangerous_chars)
if not sanitized:
raise ValueError("Search query contains only invalid characters")
return sanitized
def validate_source_filter(source: str) -> str:
"""Validate the source filter parameter."""
allowed = {"all", "pacman", "aur"}
source = source.strip().lower()
if source not in allowed:
raise ValueError(f"Invalid source filter '{source}'. Allowed: {', '.join(allowed)}")
return source
+65
View File
@@ -0,0 +1,65 @@
# ArchStore — Arch Linux Package Store
A modern lightweight package manager client for Arch Linux that combines official `pacman` repositories and the Arch User Repository (AUR) into one clean, elegant Play Store-like interface.
## Main Features
- **Unified Search**: Search packages across pacman repositories and the AUR simultaneously.
- **Detailed Package Sheets**: View descriptions, maintainers, votes, popularity, and installed statuses.
- **PKGBUILD Security Scanner**: Analyzes PKGBUILD script manifests for suspicious scripts, remote code execution (curl/wget to sh), command injection, and other threats.
- **System Updates Check**: Checks for updates from both pacman sync databases and the AUR.
- **Category Browsing**: Explore applications by genre (Development, System, Networks, Multimedia, Games, etc.).
- **Local SQLite Caching**: Fast indexing and pagination for package queries with a 15-minute Time-to-Live (TTL).
---
## Technical Architecture
### Backend (FastAPI + SQLite)
- Safe execution of system tools (`pacman`, `yay`) utilizing `asyncio.subprocess` exec arrays (no `shell=True`) to completely eliminate command injection vectors.
- Whitelist-based package name and search query sanitization.
- Lightweight SQLite storage cache with auto-expiration.
### Frontend (React + Vite + TailwindCSS v4)
- Responsive dark-mode UI inspired by Arch Linux.
- Fixed sidebar layout collapsing on smaller device widths.
- Shimmer skeleton loaders, micro-animations, and staggered grids.
---
## Installation & Setup
### Prerequisites
Make sure you have `python`, `node`, `npm`, and an AUR helper (like `yay`) installed.
### 1. Backend Setup
Create a virtual environment, activate it, and install Python dependencies:
```bash
cd backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```
Start the development API server:
```bash
uvicorn main:app --reload --port 8000
```
The backend API will run on `http://localhost:8000`.
### 2. Frontend Setup
Navigate to the frontend folder, install npm modules, and run the development server:
```bash
cd frontend
npm install
npm run dev
```
The frontend application will start on `http://localhost:5173`. Any calls to `/api` will be proxied to the backend automatically.
---
## Security Policy
1. **Command Sanitization**: Strict whitelist of `^[a-zA-Z0-9@._+-]+$` for all package names passed to shell processes.
2. **Untrusted Scripts Isolation**: Build and PKGBUILD script generation is handled strictly through the pacman package manager database structures and standard AUR helpers (`yay`), bypassing manual root exec calls.
3. **No Sudo Privilege Escalation without Prompt**: Installation requests call `pkexec` (standard Polkit helper) to prompt user dynamically, or run in the user's home space for user-run AUR installs.
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
])
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="ArchStore — A modern lightweight package store for Arch Linux combining pacman and AUR." />
<meta name="theme-color" content="#0d1117" />
<title>ArchStore — Arch Linux Package Store</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2867
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"tailwindcss": "^4.3.0",
"vite": "^8.0.12"
}
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1793d1"/>
<stop offset="100%" stop-color="#0f6ea8"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="14" fill="#0d1117"/>
<path d="M32 8L12 52h10l10-22 10 22h10L32 8z" fill="url(#g)"/>
<circle cx="32" cy="38" r="3" fill="#0d1117"/>
</svg>

After

Width:  |  Height:  |  Size: 450 B

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import MainLayout from './layouts/MainLayout';
import Home from './pages/Home';
import Search from './pages/Search';
import Installed from './pages/Installed';
import Updates from './pages/Updates';
import Categories from './pages/Categories';
import Settings from './pages/Settings';
import PackageView from './pages/PackageView';
export default function App() {
return (
<Router>
<Routes>
<Route path="/" element={<MainLayout />}>
<Route index element={<Home />} />
<Route path="search" element={<Search />} />
<Route path="installed" element={<Installed />} />
<Route path="updates" element={<Updates />} />
<Route path="categories" element={<Categories />} />
<Route path="categories/:categoryName" element={<Categories />} />
<Route path="settings" element={<Settings />} />
<Route path="package/:packageName" element={<PackageView />} />
</Route>
</Routes>
</Router>
);
}
+72
View File
@@ -0,0 +1,72 @@
/**
* ArchStore API Client
* Handles all communication with the FastAPI backend.
*/
const BASE_URL = '/api';
async function request(endpoint, options = {}) {
const url = `${BASE_URL}${endpoint}`;
const config = {
headers: { 'Content-Type': 'application/json' },
...options,
};
try {
const response = await fetch(url, config);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || `Request failed: ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.message === 'Failed to fetch') {
throw new Error('Cannot connect to ArchStore backend. Is the server running?');
}
throw error;
}
}
export const api = {
// Package operations
searchPackages: (query, source = 'all') =>
request(`/packages/search?q=${encodeURIComponent(query)}&source=${source}`),
getPackageInfo: (name) =>
request(`/packages/${encodeURIComponent(name)}`),
scanPackage: (name) =>
request(`/packages/${encodeURIComponent(name)}/scan`),
installPackage: (name) =>
request(`/packages/${encodeURIComponent(name)}/install`, { method: 'POST' }),
removePackage: (name) =>
request(`/packages/${encodeURIComponent(name)}/remove`, { method: 'POST' }),
listInstalled: () =>
request('/packages/installed'),
// Updates
checkUpdates: () =>
request('/updates/check'),
applyUpdates: () =>
request('/updates/apply', { method: 'POST' }),
// Categories
listCategories: () =>
request('/categories'),
getCategoryPackages: (name) =>
request(`/categories/${encodeURIComponent(name)}`),
// System
clearCache: () =>
request('/cache/clear', { method: 'POST' }),
healthCheck: () =>
request('/health'),
};
export default api;
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,9 @@
export default function LoadingSpinner({ size = 'md', text = '' }) {
const px = { sm: 20, md: 28, lg: 40 }[size] || 28;
return (
<div className="flex flex-col items-center justify-center gap-4 py-16">
<div className="spinner" style={{ width: px, height: px }}></div>
{text && <p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{text}</p>}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useNavigate } from 'react-router-dom';
import { CheckCircle, AlertTriangle, Star, ArrowDownToLine } from 'lucide-react';
export default function PackageCard({ pkg }) {
const navigate = useNavigate();
const sourceBadge = pkg.source === 'aur' ? 'badge-aur' : 'badge-pacman';
const sourceLabel = pkg.source === 'aur' ? 'AUR' : (pkg.repository || 'pacman');
return (
<div
className="card card-interactive p-5 flex flex-col gap-3"
onClick={() => navigate(`/package/${pkg.name}`)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && navigate(`/package/${pkg.name}`)}
>
{/* Top row: name + badge */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold truncate" style={{ color: 'var(--text-primary)', fontSize: '0.95rem' }}>
{pkg.name}
</h3>
{pkg.installed && <CheckCircle size={14} style={{ color: 'var(--green)', flexShrink: 0 }} />}
{pkg.out_of_date && <AlertTriangle size={14} style={{ color: 'var(--amber)', flexShrink: 0 }} />}
</div>
<span className="text-xs font-mono" style={{ color: 'var(--text-tertiary)' }}>
{pkg.version || '—'}
</span>
</div>
<span className={`badge ${sourceBadge}`}>{sourceLabel}</span>
</div>
{/* Description */}
<p className="text-sm leading-relaxed flex-1"
style={{ color: 'var(--text-secondary)', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{pkg.description || 'No description available'}
</p>
{/* Footer */}
<div className="flex items-center justify-between pt-1"
style={{ borderTop: '1px solid var(--border-primary)', marginTop: 'auto' }}>
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-tertiary)' }}>
{pkg.votes !== undefined && (
<span className="flex items-center gap-1">
<Star size={12} /> {pkg.votes}
</span>
)}
{pkg.popularity > 0 && (
<span>{pkg.popularity.toFixed(2)}</span>
)}
</div>
{pkg.installed ? (
<span className="badge badge-installed">Installed</span>
) : (
<span className="flex items-center gap-1 text-xs font-semibold" style={{ color: 'var(--accent)' }}>
<ArrowDownToLine size={13} /> Get
</span>
)}
</div>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import PackageCard from './PackageCard';
import { PackageOpen } from 'lucide-react';
export default function PackageGrid({ packages, loading }) {
if (loading) {
return (
<div className="pkg-grid">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="card p-5 flex flex-col gap-3">
<div className="flex justify-between">
<div className="shimmer h-5 w-32"></div>
<div className="shimmer h-5 w-16 rounded-full"></div>
</div>
<div className="shimmer h-3 w-20"></div>
<div className="shimmer h-4 w-full"></div>
<div className="shimmer h-4 w-3/4"></div>
<div className="flex justify-between pt-2" style={{ borderTop: '1px solid var(--border-primary)' }}>
<div className="shimmer h-3 w-12"></div>
<div className="shimmer h-3 w-16"></div>
</div>
</div>
))}
</div>
);
}
if (!packages || packages.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-24" style={{ color: 'var(--text-tertiary)' }}>
<div className="w-16 h-16 rounded-2xl flex items-center justify-center mb-5"
style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
<PackageOpen size={28} />
</div>
<p className="text-base font-semibold mb-1" style={{ color: 'var(--text-secondary)' }}>No packages found</p>
<p className="text-sm">Try adjusting your search or filters</p>
</div>
);
}
return (
<div className="pkg-grid stagger">
{packages.map((pkg) => (
<PackageCard key={`${pkg.source}-${pkg.name}`} pkg={pkg} />
))}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useState, useCallback } from 'react';
import { Search } from 'lucide-react';
export default function SearchBar({ onSearch, initialQuery = '' }) {
const [query, setQuery] = useState(initialQuery);
const handleSubmit = useCallback((e) => {
e.preventDefault();
if (query.trim()) onSearch(query);
}, [query, onSearch]);
return (
<form onSubmit={handleSubmit} className="w-full max-w-xl relative">
<Search
size={17}
className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: 'var(--text-tertiary)' }}
/>
<input
id="search-input"
type="text"
className="input pl-11 pr-5 py-3 rounded-xl"
style={{ background: 'var(--bg-secondary)', fontSize: '0.9rem' }}
placeholder="Search packages..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoComplete="off"
spellCheck="false"
/>
</form>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { NavLink } from 'react-router-dom';
import {
Home, Search, Package, RefreshCw, Grid3X3, Settings, X
} from 'lucide-react';
import { useState, useEffect } from 'react';
import api from '../api/client';
const navItems = [
{ path: '/', icon: Home, label: 'Home' },
{ path: '/search', icon: Search, label: 'Search' },
{ path: '/installed', icon: Package, label: 'Installed' },
{ path: '/updates', icon: RefreshCw, label: 'Updates' },
{ path: '/categories', icon: Grid3X3, label: 'Categories' },
{ path: '/settings', icon: Settings, label: 'Settings' },
];
export default function Sidebar({ isOpen, onClose }) {
const [updateCount, setUpdateCount] = useState(0);
useEffect(() => {
api.checkUpdates()
.then(data => setUpdateCount(data.count || 0))
.catch(() => {});
}, []);
return (
<aside className={`sidebar ${isOpen ? 'open' : ''}`}>
{/* ── Brand ── */}
<div className="flex items-center justify-between mb-10">
<NavLink to="/" className="flex items-center gap-3 no-underline" onClick={onClose}>
<div className="w-10 h-10 rounded-xl flex items-center justify-center"
style={{
background: 'linear-gradient(135deg, var(--accent), var(--violet))',
boxShadow: '0 4px 14px var(--accent-glow)'
}}>
<span className="text-white font-black text-lg">A</span>
</div>
<div>
<h1 className="text-base font-bold" style={{ color: 'var(--text-primary)', letterSpacing: '-0.02em' }}>
ArchStore
</h1>
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Package Manager</p>
</div>
</NavLink>
<button
className="lg:hidden btn-ghost !p-1.5"
onClick={onClose}
aria-label="Close sidebar"
>
<X size={18} />
</button>
</div>
{/* ── Navigation ── */}
<nav className="flex flex-col gap-2 flex-1">
<p className="text-[10px] font-bold uppercase tracking-widest mb-1 px-4"
style={{ color: 'var(--text-tertiary)' }}>
Menu
</p>
{navItems.map(({ path, icon: Icon, label }) => (
<NavLink
key={path}
to={path}
end={path === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
onClick={onClose}
>
<Icon size={18} strokeWidth={isOpen ? 2 : 1.8} />
<span>{label}</span>
{label === 'Updates' && updateCount > 0 && (
<span className="ml-auto text-[11px] font-bold px-2 py-0.5 rounded-full"
style={{ background: 'var(--accent)', color: 'white', minWidth: '22px', textAlign: 'center' }}>
{updateCount}
</span>
)}
</NavLink>
))}
</nav>
{/* ── Footer ── */}
<div className="pt-5 mt-4" style={{ borderTop: '1px solid var(--border-primary)' }}>
<div className="flex items-center gap-2 px-4 mb-2">
<span className="w-2 h-2 rounded-full" style={{ background: 'var(--green)' }}></span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>System Ready</span>
</div>
<p className="text-[11px] px-4" style={{ color: 'var(--text-tertiary)' }}>
v1.0.0 · Arch Linux
</p>
</div>
</aside>
);
}
+543
View File
@@ -0,0 +1,543 @@
@import "tailwindcss";
/*
ArchStore Premium Design System
Dark & Light theme with CSS custom properties
*/
/* ── Dark Mode (Default) ─────────────────────── */
:root {
--bg-base: #07090f;
--bg-primary: #0c1018;
--bg-secondary: #111827;
--bg-tertiary: #1a2235;
--bg-card: #111827;
--bg-card-hover: #162036;
--bg-elevated: #1e293b;
--bg-input: #0f172a;
--bg-sidebar: rgba(11, 15, 25, 0.92);
--bg-overlay: rgba(0, 0, 0, 0.6);
--border-primary: #1e293b;
--border-secondary: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-tertiary: #64748b;
--text-inverse: #0f172a;
--accent-h: 199;
--accent-s: 89%;
--accent-l: 48%;
--accent: hsl(var(--accent-h), var(--accent-s), var(--accent-l));
--accent-hover: hsl(var(--accent-h), var(--accent-s), 56%);
--accent-muted: hsla(var(--accent-h), var(--accent-s), var(--accent-l), 0.12);
--accent-glow: hsla(var(--accent-h), var(--accent-s), var(--accent-l), 0.2);
--green: #22c55e;
--green-muted: rgba(34, 197, 94, 0.12);
--amber: #f59e0b;
--amber-muted: rgba(245, 158, 11, 0.12);
--red: #ef4444;
--red-muted: rgba(239, 68, 68, 0.1);
--blue: #3b82f6;
--blue-muted: rgba(59, 130, 246, 0.12);
--violet: #8b5cf6;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 20px -4px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 12px 40px -8px rgba(0, 0, 0, 0.5);
--shadow-glow: 0 0 30px -5px var(--accent-glow);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-full: 9999px;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-spring: 350ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* ── Light Mode ──────────────────────────────── */
.light {
--bg-base: #f8fafc;
--bg-primary: #f1f5f9;
--bg-secondary: #ffffff;
--bg-tertiary: #f8fafc;
--bg-card: #ffffff;
--bg-card-hover: #f8fafc;
--bg-elevated: #ffffff;
--bg-input: #f1f5f9;
--bg-sidebar: rgba(255, 255, 255, 0.92);
--bg-overlay: rgba(15, 23, 42, 0.3);
--border-primary: #e2e8f0;
--border-secondary: #cbd5e1;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-tertiary: #94a3b8;
--text-inverse: #f1f5f9;
--accent: hsl(var(--accent-h), var(--accent-s), 42%);
--accent-hover: hsl(var(--accent-h), var(--accent-s), 35%);
--accent-muted: hsla(var(--accent-h), var(--accent-s), var(--accent-l), 0.08);
--accent-glow: hsla(var(--accent-h), var(--accent-s), var(--accent-l), 0.1);
--green-muted: rgba(34, 197, 94, 0.08);
--amber-muted: rgba(245, 158, 11, 0.08);
--red-muted: rgba(239, 68, 68, 0.06);
--blue-muted: rgba(59, 130, 246, 0.08);
--shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.04);
--shadow-md: 0 4px 20px -4px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 12px 40px -8px rgba(15, 23, 42, 0.08);
--shadow-glow: 0 0 30px -5px var(--accent-glow);
}
/* ── Tailwind v4 Theme Tokens ────────────────── */
@theme {
--color-bg-base: var(--bg-base);
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
--color-bg-tertiary: var(--bg-tertiary);
--color-bg-card: var(--bg-card);
--color-bg-card-hover: var(--bg-card-hover);
--color-bg-elevated: var(--bg-elevated);
--color-bg-input: var(--bg-input);
--color-bg-sidebar: var(--bg-sidebar);
--color-bg-overlay: var(--bg-overlay);
--color-border-primary: var(--border-primary);
--color-border-secondary: var(--border-secondary);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-tertiary: var(--text-tertiary);
--color-text-inverse: var(--text-inverse);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-muted: var(--accent-muted);
--color-accent-glow: var(--accent-glow);
--color-green: var(--green);
--color-green-muted: var(--green-muted);
--color-amber: var(--amber);
--color-amber-muted: var(--amber-muted);
--color-red: var(--red);
--color-red-muted: var(--red-muted);
--color-blue: var(--blue);
--color-blue-muted: var(--blue-muted);
--color-violet: var(--violet);
--font-sans: var(--font-sans);
}
/*
Global Resets
*/
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-base);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: background-color var(--transition-normal), color var(--transition-normal);
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-primary); border-radius: var(--radius-full); }
::-webkit-scrollbar-thumb:hover { background: var(--border-secondary); }
/*
Animations
*/
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse-ring {
0% { transform: scale(0.9); opacity: 0.5; }
100% { transform: scale(1.3); opacity: 0; }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.animate-fade-in { animation: fadeIn 0.3s ease-out both; }
.animate-slide-up { animation: slideUp 0.4s ease-out both; }
.stagger > * { animation: fadeIn 0.35s ease-out both; }
.stagger > *:nth-child(1) { animation-delay: 30ms; }
.stagger > *:nth-child(2) { animation-delay: 60ms; }
.stagger > *:nth-child(3) { animation-delay: 90ms; }
.stagger > *:nth-child(4) { animation-delay: 120ms; }
.stagger > *:nth-child(5) { animation-delay: 150ms; }
.stagger > *:nth-child(6) { animation-delay: 180ms; }
.stagger > *:nth-child(7) { animation-delay: 210ms; }
.stagger > *:nth-child(8) { animation-delay: 240ms; }
.stagger > *:nth-child(n+9) { animation-delay: 270ms; }
/*
Layout Structure
*/
.app-layout {
display: flex;
min-height: 100vh;
background: var(--bg-base);
}
.sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 272px;
z-index: 50;
display: flex;
flex-direction: column;
padding: 28px 20px;
overflow-y: auto;
background: var(--bg-sidebar);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border-right: 1px solid var(--border-primary);
transition: transform var(--transition-spring), background var(--transition-normal);
}
.main-content {
margin-left: 272px;
flex: 1;
min-height: 100vh;
padding: 36px 48px;
transition: margin-left var(--transition-spring);
}
@media (max-width: 1024px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}
.main-content {
margin-left: 0;
padding: 24px 20px;
}
}
/*
Nav Items
*/
.nav-link {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 16px;
border-radius: var(--radius-md);
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: all var(--transition-fast);
position: relative;
}
.nav-link:hover {
background: var(--accent-muted);
color: var(--text-primary);
}
.nav-link.active {
background: var(--accent-muted);
color: var(--accent);
font-weight: 600;
}
.nav-link.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background: var(--accent);
border-radius: 0 var(--radius-full) var(--radius-full) 0;
}
/*
Card System
*/
.card {
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
transition: transform var(--transition-spring),
box-shadow var(--transition-spring),
background var(--transition-normal),
border-color var(--transition-fast);
}
.card-interactive {
cursor: pointer;
}
.card-interactive:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--border-secondary);
}
/*
Badges
*/
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: var(--radius-full);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.badge-pacman {
background: var(--accent-muted);
color: var(--accent);
}
.badge-aur {
background: var(--amber-muted);
color: var(--amber);
}
.badge-installed {
background: var(--green-muted);
color: var(--green);
}
/*
Buttons
*/
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 22px;
border-radius: var(--radius-md);
font-size: 0.85rem;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
border: none;
transition: all var(--transition-fast);
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
.btn-primary {
background: var(--accent);
color: white;
box-shadow: 0 2px 12px var(--accent-glow);
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 20px var(--accent-glow);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-primary);
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--border-secondary);
background: var(--bg-tertiary);
}
.btn-danger {
background: var(--red-muted);
color: var(--red);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.btn-danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.4);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 8px 14px;
}
.btn-ghost:hover:not(:disabled) {
background: var(--bg-tertiary);
color: var(--text-primary);
}
/*
Inputs
*/
.input {
width: 100%;
padding: 11px 16px;
background: var(--bg-input);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 0.9rem;
outline: none;
transition: all var(--transition-fast);
}
.input::placeholder { color: var(--text-tertiary); }
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
/*
Typography
*/
.page-title {
font-size: 1.75rem;
font-weight: 800;
letter-spacing: -0.03em;
color: var(--text-primary);
line-height: 1.2;
}
.page-subtitle {
font-size: 0.95rem;
color: var(--text-secondary);
margin-top: 4px;
}
/*
Grids
*/
.pkg-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 20px;
}
@media (max-width: 720px) {
.pkg-grid { grid-template-columns: 1fr; }
}
.cat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
/*
Utility Components
*/
.shimmer {
background: linear-gradient(90deg,
var(--bg-secondary) 25%, var(--bg-tertiary) 50%, var(--bg-secondary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-md);
}
.spinner {
width: 28px; height: 28px;
border: 3px solid var(--border-primary);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.divider {
height: 1px;
background: var(--border-primary);
border: none;
}
/*
Theme Toggle
*/
.theme-toggle {
position: relative;
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: all var(--transition-fast);
}
.theme-toggle:hover {
border-color: var(--border-secondary);
color: var(--text-primary);
background: var(--bg-tertiary);
}
/*
Decorative
*/
.gradient-text {
background: linear-gradient(135deg, var(--accent), var(--violet));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.glow-ring {
position: absolute;
border-radius: 50%;
background: var(--accent);
opacity: 0.06;
filter: blur(60px);
pointer-events: none;
}
+80
View File
@@ -0,0 +1,80 @@
import { useState, useEffect } from 'react';
import { Outlet, useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import SearchBar from '../components/SearchBar';
import { Menu, X, Sun, Moon } from 'lucide-react';
export default function MainLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [theme, setTheme] = useState(() => localStorage.getItem('archstore-theme') || 'dark');
const navigate = useNavigate();
useEffect(() => {
const root = document.documentElement;
if (theme === 'light') {
root.classList.add('light');
} else {
root.classList.remove('light');
}
localStorage.setItem('archstore-theme', theme);
}, [theme]);
const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark');
const handleSearch = (query) => {
if (query.trim()) {
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
setSidebarOpen(false);
}
};
return (
<div className="app-layout">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 lg:hidden"
style={{ background: 'var(--bg-overlay)' }}
onClick={() => setSidebarOpen(false)}
/>
)}
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<main className="main-content">
{/* ── Top Bar ── */}
<header className="flex items-center gap-3 mb-10">
{/* Mobile menu */}
<button
id="menu-toggle"
className="btn-ghost lg:hidden !p-2.5 rounded-xl"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label="Toggle menu"
>
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button>
{/* Search */}
<div className="flex-1">
<SearchBar onSearch={handleSearch} />
</div>
{/* Theme toggle */}
<button
onClick={toggleTheme}
className="theme-toggle"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
</header>
{/* ── Page ── */}
<div className="animate-fade-in">
<Outlet />
</div>
</main>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
+117
View File
@@ -0,0 +1,117 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../api/client';
import PackageGrid from '../components/PackageGrid';
import {
Code, Monitor, Wifi, Music, Gamepad2, LayoutDashboard, Type, ShieldCheck,
ArrowLeft, Package
} from 'lucide-react';
const catMeta = {
Development: { icon: Code, color: '#3b82f6', bg: 'rgba(59,130,246,0.1)' },
System: { icon: Monitor, color: '#64748b', bg: 'rgba(100,116,139,0.1)' },
Network: { icon: Wifi, color: '#10b981', bg: 'rgba(16,185,129,0.1)' },
Multimedia: { icon: Music, color: '#a855f7', bg: 'rgba(168,85,247,0.1)' },
Games: { icon: Gamepad2, color: '#ef4444', bg: 'rgba(239,68,68,0.1)' },
Desktop: { icon: LayoutDashboard, color: '#6366f1', bg: 'rgba(99,102,241,0.1)' },
Fonts: { icon: Type, color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
Security: { icon: ShieldCheck, color: '#06b6d4', bg: 'rgba(6,182,212,0.1)' },
};
export default function Categories() {
const { categoryName } = useParams();
const navigate = useNavigate();
const [categories, setCategories] = useState([]);
const [packages, setPackages] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (categoryName) loadPkgs(categoryName);
else loadCats();
}, [categoryName]);
async function loadCats() {
setLoading(true); setError(null);
try { const d = await api.listCategories(); setCategories(d.results || []); }
catch (e) { setError(e.message); }
finally { setLoading(false); }
}
async function loadPkgs(name) {
setLoading(true); setError(null);
try { const d = await api.getCategoryPackages(name); setPackages(d.results || []); }
catch (e) { setError(e.message); }
finally { setLoading(false); }
}
if (categoryName) {
const meta = catMeta[categoryName] || { icon: Package, color: 'var(--accent)', bg: 'var(--accent-muted)' };
const Icon = meta.icon;
return (
<div className="animate-slide-up">
<button onClick={() => navigate('/categories')} className="btn btn-secondary mb-6">
<ArrowLeft size={15} /> Back
</button>
<div className="flex items-center gap-4 mb-8">
<div className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{ background: meta.bg, border: '1px solid var(--border-primary)' }}>
<Icon size={26} style={{ color: meta.color }} />
</div>
<div>
<h1 className="page-title">{categoryName}</h1>
<p className="page-subtitle">Browse popular {categoryName.toLowerCase()} packages</p>
</div>
</div>
{error && <div className="rounded-xl p-4 mb-6 text-sm" style={{ background: 'var(--red-muted)', color: 'var(--red)' }}>{error}</div>}
<PackageGrid packages={packages} loading={loading} />
</div>
);
}
return (
<div className="animate-slide-up">
<div className="mb-8">
<h1 className="page-title">Categories</h1>
<p className="page-subtitle">Explore software by type</p>
</div>
{error && <div className="rounded-xl p-4 mb-6 text-sm" style={{ background: 'var(--red-muted)', color: 'var(--red)' }}>{error}</div>}
{loading ? (
<div className="cat-grid">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="card p-5">
<div className="shimmer h-11 w-11 rounded-xl mb-4"></div>
<div className="shimmer h-4 w-24 mb-2"></div>
<div className="shimmer h-3 w-full"></div>
</div>
))}
</div>
) : (
<div className="cat-grid stagger">
{categories.map((cat) => {
const meta = catMeta[cat.name] || { icon: Package, color: 'var(--accent)', bg: 'var(--accent-muted)' };
const Icon = meta.icon;
return (
<div key={cat.name}
className="card card-interactive p-6 group"
onClick={() => navigate(`/categories/${cat.name}`)}>
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-4 transition-transform group-hover:scale-110"
style={{ background: meta.bg }}>
<Icon size={22} style={{ color: meta.color }} />
</div>
<h3 className="font-semibold text-sm mb-1 transition-colors" style={{ color: 'var(--text-primary)' }}>
{cat.name}
</h3>
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-tertiary)' }}>
{cat.description || 'Explore packages'}
</p>
</div>
);
})}
</div>
)}
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Search, TrendingUp, Package, ArrowRight, Sparkles, Shield,
Code, Monitor, Wifi, Music, Gamepad2, LayoutDashboard, Type, ShieldCheck
} from 'lucide-react';
import api from '../api/client';
import PackageGrid from '../components/PackageGrid';
const catMeta = {
Development: { icon: Code, color: '#3b82f6', bg: 'rgba(59,130,246,0.1)' },
System: { icon: Monitor, color: '#64748b', bg: 'rgba(100,116,139,0.1)' },
Network: { icon: Wifi, color: '#10b981', bg: 'rgba(16,185,129,0.1)' },
Multimedia: { icon: Music, color: '#a855f7', bg: 'rgba(168,85,247,0.1)' },
Games: { icon: Gamepad2, color: '#ef4444', bg: 'rgba(239,68,68,0.1)' },
Desktop: { icon: LayoutDashboard, color: '#6366f1', bg: 'rgba(99,102,241,0.1)' },
Fonts: { icon: Type, color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' },
Security: { icon: ShieldCheck, color: '#06b6d4', bg: 'rgba(6,182,212,0.1)' },
};
export default function Home() {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [featured, setFeatured] = useState([]);
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => { loadData(); }, []);
async function loadData() {
setLoading(true);
try {
const [catRes, featRes] = await Promise.allSettled([
api.listCategories(),
api.searchPackages('firefox chromium vlc', 'all'),
]);
if (catRes.status === 'fulfilled') setCategories(catRes.value.results || []);
if (featRes.status === 'fulfilled') setFeatured((featRes.value.results || []).slice(0, 6));
} catch { /* silent */ }
finally { setLoading(false); }
}
const handleSearch = (e) => {
e.preventDefault();
if (query.trim()) navigate(`/search?q=${encodeURIComponent(query.trim())}`);
};
return (
<div className="animate-slide-up">
{/* ════════════════ Hero ════════════════ */}
<section className="relative rounded-2xl overflow-hidden mb-12 p-10 md:p-14"
style={{
background: 'linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%)',
border: '1px solid var(--border-primary)'
}}>
{/* Decorative blobs */}
<div className="glow-ring" style={{ width: 300, height: 300, top: -100, right: -60 }}></div>
<div className="glow-ring" style={{ width: 200, height: 200, bottom: -80, left: -40, background: 'var(--violet)' }}></div>
<div className="relative z-10 max-w-xl">
<div className="flex items-center gap-2 mb-5">
<Sparkles size={16} style={{ color: 'var(--accent)' }} />
<span className="text-xs font-bold uppercase tracking-widest" style={{ color: 'var(--accent)' }}>
Welcome to ArchStore
</span>
</div>
<h1 className="text-3xl md:text-[2.5rem] font-extrabold leading-tight mb-4"
style={{ letterSpacing: '-0.03em' }}>
Discover packages for{' '}
<span className="gradient-text">Arch Linux</span>
</h1>
<p className="text-base leading-relaxed mb-8" style={{ color: 'var(--text-secondary)' }}>
Browse, install, and manage software from official pacman repositories
and the AUR all in one beautiful interface.
</p>
<form onSubmit={handleSearch} className="flex gap-3 max-w-md">
<div className="relative flex-1">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2" style={{ color: 'var(--text-tertiary)' }} />
<input
id="hero-search"
type="text"
className="input pl-11 py-3 rounded-xl"
placeholder="Search packages..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<button type="submit" className="btn btn-primary px-6 rounded-xl">
<Search size={16} />
<span className="hidden sm:inline">Search</span>
</button>
</form>
</div>
</section>
{/* ════════════════ Stats ════════════════ */}
<section className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-12">
{[
{ icon: Package, label: 'Pacman Repos', value: 'Official', color: 'var(--accent)' },
{ icon: TrendingUp, label: 'AUR Packages', value: '80,000+', color: 'var(--amber)' },
{ icon: Shield, label: 'Security Scan', value: 'Built-in', color: 'var(--green)' },
{ icon: Sparkles, label: 'Updates', value: 'Real-time', color: 'var(--violet)' },
].map(({ icon: Icon, label, value, color }) => (
<div key={label} className="card p-5 text-center">
<Icon size={20} style={{ color, margin: '0 auto 8px' }} />
<p className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>{value}</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{label}</p>
</div>
))}
</section>
{/* ════════════════ Categories ════════════════ */}
<section className="mb-12">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>Browse Categories</h2>
<button className="btn-ghost text-xs font-semibold flex items-center gap-1"
style={{ color: 'var(--accent)' }}
onClick={() => navigate('/categories')}>
View all <ArrowRight size={14} />
</button>
</div>
<div className="cat-grid stagger">
{(categories.length > 0 ? categories : Object.keys(catMeta).map(n => ({ name: n }))).map((cat) => {
const meta = catMeta[cat.name] || { icon: Package, color: 'var(--accent)', bg: 'var(--accent-muted)' };
const Icon = meta.icon;
return (
<div key={cat.name}
className="card card-interactive p-5 group"
onClick={() => navigate(`/categories/${cat.name}`)}>
<div className="w-11 h-11 rounded-xl flex items-center justify-center mb-3 transition-transform group-hover:scale-110"
style={{ background: meta.bg }}>
<Icon size={20} style={{ color: meta.color }} />
</div>
<h3 className="font-semibold text-sm mb-0.5" style={{ color: 'var(--text-primary)' }}>{cat.name}</h3>
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-tertiary)' }}>
{cat.description || 'Explore packages'}
</p>
</div>
);
})}
</div>
</section>
{/* ════════════════ Featured ════════════════ */}
{featured.length > 0 && (
<section>
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-bold" style={{ color: 'var(--text-primary)' }}>Popular Packages</h2>
<button className="btn-ghost text-xs font-semibold flex items-center gap-1"
style={{ color: 'var(--accent)' }}
onClick={() => navigate('/search?q=popular')}>
See more <ArrowRight size={14} />
</button>
</div>
<PackageGrid packages={featured} loading={loading} />
</section>
)}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { useState, useEffect } from 'react';
import api from '../api/client';
import PackageGrid from '../components/PackageGrid';
import { RefreshCw, Search } from 'lucide-react';
export default function Installed() {
const [packages, setPackages] = useState([]);
const [filtered, setFiltered] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [error, setError] = useState(null);
useEffect(() => { load(); }, []);
useEffect(() => {
if (!filter.trim()) { setFiltered(packages); return; }
const q = filter.toLowerCase();
setFiltered(packages.filter(p =>
p.name.toLowerCase().includes(q) || (p.description && p.description.toLowerCase().includes(q))
));
}, [filter, packages]);
async function load() {
setLoading(true); setError(null);
try {
const data = await api.listInstalled();
setPackages(data.results || []);
} catch (err) { setError(err.message); }
finally { setLoading(false); }
}
return (
<div className="animate-slide-up">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
<h1 className="page-title">Installed Packages</h1>
<p className="page-subtitle">
{packages.length > 0 ? `${packages.length} packages installed on your system` : 'Loading installed packages...'}
</p>
</div>
<button onClick={load} disabled={loading} className="btn btn-secondary">
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{error && (
<div className="rounded-xl p-4 mb-6 text-sm font-medium"
style={{ background: 'var(--red-muted)', color: 'var(--red)', border: '1px solid rgba(239,68,68,0.2)' }}>
{error}
</div>
)}
{!loading && packages.length > 0 && (
<div className="relative max-w-sm mb-8">
<Search size={15} className="absolute left-3.5 top-1/2 -translate-y-1/2" style={{ color: 'var(--text-tertiary)' }} />
<input
id="installed-filter"
type="text"
className="input pl-10 py-2.5 text-sm rounded-xl"
placeholder="Filter installed packages..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
)}
<PackageGrid packages={filtered} loading={loading} />
</div>
);
}
+291
View File
@@ -0,0 +1,291 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../api/client';
import LoadingSpinner from '../components/LoadingSpinner';
import {
ArrowLeft, Globe, ExternalLink, Download, Trash2,
ShieldCheck, ShieldAlert, Shield, User, Clock, Package, AlertTriangle
} from 'lucide-react';
export default function PackageView() {
const { packageName } = useParams();
const navigate = useNavigate();
const [pkg, setPkg] = useState(null);
const [scan, setScan] = useState(null);
const [loading, setLoading] = useState(true);
const [scanning, setScanning] = useState(false);
const [acting, setActing] = useState(false);
const [log, setLog] = useState('');
const [error, setError] = useState(null);
useEffect(() => { load(); }, [packageName]);
async function load() {
setLoading(true); setError(null);
try {
const d = await api.getPackageInfo(packageName);
setPkg(d);
if (d.source === 'aur') doScan(d.name);
} catch (e) { setError(e.message); }
finally { setLoading(false); }
}
async function doScan(name) {
setScanning(true);
try { setScan(await api.scanPackage(name)); }
catch { /* silent */ }
finally { setScanning(false); }
}
async function install() {
setActing(true); setLog('Installing...\n'); setError(null);
try {
const r = await api.installPackage(pkg.name);
if (r.success) { setLog(p => p + '\n✓ Installed!\n' + r.message); setPkg(await api.getPackageInfo(pkg.name)); }
else setError('Installation failed: ' + r.message);
} catch (e) { setError(e.message); }
finally { setActing(false); }
}
async function remove() {
if (!confirm(`Remove ${pkg.name}?`)) return;
setActing(true); setLog('Removing...\n'); setError(null);
try {
const r = await api.removePackage(pkg.name);
if (r.success) { setLog(p => p + '\n✓ Removed!\n' + r.message); setPkg(await api.getPackageInfo(pkg.name)); }
else setError('Removal failed: ' + r.message);
} catch (e) { setError(e.message); }
finally { setActing(false); }
}
if (loading) return <LoadingSpinner text="Loading package details..." />;
if (error && !pkg) {
return (
<div className="animate-slide-up">
<button onClick={() => navigate(-1)} className="btn btn-secondary mb-6"><ArrowLeft size={15} /> Back</button>
<div className="rounded-xl p-6 text-sm" style={{ background: 'var(--red-muted)', color: 'var(--red)', border: '1px solid rgba(239,68,68,0.2)' }}>
<p className="font-semibold mb-1">Package not found</p>
<p>{error}</p>
</div>
</div>
);
}
const riskColor = !scan?.scanned ? 'var(--text-tertiary)'
: scan.risk_score >= 70 ? 'var(--red)'
: scan.risk_score >= 40 ? 'var(--amber)'
: 'var(--green)';
return (
<div className="animate-slide-up max-w-4xl">
<button onClick={() => navigate(-1)} className="btn btn-secondary mb-6"><ArrowLeft size={15} /> Back</button>
{error && (
<div className="rounded-xl p-4 mb-6 text-sm" style={{ background: 'var(--red-muted)', color: 'var(--red)', border: '1px solid rgba(239,68,68,0.2)' }}>
{error}
</div>
)}
{acting && (
<div className="card p-5 mb-6" style={{ borderColor: 'rgba(2,132,199,0.3)' }}>
<div className="flex items-center gap-2 mb-3 text-sm font-semibold" style={{ color: 'var(--accent)' }}>
<div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }}></div>
Working...
</div>
<pre className="p-3 rounded-lg font-mono text-xs max-h-48 overflow-auto whitespace-pre-wrap"
style={{ background: 'var(--bg-primary)', border: '1px solid var(--border-primary)', color: 'var(--text-secondary)' }}>
{log}
</pre>
</div>
)}
{/* ═══ Main Info Card ═══ */}
<div className="card p-6 md:p-8 mb-6">
<div className="flex flex-col md:flex-row gap-6">
{/* Left */}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-3 mb-3">
<h1 className="text-2xl md:text-3xl font-extrabold" style={{ letterSpacing: '-0.03em', color: 'var(--text-primary)' }}>
{pkg.name}
</h1>
<span className={`badge ${pkg.source === 'aur' ? 'badge-aur' : 'badge-pacman'}`}>
{pkg.source === 'aur' ? 'AUR' : (pkg.repository || 'pacman')}
</span>
{pkg.installed && <span className="badge badge-installed">Installed</span>}
{pkg.out_of_date && (
<span className="badge" style={{ background: 'var(--amber-muted)', color: 'var(--amber)' }}>
<AlertTriangle size={11} className="mr-1" /> Out of Date
</span>
)}
</div>
<p className="text-sm leading-relaxed mb-6" style={{ color: 'var(--text-secondary)' }}>
{pkg.description || 'No description available.'}
</p>
{/* Meta grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<MetaBox label="Version" value={pkg.version} mono />
{pkg.source === 'aur' ? (
<>
<MetaBox label="Votes" value={pkg.votes} />
<MetaBox label="Popularity" value={pkg.popularity?.toFixed(2) || '0.00'} />
<MetaBox label="Status" value={pkg.out_of_date ? 'Out of Date' : 'Current'}
color={pkg.out_of_date ? 'var(--amber)' : 'var(--green)'} />
</>
) : (
<>
<MetaBox label="Repository" value={pkg.repository || 'pacman'} />
<MetaBox label="State" value={pkg.installed ? 'Installed' : 'Available'} span={2} />
</>
)}
</div>
</div>
{/* Right — Actions */}
<div className="flex flex-col gap-3 md:w-52 shrink-0">
{pkg.installed ? (
<button onClick={remove} disabled={acting} className="btn btn-danger w-full py-3">
<Trash2 size={15} /> Uninstall
</button>
) : (
<button onClick={install} disabled={acting} className="btn btn-primary w-full py-3">
<Download size={15} /> Install
</button>
)}
{pkg.url && (
<a href={pkg.url} target="_blank" rel="noopener noreferrer"
className="btn btn-secondary w-full py-3 no-underline text-center">
<Globe size={15} /> Website <ExternalLink size={11} />
</a>
)}
</div>
</div>
</div>
{/* ═══ Security Scan (AUR only) ═══ */}
{pkg.source === 'aur' && (
<div className="card p-6 mb-6">
<h2 className="font-bold text-sm flex items-center gap-2 mb-5 pb-3"
style={{ borderBottom: '1px solid var(--border-primary)', color: 'var(--text-primary)' }}>
<Shield size={17} style={{ color: 'var(--accent)' }} />
Security Scan
</h2>
{scanning ? (
<LoadingSpinner size="sm" text="Scanning PKGBUILD..." />
) : scan?.scanned ? (
<div className="flex flex-col gap-4">
{/* Score bar */}
<div className="flex items-center justify-between p-4 rounded-xl"
style={{ background: 'var(--bg-primary)', border: '1px solid var(--border-primary)' }}>
<div className="flex items-center gap-3">
{scan.risk_score >= 70 ? <ShieldAlert size={22} style={{ color: riskColor }} />
: scan.risk_score >= 40 ? <ShieldAlert size={22} style={{ color: riskColor }} />
: <ShieldCheck size={22} style={{ color: riskColor }} />}
<div>
<p className="font-semibold text-sm capitalize" style={{ color: 'var(--text-primary)' }}>
{scan.risk_level}
</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>PKGBUILD analysis</p>
</div>
</div>
<div className="text-right">
<span className="text-2xl font-extrabold" style={{ color: riskColor }}>{scan.risk_score}</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>/100</span>
</div>
</div>
{/* Findings */}
{scan.findings.filter(f => f.severity !== 'info').length > 0 ? (
<div className="flex flex-col gap-2">
{scan.findings.filter(f => f.severity !== 'info').map((f, i) => (
<div key={i} className="p-3 rounded-xl text-xs flex flex-col gap-1"
style={{
background: f.severity === 'critical' ? 'var(--red-muted)' : 'var(--amber-muted)',
border: `1px solid ${f.severity === 'critical' ? 'rgba(239,68,68,0.15)' : 'rgba(245,158,11,0.15)'}`,
color: f.severity === 'critical' ? 'var(--red)' : 'var(--amber)',
}}>
<div className="flex justify-between">
<span className="font-bold uppercase">{f.severity}</span>
{f.line_number > 0 && <span className="font-mono opacity-70">L{f.line_number}</span>}
</div>
<p style={{ color: 'var(--text-primary)' }}>{f.description}</p>
{f.line_content && (
<pre className="p-2 rounded-lg font-mono mt-1 overflow-x-auto"
style={{ background: 'var(--bg-primary)', color: 'var(--text-tertiary)', fontSize: '0.65rem' }}>
{f.line_content}
</pre>
)}
</div>
))}
</div>
) : (
<div className="p-4 rounded-xl text-sm text-center"
style={{ background: 'var(--green-muted)', color: 'var(--green)', border: '1px solid rgba(34,197,94,0.15)' }}>
No security issues detected. Safe to install.
</div>
)}
</div>
) : (
<p className="text-xs text-center py-4" style={{ color: 'var(--text-tertiary)' }}>
Scan not available for this package.
</p>
)}
</div>
)}
{/* ═══ Metadata ═══ */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="card p-6">
<h3 className="font-bold text-sm mb-4 flex items-center gap-2 pb-3"
style={{ borderBottom: '1px solid var(--border-primary)', color: 'var(--text-primary)' }}>
<User size={15} /> Metadata
</h3>
<div className="flex flex-col gap-3 text-xs">
{pkg.maintainer && <MetaRow label="Maintainer" value={pkg.maintainer} />}
{pkg.last_modified > 0 && <MetaRow label="Modified" value={new Date(pkg.last_modified * 1000).toLocaleDateString()} />}
{pkg.first_submitted > 0 && <MetaRow label="Submitted" value={new Date(pkg.first_submitted * 1000).toLocaleDateString()} />}
{pkg.package_base && <MetaRow label="Base" value={pkg.package_base} />}
</div>
</div>
<div className="card p-6">
<h3 className="font-bold text-sm mb-4 flex items-center gap-2 pb-3"
style={{ borderBottom: '1px solid var(--border-primary)', color: 'var(--text-primary)' }}>
<Package size={15} /> Dependencies
</h3>
<div className="p-4 rounded-xl text-xs flex items-start gap-3"
style={{ background: 'var(--bg-primary)', border: '1px solid var(--border-primary)' }}>
<Clock size={14} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 1 }} />
<span style={{ color: 'var(--text-secondary)' }}>
Dependencies are automatically resolved by pacman/yay during installation.
</span>
</div>
</div>
</div>
</div>
);
}
/* ── Helper components ── */
function MetaBox({ label, value, mono, color, span }) {
return (
<div className={`p-3 rounded-xl ${span === 2 ? 'col-span-2' : ''}`}
style={{ background: 'var(--bg-primary)', border: '1px solid var(--border-primary)' }}>
<p className="text-[10px] uppercase font-bold tracking-wide mb-0.5" style={{ color: 'var(--text-tertiary)' }}>{label}</p>
<p className={`text-sm font-semibold truncate ${mono ? 'font-mono' : ''}`}
style={{ color: color || 'var(--text-primary)' }}>{value}</p>
</div>
);
}
function MetaRow({ label, value }) {
return (
<div className="flex justify-between items-center">
<span style={{ color: 'var(--text-tertiary)' }}>{label}</span>
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>{value}</span>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import api from '../api/client';
import PackageGrid from '../components/PackageGrid';
import { Filter, Search } from 'lucide-react';
export default function SearchPage() {
const [searchParams] = useSearchParams();
const query = searchParams.get('q') || '';
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [source, setSource] = useState('all');
const [error, setError] = useState(null);
useEffect(() => {
if (query.trim()) performSearch(query, source);
else setResults([]);
}, [query, source]);
async function performSearch(q, s) {
setLoading(true);
setError(null);
try {
const data = await api.searchPackages(q, s);
setResults(data.results || []);
} catch (err) {
setError(err.message);
setResults([]);
} finally {
setLoading(false);
}
}
const tabs = [
{ id: 'all', label: 'All' },
{ id: 'pacman', label: 'Official' },
{ id: 'aur', label: 'AUR' },
];
return (
<div className="animate-slide-up">
{/* Header */}
<div className="mb-8">
<h1 className="page-title">Search Results</h1>
<p className="page-subtitle">
{query ? <>Showing results for <strong style={{ color: 'var(--text-primary)' }}>"{query}"</strong></> : 'Enter a query to search packages'}
</p>
</div>
{query && (
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
{/* Source tabs */}
<div className="flex p-1 rounded-xl" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setSource(tab.id)}
className="px-5 py-2 text-xs font-semibold rounded-lg transition-all"
style={{
background: source === tab.id ? 'var(--accent)' : 'transparent',
color: source === tab.id ? 'white' : 'var(--text-secondary)',
}}
>
{tab.label}
</button>
))}
</div>
<div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-tertiary)' }}>
<Filter size={13} />
{results.length} package{results.length !== 1 ? 's' : ''}
</div>
</div>
)}
{error && (
<div className="rounded-xl p-4 mb-6 text-sm font-medium"
style={{ background: 'var(--red-muted)', color: 'var(--red)', border: '1px solid rgba(239,68,68,0.2)' }}>
{error}
</div>
)}
{query ? (
<PackageGrid packages={results} loading={loading} />
) : (
<div className="flex flex-col items-center justify-center py-24" style={{ color: 'var(--text-tertiary)' }}>
<div className="w-16 h-16 rounded-2xl flex items-center justify-center mb-5"
style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
<Search size={28} />
</div>
<p className="text-base font-semibold mb-1" style={{ color: 'var(--text-secondary)' }}>Start searching</p>
<p className="text-sm">Type a package name or keyword above</p>
</div>
)}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import { useState, useEffect } from 'react';
import api from '../api/client';
import { Database, Cpu, Heart, CheckCircle2, AlertCircle } from 'lucide-react';
export default function Settings() {
const [clearing, setClearing] = useState(false);
const [health, setHealth] = useState(null);
const [checking, setChecking] = useState(false);
const [msg, setMsg] = useState(null);
useEffect(() => { checkHealth(); }, []);
async function checkHealth() {
setChecking(true);
try { setHealth(await api.healthCheck()); }
catch (e) { setHealth({ status: 'offline', error: e.message }); }
finally { setChecking(false); }
}
async function clearCache() {
setClearing(true); setMsg(null);
try {
await api.clearCache();
setMsg({ ok: true, text: 'Cache cleared successfully' });
} catch (e) { setMsg({ ok: false, text: e.message }); }
finally { setClearing(false); }
}
return (
<div className="animate-slide-up max-w-2xl">
<div className="mb-8">
<h1 className="page-title">Settings</h1>
<p className="page-subtitle">Configure ArchStore preferences</p>
</div>
{msg && (
<div className="rounded-xl p-4 mb-6 flex items-center gap-3 text-sm font-medium"
style={{
background: msg.ok ? 'var(--green-muted)' : 'var(--red-muted)',
color: msg.ok ? 'var(--green)' : 'var(--red)',
border: `1px solid ${msg.ok ? 'rgba(34,197,94,0.2)' : 'rgba(239,68,68,0.2)'}`,
}}>
<CheckCircle2 size={16} />
{msg.text}
</div>
)}
<div className="flex flex-col gap-5">
{/* AUR Helper */}
<div className="card p-6">
<h3 className="font-semibold text-sm mb-1 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
<Cpu size={16} style={{ color: 'var(--accent)' }} />
AUR Helper
</h3>
<p className="text-xs mb-4" style={{ color: 'var(--text-tertiary)' }}>
The helper used to build and install AUR packages.
</p>
<div className="flex gap-3">
{['yay', 'paru'].map((t) => (
<div key={t} className="flex-1 p-3 rounded-xl text-center text-sm font-semibold transition-all cursor-default"
style={{
background: t === 'yay' ? 'var(--accent-muted)' : 'var(--bg-tertiary)',
color: t === 'yay' ? 'var(--accent)' : 'var(--text-tertiary)',
border: `1px solid ${t === 'yay' ? 'rgba(2,132,199,0.2)' : 'var(--border-primary)'}`,
}}>
{t}
{t === 'yay' && <span className="ml-2 text-[10px] font-bold px-1.5 py-0.5 rounded-full"
style={{ background: 'var(--green-muted)', color: 'var(--green)' }}>Active</span>}
</div>
))}
</div>
</div>
{/* Cache */}
<div className="card p-6">
<h3 className="font-semibold text-sm mb-1 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
<Database size={16} style={{ color: 'var(--accent)' }} />
Cache
</h3>
<p className="text-xs mb-4" style={{ color: 'var(--text-tertiary)' }}>
Search results and metadata are cached locally for 15 minutes.
</p>
<button onClick={clearCache} disabled={clearing} className="btn btn-danger text-xs">
{clearing ? 'Clearing...' : 'Clear Cache'}
</button>
</div>
{/* Health */}
<div className="card p-6">
<h3 className="font-semibold text-sm mb-4 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
<AlertCircle size={16} style={{ color: 'var(--accent)' }} />
Backend Status
</h3>
<div className="flex items-center justify-between p-4 rounded-xl font-mono text-xs"
style={{ background: 'var(--bg-primary)', border: '1px solid var(--border-primary)' }}>
<span style={{ color: 'var(--text-tertiary)' }}>FastAPI Server</span>
{health ? (
health.status === 'healthy' ? (
<span className="flex items-center gap-2 font-semibold" style={{ color: 'var(--green)' }}>
<span className="w-2 h-2 rounded-full" style={{ background: 'var(--green)' }}></span> Online
</span>
) : (
<span className="flex items-center gap-2 font-semibold" style={{ color: 'var(--red)' }}>
<span className="w-2 h-2 rounded-full" style={{ background: 'var(--red)' }}></span> Offline
</span>
)
) : (
<span style={{ color: 'var(--text-tertiary)' }}>{checking ? 'Checking...' : 'Unknown'}</span>
)}
</div>
</div>
{/* Footer */}
<div className="text-center py-6 text-xs" style={{ color: 'var(--text-tertiary)' }}>
<span className="flex items-center justify-center gap-1">
Made with <Heart size={11} style={{ color: 'var(--red)' }} /> for Arch Linux
</span>
<span>ArchStore v1.0.0</span>
</div>
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useState, useEffect } from 'react';
import api from '../api/client';
import LoadingSpinner from '../components/LoadingSpinner';
import { RefreshCw, ArrowUpCircle, Info, CheckCircle2 } from 'lucide-react';
export default function Updates() {
const [updates, setUpdates] = useState([]);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false);
const [log, setLog] = useState('');
const [error, setError] = useState(null);
useEffect(() => { check(); }, []);
async function check() {
setLoading(true); setError(null);
try { const d = await api.checkUpdates(); setUpdates(d.results || []); }
catch (e) { setError(e.message); }
finally { setLoading(false); }
}
async function handleUpdate() {
if (!confirm('Run a full system upgrade (yay -Syu)?')) return;
setUpdating(true); setError(null);
setLog('Starting system upgrade...\n');
try {
const r = await api.applyUpdates();
if (r.success) { setLog(p => p + '\n✓ Upgrade complete!\n' + r.message); setUpdates([]); }
else setError('Upgrade failed: ' + r.message);
} catch (e) { setError(e.message); }
finally { setUpdating(false); }
}
return (
<div className="animate-slide-up">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
<h1 className="page-title">System Updates</h1>
<p className="page-subtitle">Keep your system and AUR packages current</p>
</div>
<div className="flex gap-3">
<button onClick={check} disabled={loading || updating} className="btn btn-secondary">
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} /> Check
</button>
{updates.length > 0 && (
<button onClick={handleUpdate} disabled={updating} className="btn btn-primary">
<ArrowUpCircle size={15} /> Update All
</button>
)}
</div>
</div>
{error && (
<div className="rounded-xl p-4 mb-6 text-sm font-medium"
style={{ background: 'var(--red-muted)', color: 'var(--red)', border: '1px solid rgba(239,68,68,0.2)' }}>
{error}
</div>
)}
{updating && (
<div className="card p-6 mb-8" style={{ borderColor: 'rgba(2,132,199,0.3)' }}>
<h3 className="font-semibold mb-3 flex items-center gap-2 text-sm">
<RefreshCw size={16} className="animate-spin" style={{ color: 'var(--accent)' }} />
Upgrading...
</h3>
<pre className="p-4 rounded-lg font-mono text-xs overflow-x-auto max-h-64 whitespace-pre-wrap"
style={{ background: 'var(--bg-primary)', border: '1px solid var(--border-primary)', color: 'var(--text-secondary)' }}>
{log}
</pre>
</div>
)}
{loading ? (
<LoadingSpinner text="Checking for updates..." />
) : updates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24" style={{ color: 'var(--text-tertiary)' }}>
<div className="w-16 h-16 rounded-2xl flex items-center justify-center mb-5"
style={{ background: 'var(--green-muted)', border: '1px solid rgba(34,197,94,0.2)' }}>
<CheckCircle2 size={28} style={{ color: 'var(--green)' }} />
</div>
<p className="text-base font-semibold mb-1" style={{ color: 'var(--text-secondary)' }}>All up to date</p>
<p className="text-sm">No updates available right now</p>
</div>
) : (
<div>
<div className="rounded-xl p-4 mb-6 flex items-start gap-3 text-sm"
style={{ background: 'var(--accent-muted)', border: '1px solid rgba(2,132,199,0.2)' }}>
<Info size={18} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 2 }} />
<div>
<p className="font-semibold" style={{ color: 'var(--text-primary)' }}>{updates.length} update{updates.length !== 1 ? 's' : ''} available</p>
<p className="text-xs mt-0.5" style={{ color: 'var(--text-secondary)' }}>System administrator privileges required to install.</p>
</div>
</div>
<div className="card overflow-hidden">
{updates.map((u, i) => (
<div key={`${u.source}-${u.name}`}
className="flex items-center justify-between px-5 py-4 transition-colors"
style={{
borderBottom: i < updates.length - 1 ? '1px solid var(--border-primary)' : 'none',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-card-hover)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-sm" style={{ color: 'var(--text-primary)' }}>{u.name}</span>
<span className={`badge ${u.source === 'aur' ? 'badge-aur' : 'badge-pacman'}`}>
{u.source === 'aur' ? 'AUR' : 'pacman'}
</span>
</div>
<div className="flex items-center gap-2 text-xs font-mono" style={{ color: 'var(--text-tertiary)' }}>
<span>{u.current_version}</span>
<span></span>
<span style={{ color: 'var(--green)', fontWeight: 600 }}>{u.new_version}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})