16 Commits

Author SHA1 Message Date
Mondo Diaz
69d1db2493 Merge branch 'feature/frontend-hierarchy-components' 2025-12-12 10:15:44 -06:00
Mondo Diaz
30774e429b Add frontend components for project/package/tag hierarchy views
- Create reusable UI components: Badge, Breadcrumb, Card, DataTable,
  FilterChip, Pagination, SearchInput, SortDropdown
- Update Home page with search, sort, filter, and pagination
- Update ProjectPage with package stats, format filtering, keyboard nav
- Update PackagePage with DataTable for tags, copy artifact ID, metadata
- Add TypeScript types for paginated responses and list params
- Update API client with pagination and filter query param support
- URL-based state persistence for all filter/sort/page params
- Keyboard navigation (Backspace to go up hierarchy)
2025-12-12 10:10:10 -06:00
Mondo Diaz
59b306be69 Update README with new tag and artifact API endpoints
- Document enhanced tags listing with pagination, search, sort
- Add examples for single tag and tag history endpoints
- Add examples for listing artifacts by package with filtering
- Update artifact endpoint to show tag references in response
2025-12-12 09:47:58 -06:00
Mondo Diaz
516fe68252 Add API endpoints for listing tagged versions and artifacts
- Enhanced GET /api/v1/project/{project}/{package}/tags with pagination,
  search, sort, order parameters and artifact metadata
- Added GET /api/v1/project/{project}/{package}/tags/{tag_name} for
  single tag with full artifact details
- Added GET /api/v1/project/{project}/{package}/tags/{tag_name}/history
  for tag change history
- Added GET /api/v1/project/{project}/{package}/artifacts for listing
  all artifacts in a package with filtering by content_type and date range
- Enhanced GET /api/v1/artifact/{artifact_id} to include list of
  packages/tags referencing the artifact
- Added new schemas: TagDetailResponse, TagHistoryResponse,
  ArtifactDetailResponse, ArtifactTagInfo, PackageArtifactResponse
2025-12-12 09:38:55 -06:00
Mondo Diaz
a4b4d700c2 Revert "Add API endpoints for listing tagged versions and artifacts"
This reverts commit 54e33e67ce.
2025-12-12 09:38:38 -06:00
Mondo Diaz
54e33e67ce Add API endpoints for listing tagged versions and artifacts
- Enhanced GET /api/v1/project/{project}/{package}/tags with pagination,
  search, sort, order parameters and artifact metadata
- Added GET /api/v1/project/{project}/{package}/tags/{tag_name} for
  single tag with full artifact details
- Added GET /api/v1/project/{project}/{package}/tags/{tag_name}/history
  for tag change history
- Added GET /api/v1/project/{project}/{package}/artifacts for listing
  all artifacts in a package with filtering by content_type and date range
- Enhanced GET /api/v1/artifact/{artifact_id} to include list of
  packages/tags referencing the artifact
- Added new schemas: TagDetailResponse, TagHistoryResponse,
  ArtifactDetailResponse, ArtifactTagInfo, PackageArtifactResponse
2025-12-12 09:38:06 -06:00
Mondo Diaz
8b7b523aa8 Merge branch 'feature/packages-api-enhancements' into 'main'
Implement Backend API to List Packages within a Project

Closes #3

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!6
2025-12-11 18:47:46 -06:00
Mondo Diaz
dea03c4a12 Implement Backend API to List Packages within a Project 2025-12-11 18:47:46 -06:00
Mondo Diaz
1793fd3a8f Merge branch 'feature/upload-download-apis' into 'main'
Implement backend upload/download API enhancements

Closes #11

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!4
2025-12-11 18:05:08 -06:00
Mondo Diaz
c119ab4a04 Implement backend upload/download API enhancements 2025-12-11 18:05:08 -06:00
Mondo Diaz
e9404a4425 Merge branch 'feature/projects-api-pagination-search' into 'main'
Add pagination and search to projects API

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!1
2025-12-11 15:03:42 -06:00
Mondo Diaz
b896ad1fad Add pagination and search to projects API 2025-12-11 15:03:41 -06:00
Mondo Diaz
b3861894cb Merge branch 'feature/modern-dark-ui' into 'main'
Modern dark mode UI overhaul

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!3
2025-12-11 14:58:13 -06:00
Mondo Diaz
71bb7d96b3 Modern dark mode UI overhaul 2025-12-11 14:58:13 -06:00
Mondo Diaz
e0802444c0 Merge branch 'feature/test-data-seeding' into 'main'
Add development mode with automatic test data seeding

See merge request esv/bsf/bsf-integration/orchard/orchard-mvp!2
2025-12-11 14:36:22 -06:00
Mondo Diaz
c08d1082eb Add development mode with automatic test data seeding 2025-12-11 14:36:22 -06:00
36 changed files with 4439 additions and 226 deletions

8
.gitignore vendored
View File

@@ -1,3 +1,11 @@
# Python
__pycache__/
*.py[cod]
*.pyo
.Python
*.egg-info/
.eggs/
# Binaries # Binaries
/bin/ /bin/
*.exe *.exe

274
README.md
View File

@@ -22,12 +22,30 @@ Orchard is a centralized binary artifact storage system that provides content-ad
- **Package** - Named collection within a project - **Package** - Named collection within a project
- **Artifact** - Specific content instance identified by SHA256 - **Artifact** - Specific content instance identified by SHA256
- **Tags** - Alias system for referencing artifacts by human-readable names (e.g., `v1.0.0`, `latest`, `stable`) - **Tags** - Alias system for referencing artifacts by human-readable names (e.g., `v1.0.0`, `latest`, `stable`)
- **Package Formats & Platforms** - Packages can be tagged with format (npm, pypi, docker, deb, rpm, etc.) and platform (linux, darwin, windows, etc.)
- **Rich Package Metadata** - Package listings include aggregated stats (tag count, artifact count, total size, latest tag)
- **S3-Compatible Backend** - Uses MinIO (or any S3-compatible storage) for artifact storage - **S3-Compatible Backend** - Uses MinIO (or any S3-compatible storage) for artifact storage
- **PostgreSQL Metadata** - Relational database for metadata, access control, and audit trails - **PostgreSQL Metadata** - Relational database for metadata, access control, and audit trails
- **REST API** - Full HTTP API for all operations - **REST API** - Full HTTP API for all operations
- **Web UI** - React-based interface for managing artifacts - **Web UI** - React-based interface for managing artifacts with:
- Hierarchical navigation (Projects → Packages → Tags/Artifacts)
- Search, sort, and filter capabilities on all list views
- URL-based state persistence for filters and pagination
- Keyboard navigation (Backspace to go up hierarchy)
- Copy-to-clipboard for artifact IDs
- Responsive design for mobile and desktop
- **Docker Compose Setup** - Easy local development environment - **Docker Compose Setup** - Easy local development environment
- **Helm Chart** - Kubernetes deployment with PostgreSQL, MinIO, and Redis subcharts - **Helm Chart** - Kubernetes deployment with PostgreSQL, MinIO, and Redis subcharts
- **Multipart Upload** - Automatic multipart upload for files larger than 100MB
- **Resumable Uploads** - API for resumable uploads with part-by-part upload support
- **Range Requests** - HTTP range request support for partial downloads
- **Format-Specific Metadata** - Automatic extraction of metadata from package formats:
- `.deb` - Debian packages (name, version, architecture, maintainer)
- `.rpm` - RPM packages (name, version, release, architecture)
- `.tar.gz/.tgz` - Tarballs (name, version from filename)
- `.whl` - Python wheels (name, version, author)
- `.jar` - Java JARs (manifest info, Maven coordinates)
- `.zip` - ZIP files (file count, uncompressed size)
### API Endpoints ### API Endpoints
@@ -38,14 +56,31 @@ Orchard is a centralized binary artifact storage system that provides content-ad
| `GET` | `/api/v1/projects` | List all projects | | `GET` | `/api/v1/projects` | List all projects |
| `POST` | `/api/v1/projects` | Create a new project | | `POST` | `/api/v1/projects` | Create a new project |
| `GET` | `/api/v1/projects/:project` | Get project details | | `GET` | `/api/v1/projects/:project` | Get project details |
| `GET` | `/api/v1/project/:project/packages` | List packages in a project | | `GET` | `/api/v1/project/:project/packages` | List packages (with pagination, search, filtering) |
| `GET` | `/api/v1/project/:project/packages/:package` | Get single package with metadata |
| `POST` | `/api/v1/project/:project/packages` | Create a new package | | `POST` | `/api/v1/project/:project/packages` | Create a new package |
| `POST` | `/api/v1/project/:project/:package/upload` | Upload an artifact | | `POST` | `/api/v1/project/:project/:package/upload` | Upload an artifact |
| `GET` | `/api/v1/project/:project/:package/+/:ref` | Download an artifact | | `GET` | `/api/v1/project/:project/:package/+/:ref` | Download an artifact (supports Range header) |
| `GET` | `/api/v1/project/:project/:package/tags` | List all tags | | `HEAD` | `/api/v1/project/:project/:package/+/:ref` | Get artifact metadata without downloading |
| `GET` | `/api/v1/project/:project/:package/tags` | List tags (with pagination, search, sorting, artifact metadata) |
| `POST` | `/api/v1/project/:project/:package/tags` | Create a tag | | `POST` | `/api/v1/project/:project/:package/tags` | Create a tag |
| `GET` | `/api/v1/project/:project/:package/tags/:tag_name` | Get single tag with artifact metadata |
| `GET` | `/api/v1/project/:project/:package/tags/:tag_name/history` | Get tag change history |
| `GET` | `/api/v1/project/:project/:package/artifacts` | List artifacts in package (with filtering) |
| `GET` | `/api/v1/project/:project/:package/consumers` | List consumers of a package | | `GET` | `/api/v1/project/:project/:package/consumers` | List consumers of a package |
| `GET` | `/api/v1/artifact/:id` | Get artifact metadata by hash | | `GET` | `/api/v1/artifact/:id` | Get artifact metadata with referencing tags |
#### Resumable Upload Endpoints
For large files, use the resumable upload API:
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/v1/project/:project/:package/upload/init` | Initialize resumable upload |
| `PUT` | `/api/v1/project/:project/:package/upload/:upload_id/part/:part_number` | Upload a part |
| `POST` | `/api/v1/project/:project/:package/upload/:upload_id/complete` | Complete upload |
| `DELETE` | `/api/v1/project/:project/:package/upload/:upload_id` | Abort upload |
| `GET` | `/api/v1/project/:project/:package/upload/:upload_id/status` | Get upload status |
### Reference Formats ### Reference Formats
@@ -128,7 +163,61 @@ curl -X POST http://localhost:8080/api/v1/projects \
```bash ```bash
curl -X POST http://localhost:8080/api/v1/project/my-project/packages \ curl -X POST http://localhost:8080/api/v1/project/my-project/packages \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"name": "releases", "description": "Release builds"}' -d '{"name": "releases", "description": "Release builds", "format": "generic", "platform": "any"}'
```
Supported formats: `generic`, `npm`, `pypi`, `docker`, `deb`, `rpm`, `maven`, `nuget`, `helm`
Supported platforms: `any`, `linux`, `darwin`, `windows`, `linux-amd64`, `linux-arm64`, `darwin-amd64`, `darwin-arm64`, `windows-amd64`
### List Packages
```bash
# Basic listing
curl http://localhost:8080/api/v1/project/my-project/packages
# With pagination
curl "http://localhost:8080/api/v1/project/my-project/packages?page=1&limit=10"
# With search
curl "http://localhost:8080/api/v1/project/my-project/packages?search=release"
# With sorting
curl "http://localhost:8080/api/v1/project/my-project/packages?sort=created_at&order=desc"
# Filter by format/platform
curl "http://localhost:8080/api/v1/project/my-project/packages?format=npm&platform=linux"
```
Response includes aggregated metadata:
```json
{
"items": [
{
"id": "uuid",
"name": "releases",
"description": "Release builds",
"format": "generic",
"platform": "any",
"tag_count": 5,
"artifact_count": 3,
"total_size": 1048576,
"latest_tag": "v1.0.0",
"latest_upload_at": "2025-01-01T00:00:00Z",
"recent_tags": [...]
}
],
"pagination": {"page": 1, "limit": 20, "total": 1, "total_pages": 1}
}
```
### Get Single Package
```bash
curl http://localhost:8080/api/v1/project/my-project/packages/releases
# Include all tags (not just recent 5)
curl "http://localhost:8080/api/v1/project/my-project/packages/releases?include_tags=true"
``` ```
### Upload an Artifact ### Upload an Artifact
@@ -146,10 +235,43 @@ Response:
"size": 1048576, "size": 1048576,
"project": "my-project", "project": "my-project",
"package": "releases", "package": "releases",
"tag": "v1.0.0" "tag": "v1.0.0",
"format_metadata": {
"format": "tarball",
"package_name": "app",
"version": "1.0.0"
},
"deduplicated": false
} }
``` ```
### Resumable Upload (for large files)
For files larger than 100MB, use the resumable upload API:
```bash
# 1. Initialize upload (client must compute SHA256 hash first)
curl -X POST http://localhost:8080/api/v1/project/my-project/releases/upload/init \
-H "Content-Type: application/json" \
-d '{
"expected_hash": "a3f5d8e12b4c67890abcdef1234567890abcdef1234567890abcdef12345678",
"filename": "large-file.tar.gz",
"size": 524288000,
"tag": "v2.0.0"
}'
# Response: {"upload_id": "abc123", "already_exists": false, "chunk_size": 10485760}
# 2. Upload parts (10MB chunks recommended)
curl -X PUT http://localhost:8080/api/v1/project/my-project/releases/upload/abc123/part/1 \
--data-binary @chunk1.bin
# 3. Complete the upload
curl -X POST http://localhost:8080/api/v1/project/my-project/releases/upload/abc123/complete \
-H "Content-Type: application/json" \
-d '{"tag": "v2.0.0"}'
```
### Download an Artifact ### Download an Artifact
```bash ```bash
@@ -161,6 +283,12 @@ curl -O http://localhost:8080/api/v1/project/my-project/releases/+/artifact:a3f5
# Using the short URL pattern # Using the short URL pattern
curl -O http://localhost:8080/project/my-project/releases/+/latest curl -O http://localhost:8080/project/my-project/releases/+/latest
# Partial download (range request)
curl -H "Range: bytes=0-1023" http://localhost:8080/api/v1/project/my-project/releases/+/v1.0.0
# Check file info without downloading (HEAD request)
curl -I http://localhost:8080/api/v1/project/my-project/releases/+/v1.0.0
``` ```
### Create a Tag ### Create a Tag
@@ -171,12 +299,119 @@ curl -X POST http://localhost:8080/api/v1/project/my-project/releases/tags \
-d '{"name": "stable", "artifact_id": "a3f5d8e12b4c6789..."}' -d '{"name": "stable", "artifact_id": "a3f5d8e12b4c6789..."}'
``` ```
### List Tags
```bash
# Basic listing with artifact metadata
curl http://localhost:8080/api/v1/project/my-project/releases/tags
# With pagination
curl "http://localhost:8080/api/v1/project/my-project/releases/tags?page=1&limit=10"
# Search by tag name
curl "http://localhost:8080/api/v1/project/my-project/releases/tags?search=v1"
# Sort by created_at descending
curl "http://localhost:8080/api/v1/project/my-project/releases/tags?sort=created_at&order=desc"
```
Response includes artifact metadata:
```json
{
"items": [
{
"id": "uuid",
"package_id": "uuid",
"name": "v1.0.0",
"artifact_id": "a3f5d8e...",
"created_at": "2025-01-01T00:00:00Z",
"created_by": "user",
"artifact_size": 1048576,
"artifact_content_type": "application/gzip",
"artifact_original_name": "app-v1.0.0.tar.gz",
"artifact_created_at": "2025-01-01T00:00:00Z",
"artifact_format_metadata": {}
}
],
"pagination": {"page": 1, "limit": 20, "total": 1, "total_pages": 1}
}
```
### Get Single Tag
```bash
curl http://localhost:8080/api/v1/project/my-project/releases/tags/v1.0.0
```
### Get Tag History
```bash
curl http://localhost:8080/api/v1/project/my-project/releases/tags/latest/history
```
Returns list of artifact changes for the tag (most recent first).
### List Artifacts in Package
```bash
# Basic listing
curl http://localhost:8080/api/v1/project/my-project/releases/artifacts
# Filter by content type
curl "http://localhost:8080/api/v1/project/my-project/releases/artifacts?content_type=application/gzip"
# Filter by date range
curl "http://localhost:8080/api/v1/project/my-project/releases/artifacts?created_after=2025-01-01T00:00:00Z"
```
Response includes tags pointing to each artifact:
```json
{
"items": [
{
"id": "a3f5d8e...",
"size": 1048576,
"content_type": "application/gzip",
"original_name": "app-v1.0.0.tar.gz",
"created_at": "2025-01-01T00:00:00Z",
"created_by": "user",
"format_metadata": {},
"tags": ["v1.0.0", "latest", "stable"]
}
],
"pagination": {"page": 1, "limit": 20, "total": 1, "total_pages": 1}
}
```
### Get Artifact by ID ### Get Artifact by ID
```bash ```bash
curl http://localhost:8080/api/v1/artifact/a3f5d8e12b4c67890abcdef1234567890abcdef1234567890abcdef12345678 curl http://localhost:8080/api/v1/artifact/a3f5d8e12b4c67890abcdef1234567890abcdef1234567890abcdef12345678
``` ```
Response includes all tags/packages referencing the artifact:
```json
{
"id": "a3f5d8e...",
"size": 1048576,
"content_type": "application/gzip",
"original_name": "app-v1.0.0.tar.gz",
"created_at": "2025-01-01T00:00:00Z",
"created_by": "user",
"ref_count": 2,
"format_metadata": {},
"tags": [
{
"id": "uuid",
"name": "v1.0.0",
"package_id": "uuid",
"package_name": "releases",
"project_name": "my-project"
}
]
}
```
## Project Structure ## Project Structure
``` ```
@@ -185,19 +420,31 @@ orchard/
│ ├── app/ │ ├── app/
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── config.py # Pydantic settings │ │ ├── config.py # Pydantic settings
│ │ ├── database.py # SQLAlchemy setup │ │ ├── database.py # SQLAlchemy setup and migrations
│ │ ├── main.py # FastAPI application │ │ ├── main.py # FastAPI application
│ │ ├── metadata.py # Format-specific metadata extraction
│ │ ├── models.py # SQLAlchemy models │ │ ├── models.py # SQLAlchemy models
│ │ ├── routes.py # API endpoints │ │ ├── routes.py # API endpoints
│ │ ├── schemas.py # Pydantic schemas │ │ ├── schemas.py # Pydantic schemas
│ │ └── storage.py # S3 storage layer │ │ └── storage.py # S3 storage layer with multipart support
│ └── requirements.txt │ └── requirements.txt
├── frontend/ ├── frontend/
│ ├── src/ │ ├── src/
│ │ ├── components/ # React components │ │ ├── components/ # Reusable UI components
│ │ │ ├── Badge.tsx # Status/type badges
│ │ │ ├── Breadcrumb.tsx # Navigation breadcrumbs
│ │ │ ├── Card.tsx # Card containers
│ │ │ ├── DataTable.tsx # Sortable data tables
│ │ │ ├── FilterChip.tsx # Active filter chips
│ │ │ ├── Pagination.tsx # Page navigation
│ │ │ ├── SearchInput.tsx # Debounced search
│ │ │ └── SortDropdown.tsx# Sort field selector
│ │ ├── pages/ # Page components │ │ ├── pages/ # Page components
│ │ ├── api.ts # API client │ │ │ ├── Home.tsx # Project list
│ │ ├── types.ts # TypeScript types │ │ │ ├── ProjectPage.tsx # Package list within project
│ │ │ └── PackagePage.tsx # Tag/artifact list within package
│ │ ├── api.ts # API client with pagination support
│ │ ├── types.ts # TypeScript interfaces
│ │ ├── App.tsx │ │ ├── App.tsx
│ │ └── main.tsx │ │ └── main.tsx
│ ├── index.html │ ├── index.html
@@ -278,9 +525,8 @@ The following features are planned but not yet implemented:
- [ ] Automated update propagation - [ ] Automated update propagation
- [ ] OIDC/SAML authentication - [ ] OIDC/SAML authentication
- [ ] API key management - [ ] API key management
- [ ] Package format detection
- [ ] Multipart upload for large files
- [ ] Redis caching layer - [ ] Redis caching layer
- [ ] Garbage collection for orphaned artifacts
## License ## License

View File

@@ -3,6 +3,9 @@ from functools import lru_cache
class Settings(BaseSettings): class Settings(BaseSettings):
# Environment
env: str = "development" # "development" or "production"
# Server # Server
server_host: str = "0.0.0.0" server_host: str = "0.0.0.0"
server_port: int = 8080 server_port: int = 8080
@@ -28,6 +31,14 @@ class Settings(BaseSettings):
sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else "" sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else ""
return f"postgresql://{self.database_user}:{self.database_password}@{self.database_host}:{self.database_port}/{self.database_dbname}{sslmode}" return f"postgresql://{self.database_user}:{self.database_password}@{self.database_host}:{self.database_port}/{self.database_dbname}{sslmode}"
@property
def is_development(self) -> bool:
return self.env.lower() == "development"
@property
def is_production(self) -> bool:
return self.env.lower() == "production"
class Config: class Config:
env_prefix = "ORCHARD_" env_prefix = "ORCHARD_"
case_sensitive = False case_sensitive = False

View File

@@ -1,20 +1,77 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, Session from sqlalchemy.orm import sessionmaker, Session
from typing import Generator from typing import Generator
import logging
from .config import get_settings from .config import get_settings
from .models import Base from .models import Base
settings = get_settings() settings = get_settings()
logger = logging.getLogger(__name__)
engine = create_engine(settings.database_url, pool_pre_ping=True) engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def init_db(): def init_db():
"""Create all tables""" """Create all tables and run migrations"""
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
# Run migrations for schema updates
_run_migrations()
def _run_migrations():
"""Run manual migrations for schema updates"""
migrations = [
# Add format_metadata column to artifacts table
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'artifacts' AND column_name = 'format_metadata'
) THEN
ALTER TABLE artifacts ADD COLUMN format_metadata JSONB DEFAULT '{}';
END IF;
END $$;
""",
# Add format column to packages table
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'packages' AND column_name = 'format'
) THEN
ALTER TABLE packages ADD COLUMN format VARCHAR(50) DEFAULT 'generic' NOT NULL;
CREATE INDEX IF NOT EXISTS idx_packages_format ON packages(format);
END IF;
END $$;
""",
# Add platform column to packages table
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'packages' AND column_name = 'platform'
) THEN
ALTER TABLE packages ADD COLUMN platform VARCHAR(50) DEFAULT 'any' NOT NULL;
CREATE INDEX IF NOT EXISTS idx_packages_platform ON packages(platform);
END IF;
END $$;
""",
]
with engine.connect() as conn:
for migration in migrations:
try:
conn.execute(text(migration))
conn.commit()
except Exception as e:
logger.warning(f"Migration failed (may already be applied): {e}")
def get_db() -> Generator[Session, None, None]: def get_db() -> Generator[Session, None, None]:
"""Dependency for getting database sessions""" """Dependency for getting database sessions"""

View File

@@ -2,19 +2,35 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import logging
import os import os
from .config import get_settings from .config import get_settings
from .database import init_db from .database import init_db, SessionLocal
from .routes import router from .routes import router
from .seed import seed_database
settings = get_settings() settings = get_settings()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup: initialize database # Startup: initialize database
init_db() init_db()
# Seed test data in development mode
if settings.is_development:
logger.info(f"Running in {settings.env} mode - checking for seed data")
db = SessionLocal()
try:
seed_database(db)
finally:
db.close()
else:
logger.info(f"Running in {settings.env} mode - skipping seed data")
yield yield
# Shutdown: cleanup if needed # Shutdown: cleanup if needed
@@ -41,11 +57,12 @@ if os.path.exists(static_dir):
# Catch-all for SPA routing (must be last) # Catch-all for SPA routing (must be last)
@app.get("/{full_path:path}") @app.get("/{full_path:path}")
async def serve_spa_routes(full_path: str): async def serve_spa_routes(full_path: str):
# Don't catch API routes # Don't catch API routes or health endpoint
if full_path.startswith("api/") or full_path.startswith("health") or full_path.startswith("project/"): if full_path.startswith("api/") or full_path.startswith("health"):
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Not found") raise HTTPException(status_code=404, detail="Not found")
# Serve SPA for all other routes (including /project/*)
index_path = os.path.join(static_dir, "index.html") index_path = os.path.join(static_dir, "index.html")
if os.path.exists(index_path): if os.path.exists(index_path):
return FileResponse(index_path) return FileResponse(index_path)

354
backend/app/metadata.py Normal file
View File

@@ -0,0 +1,354 @@
"""
Format-specific metadata extraction for uploaded artifacts.
Supports extracting version info and other metadata from package formats.
"""
import struct
import gzip
import tarfile
import io
import re
import logging
from typing import Dict, Any, Optional, BinaryIO
logger = logging.getLogger(__name__)
def extract_metadata(file: BinaryIO, filename: str, content_type: Optional[str] = None) -> Dict[str, Any]:
"""
Extract format-specific metadata from an uploaded file.
Returns a dict with extracted metadata fields.
"""
metadata = {}
# Determine format from filename extension
lower_filename = filename.lower() if filename else ""
try:
if lower_filename.endswith(".deb"):
metadata = extract_deb_metadata(file)
elif lower_filename.endswith(".rpm"):
metadata = extract_rpm_metadata(file)
elif lower_filename.endswith(".tar.gz") or lower_filename.endswith(".tgz"):
metadata = extract_tarball_metadata(file, filename)
elif lower_filename.endswith(".whl"):
metadata = extract_wheel_metadata(file)
elif lower_filename.endswith(".jar"):
metadata = extract_jar_metadata(file)
elif lower_filename.endswith(".zip"):
metadata = extract_zip_metadata(file)
except Exception as e:
logger.warning(f"Failed to extract metadata from {filename}: {e}")
# Always seek back to start after reading
try:
file.seek(0)
except Exception:
pass
return metadata
def extract_deb_metadata(file: BinaryIO) -> Dict[str, Any]:
"""
Extract metadata from a Debian .deb package.
Deb files are ar archives containing control.tar.gz with package info.
"""
metadata = {}
# Read ar archive header
ar_magic = file.read(8)
if ar_magic != b"!<arch>\n":
return metadata
# Parse ar archive to find control.tar.gz or control.tar.xz
while True:
# Read ar entry header (60 bytes)
header = file.read(60)
if len(header) < 60:
break
name = header[0:16].decode("ascii").strip()
size_str = header[48:58].decode("ascii").strip()
try:
size = int(size_str)
except ValueError:
break
if name.startswith("control.tar"):
# Read control archive
control_data = file.read(size)
# Decompress and read control file
try:
if name.endswith(".gz"):
control_data = gzip.decompress(control_data)
# Parse tar archive
with tarfile.open(fileobj=io.BytesIO(control_data), mode="r:*") as tar:
for member in tar.getmembers():
if member.name in ("./control", "control"):
f = tar.extractfile(member)
if f:
control_content = f.read().decode("utf-8", errors="replace")
metadata = parse_deb_control(control_content)
break
except Exception as e:
logger.debug(f"Failed to parse deb control: {e}")
break
else:
# Skip to next entry (align to 2 bytes)
file.seek(size + (size % 2), 1)
return metadata
def parse_deb_control(content: str) -> Dict[str, Any]:
"""Parse Debian control file format"""
metadata = {}
current_key = None
current_value = []
for line in content.split("\n"):
if line.startswith(" ") or line.startswith("\t"):
# Continuation line
if current_key:
current_value.append(line.strip())
elif ":" in line:
# Save previous field
if current_key:
metadata[current_key] = "\n".join(current_value)
# Parse new field
key, value = line.split(":", 1)
current_key = key.strip().lower()
current_value = [value.strip()]
else:
# Empty line or malformed
if current_key:
metadata[current_key] = "\n".join(current_value)
current_key = None
current_value = []
# Don't forget the last field
if current_key:
metadata[current_key] = "\n".join(current_value)
# Extract key fields
result = {}
if "package" in metadata:
result["package_name"] = metadata["package"]
if "version" in metadata:
result["version"] = metadata["version"]
if "architecture" in metadata:
result["architecture"] = metadata["architecture"]
if "maintainer" in metadata:
result["maintainer"] = metadata["maintainer"]
if "description" in metadata:
result["description"] = metadata["description"].split("\n")[0] # First line only
if "depends" in metadata:
result["depends"] = metadata["depends"]
result["format"] = "deb"
return result
def extract_rpm_metadata(file: BinaryIO) -> Dict[str, Any]:
"""
Extract metadata from an RPM package.
RPM files have a lead, signature, and header with metadata.
"""
metadata = {"format": "rpm"}
# Read RPM lead (96 bytes)
lead = file.read(96)
if len(lead) < 96:
return metadata
# Check magic number
if lead[0:4] != b"\xed\xab\xee\xdb":
return metadata
# Read name from lead (offset 10, max 66 bytes)
name_bytes = lead[10:76]
null_idx = name_bytes.find(b"\x00")
if null_idx > 0:
metadata["package_name"] = name_bytes[:null_idx].decode("ascii", errors="replace")
# Skip signature header to get to the main header
# This is complex - simplified version just extracts from lead
try:
# Skip to header
while True:
header_magic = file.read(8)
if len(header_magic) < 8:
break
if header_magic[0:3] == b"\x8e\xad\xe8":
# Found header magic
# Read header index count and data size
index_count = struct.unpack(">I", header_magic[4:8])[0]
data_size_bytes = file.read(4)
if len(data_size_bytes) < 4:
break
data_size = struct.unpack(">I", data_size_bytes)[0]
# Read header entries
entries = []
for _ in range(index_count):
entry = file.read(16)
if len(entry) < 16:
break
tag, type_, offset, count = struct.unpack(">IIII", entry)
entries.append((tag, type_, offset, count))
# Read header data
header_data = file.read(data_size)
# Extract relevant tags
# Tag 1000 = Name, Tag 1001 = Version, Tag 1002 = Release
# Tag 1004 = Summary, Tag 1022 = Arch
for tag, type_, offset, count in entries:
if type_ == 6: # STRING type
end = header_data.find(b"\x00", offset)
if end > offset:
value = header_data[offset:end].decode("utf-8", errors="replace")
if tag == 1000:
metadata["package_name"] = value
elif tag == 1001:
metadata["version"] = value
elif tag == 1002:
metadata["release"] = value
elif tag == 1004:
metadata["description"] = value
elif tag == 1022:
metadata["architecture"] = value
break
except Exception as e:
logger.debug(f"Failed to parse RPM header: {e}")
return metadata
def extract_tarball_metadata(file: BinaryIO, filename: str) -> Dict[str, Any]:
"""Extract metadata from a tarball (name and version from filename)"""
metadata = {"format": "tarball"}
# Try to extract name and version from filename
# Common patterns: package-1.0.0.tar.gz, package_1.0.0.tar.gz
basename = filename
for suffix in [".tar.gz", ".tgz", ".tar.bz2", ".tar.xz"]:
if basename.lower().endswith(suffix):
basename = basename[:-len(suffix)]
break
# Try to split name and version
patterns = [
r"^(.+)-(\d+\.\d+(?:\.\d+)?(?:[-._]\w+)?)$", # name-version
r"^(.+)_(\d+\.\d+(?:\.\d+)?(?:[-._]\w+)?)$", # name_version
]
for pattern in patterns:
match = re.match(pattern, basename)
if match:
metadata["package_name"] = match.group(1)
metadata["version"] = match.group(2)
break
return metadata
def extract_wheel_metadata(file: BinaryIO) -> Dict[str, Any]:
"""Extract metadata from a Python wheel (.whl) file"""
import zipfile
metadata = {"format": "wheel"}
try:
with zipfile.ZipFile(file, "r") as zf:
# Find METADATA file in .dist-info directory
for name in zf.namelist():
if name.endswith("/METADATA") and ".dist-info/" in name:
with zf.open(name) as f:
content = f.read().decode("utf-8", errors="replace")
# Parse email-style headers
for line in content.split("\n"):
if line.startswith("Name:"):
metadata["package_name"] = line[5:].strip()
elif line.startswith("Version:"):
metadata["version"] = line[8:].strip()
elif line.startswith("Summary:"):
metadata["description"] = line[8:].strip()
elif line.startswith("Author:"):
metadata["author"] = line[7:].strip()
elif line == "":
break # End of headers
break
except Exception as e:
logger.debug(f"Failed to parse wheel: {e}")
return metadata
def extract_jar_metadata(file: BinaryIO) -> Dict[str, Any]:
"""Extract metadata from a Java JAR file"""
import zipfile
metadata = {"format": "jar"}
try:
with zipfile.ZipFile(file, "r") as zf:
# Look for MANIFEST.MF
if "META-INF/MANIFEST.MF" in zf.namelist():
with zf.open("META-INF/MANIFEST.MF") as f:
content = f.read().decode("utf-8", errors="replace")
for line in content.split("\n"):
line = line.strip()
if line.startswith("Implementation-Title:"):
metadata["package_name"] = line[21:].strip()
elif line.startswith("Implementation-Version:"):
metadata["version"] = line[23:].strip()
elif line.startswith("Bundle-Name:"):
metadata["bundle_name"] = line[12:].strip()
elif line.startswith("Bundle-Version:"):
metadata["bundle_version"] = line[15:].strip()
# Also look for pom.properties in Maven JARs
for name in zf.namelist():
if name.endswith("/pom.properties"):
with zf.open(name) as f:
content = f.read().decode("utf-8", errors="replace")
for line in content.split("\n"):
if line.startswith("artifactId="):
metadata["artifact_id"] = line[11:].strip()
elif line.startswith("groupId="):
metadata["group_id"] = line[8:].strip()
elif line.startswith("version="):
if "version" not in metadata:
metadata["version"] = line[8:].strip()
break
except Exception as e:
logger.debug(f"Failed to parse JAR: {e}")
return metadata
def extract_zip_metadata(file: BinaryIO) -> Dict[str, Any]:
"""Extract basic metadata from a ZIP file"""
import zipfile
metadata = {"format": "zip"}
try:
with zipfile.ZipFile(file, "r") as zf:
metadata["file_count"] = len(zf.namelist())
# Calculate total uncompressed size
total_size = sum(info.file_size for info in zf.infolist())
metadata["uncompressed_size"] = total_size
except Exception as e:
logger.debug(f"Failed to parse ZIP: {e}")
return metadata

View File

@@ -38,6 +38,8 @@ class Package(Base):
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
name = Column(String(255), nullable=False) name = Column(String(255), nullable=False)
description = Column(Text) description = Column(Text)
format = Column(String(50), default="generic", nullable=False)
platform = Column(String(50), default="any", nullable=False)
created_at = Column(DateTime(timezone=True), default=datetime.utcnow) created_at = Column(DateTime(timezone=True), default=datetime.utcnow)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -49,6 +51,16 @@ class Package(Base):
__table_args__ = ( __table_args__ = (
Index("idx_packages_project_id", "project_id"), Index("idx_packages_project_id", "project_id"),
Index("idx_packages_name", "name"), Index("idx_packages_name", "name"),
Index("idx_packages_format", "format"),
Index("idx_packages_platform", "platform"),
CheckConstraint(
"format IN ('generic', 'npm', 'pypi', 'docker', 'deb', 'rpm', 'maven', 'nuget', 'helm')",
name="check_package_format"
),
CheckConstraint(
"platform IN ('any', 'linux', 'darwin', 'windows', 'linux-amd64', 'linux-arm64', 'darwin-amd64', 'darwin-arm64', 'windows-amd64')",
name="check_package_platform"
),
{"extend_existing": True}, {"extend_existing": True},
) )
@@ -64,6 +76,7 @@ class Artifact(Base):
created_by = Column(String(255), nullable=False) created_by = Column(String(255), nullable=False)
ref_count = Column(Integer, default=1) ref_count = Column(Integer, default=1)
s3_key = Column(String(1024), nullable=False) s3_key = Column(String(1024), nullable=False)
format_metadata = Column(JSON, default=dict) # Format-specific metadata (version, etc.)
tags = relationship("Tag", back_populates="artifact") tags = relationship("Tag", back_populates="artifact")
uploads = relationship("Upload", back_populates="artifact") uploads = relationship("Upload", back_populates="artifact")

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,23 @@
from datetime import datetime from datetime import datetime
from typing import Optional, List from typing import Optional, List, Dict, Any, Generic, TypeVar
from pydantic import BaseModel from pydantic import BaseModel
from uuid import UUID from uuid import UUID
T = TypeVar("T")
# Pagination schemas
class PaginationMeta(BaseModel):
page: int
limit: int
total: int
total_pages: int
class PaginatedResponse(BaseModel, Generic[T]):
items: List[T]
pagination: PaginationMeta
# Project schemas # Project schemas
class ProjectCreate(BaseModel): class ProjectCreate(BaseModel):
@@ -24,10 +39,17 @@ class ProjectResponse(BaseModel):
from_attributes = True from_attributes = True
# Package format and platform enums
PACKAGE_FORMATS = ["generic", "npm", "pypi", "docker", "deb", "rpm", "maven", "nuget", "helm"]
PACKAGE_PLATFORMS = ["any", "linux", "darwin", "windows", "linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "windows-amd64"]
# Package schemas # Package schemas
class PackageCreate(BaseModel): class PackageCreate(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
format: str = "generic"
platform: str = "any"
class PackageResponse(BaseModel): class PackageResponse(BaseModel):
@@ -35,6 +57,8 @@ class PackageResponse(BaseModel):
project_id: UUID project_id: UUID
name: str name: str
description: Optional[str] description: Optional[str]
format: str
platform: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -42,6 +66,36 @@ class PackageResponse(BaseModel):
from_attributes = True from_attributes = True
class TagSummary(BaseModel):
"""Lightweight tag info for embedding in package responses"""
name: str
artifact_id: str
created_at: datetime
class PackageDetailResponse(BaseModel):
"""Package with aggregated metadata"""
id: UUID
project_id: UUID
name: str
description: Optional[str]
format: str
platform: str
created_at: datetime
updated_at: datetime
# Aggregated fields
tag_count: int = 0
artifact_count: int = 0
total_size: int = 0
latest_tag: Optional[str] = None
latest_upload_at: Optional[datetime] = None
# Recent tags (limit 5)
recent_tags: List[TagSummary] = []
class Config:
from_attributes = True
# Artifact schemas # Artifact schemas
class ArtifactResponse(BaseModel): class ArtifactResponse(BaseModel):
id: str id: str
@@ -51,6 +105,7 @@ class ArtifactResponse(BaseModel):
created_at: datetime created_at: datetime
created_by: str created_by: str
ref_count: int ref_count: int
format_metadata: Optional[Dict[str, Any]] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -74,6 +129,78 @@ class TagResponse(BaseModel):
from_attributes = True from_attributes = True
class TagDetailResponse(BaseModel):
"""Tag with embedded artifact metadata"""
id: UUID
package_id: UUID
name: str
artifact_id: str
created_at: datetime
created_by: str
# Artifact metadata
artifact_size: int
artifact_content_type: Optional[str]
artifact_original_name: Optional[str]
artifact_created_at: datetime
artifact_format_metadata: Optional[Dict[str, Any]] = None
class Config:
from_attributes = True
class TagHistoryResponse(BaseModel):
"""History entry for tag changes"""
id: UUID
tag_id: UUID
old_artifact_id: Optional[str]
new_artifact_id: str
changed_at: datetime
changed_by: str
class Config:
from_attributes = True
class ArtifactTagInfo(BaseModel):
"""Tag info for embedding in artifact responses"""
id: UUID
name: str
package_id: UUID
package_name: str
project_name: str
class ArtifactDetailResponse(BaseModel):
"""Artifact with list of tags/packages referencing it"""
id: str
size: int
content_type: Optional[str]
original_name: Optional[str]
created_at: datetime
created_by: str
ref_count: int
format_metadata: Optional[Dict[str, Any]] = None
tags: List[ArtifactTagInfo] = []
class Config:
from_attributes = True
class PackageArtifactResponse(BaseModel):
"""Artifact with tags for package artifact listing"""
id: str
size: int
content_type: Optional[str]
original_name: Optional[str]
created_at: datetime
created_by: str
format_metadata: Optional[Dict[str, Any]] = None
tags: List[str] = [] # Tag names pointing to this artifact
class Config:
from_attributes = True
# Upload response # Upload response
class UploadResponse(BaseModel): class UploadResponse(BaseModel):
artifact_id: str artifact_id: str
@@ -81,6 +208,53 @@ class UploadResponse(BaseModel):
project: str project: str
package: str package: str
tag: Optional[str] tag: Optional[str]
format_metadata: Optional[Dict[str, Any]] = None
deduplicated: bool = False
# Resumable upload schemas
class ResumableUploadInitRequest(BaseModel):
"""Request to initiate a resumable upload"""
expected_hash: str # SHA256 hash of the file (client must compute)
filename: str
content_type: Optional[str] = None
size: int
tag: Optional[str] = None
class ResumableUploadInitResponse(BaseModel):
"""Response from initiating a resumable upload"""
upload_id: Optional[str] # None if file already exists
already_exists: bool
artifact_id: Optional[str] = None # Set if already_exists is True
chunk_size: int # Recommended chunk size for parts
class ResumableUploadPartResponse(BaseModel):
"""Response from uploading a part"""
part_number: int
etag: str
class ResumableUploadCompleteRequest(BaseModel):
"""Request to complete a resumable upload"""
tag: Optional[str] = None
class ResumableUploadCompleteResponse(BaseModel):
"""Response from completing a resumable upload"""
artifact_id: str
size: int
project: str
package: str
tag: Optional[str]
class ResumableUploadStatusResponse(BaseModel):
"""Status of a resumable upload"""
upload_id: str
uploaded_parts: List[int]
total_uploaded_bytes: int
# Consumer schemas # Consumer schemas

222
backend/app/seed.py Normal file
View File

@@ -0,0 +1,222 @@
"""
Test data seeding for development environment.
"""
import hashlib
import logging
from sqlalchemy.orm import Session
from .models import Project, Package, Artifact, Tag, Upload
from .storage import get_storage
logger = logging.getLogger(__name__)
# Test data definitions
TEST_PROJECTS = [
{
"name": "frontend-libs",
"description": "Shared frontend libraries and components",
"is_public": True,
"packages": [
{
"name": "ui-components",
"description": "Reusable UI component library",
},
{
"name": "design-tokens",
"description": "Design system tokens and variables",
},
],
},
{
"name": "backend-services",
"description": "Backend microservices and shared utilities",
"is_public": True,
"packages": [
{
"name": "auth-lib",
"description": "Authentication and authorization library",
},
{
"name": "common-utils",
"description": "Common utility functions",
},
{
"name": "api-client",
"description": "Generated API client library",
},
],
},
{
"name": "mobile-apps",
"description": "Mobile application builds and assets",
"is_public": True,
"packages": [
{
"name": "ios-release",
"description": "iOS release builds",
},
{
"name": "android-release",
"description": "Android release builds",
},
],
},
{
"name": "internal-tools",
"description": "Internal development tools (private)",
"is_public": False,
"packages": [
{
"name": "dev-scripts",
"description": "Development automation scripts",
},
],
},
]
# Sample artifacts to create (content, tags)
TEST_ARTIFACTS = [
{
"project": "frontend-libs",
"package": "ui-components",
"content": b"/* UI Components v1.0.0 */\nexport const Button = () => {};\nexport const Input = () => {};\n",
"filename": "ui-components-1.0.0.js",
"content_type": "application/javascript",
"tags": ["v1.0.0", "latest"],
},
{
"project": "frontend-libs",
"package": "ui-components",
"content": b"/* UI Components v1.1.0 */\nexport const Button = () => {};\nexport const Input = () => {};\nexport const Modal = () => {};\n",
"filename": "ui-components-1.1.0.js",
"content_type": "application/javascript",
"tags": ["v1.1.0"],
},
{
"project": "frontend-libs",
"package": "design-tokens",
"content": b'{"colors": {"primary": "#007bff", "secondary": "#6c757d"}, "spacing": {"sm": "8px", "md": "16px"}}',
"filename": "tokens.json",
"content_type": "application/json",
"tags": ["v1.0.0", "latest"],
},
{
"project": "backend-services",
"package": "common-utils",
"content": b"# Common Utils\n\ndef format_date(dt):\n return dt.isoformat()\n\ndef slugify(text):\n return text.lower().replace(' ', '-')\n",
"filename": "utils-2.0.0.py",
"content_type": "text/x-python",
"tags": ["v2.0.0", "stable", "latest"],
},
{
"project": "backend-services",
"package": "auth-lib",
"content": b"package auth\n\nfunc ValidateToken(token string) bool {\n return len(token) > 0\n}\n",
"filename": "auth-lib-1.0.0.go",
"content_type": "text/x-go",
"tags": ["v1.0.0", "latest"],
},
]
def is_database_empty(db: Session) -> bool:
"""Check if the database has any projects."""
return db.query(Project).first() is None
def seed_database(db: Session) -> None:
"""Seed the database with test data."""
if not is_database_empty(db):
logger.info("Database already has data, skipping seed")
return
logger.info("Seeding database with test data...")
storage = get_storage()
# Create projects and packages
project_map = {}
package_map = {}
for project_data in TEST_PROJECTS:
project = Project(
name=project_data["name"],
description=project_data["description"],
is_public=project_data["is_public"],
created_by="seed-user",
)
db.add(project)
db.flush() # Get the ID
project_map[project_data["name"]] = project
for package_data in project_data["packages"]:
package = Package(
project_id=project.id,
name=package_data["name"],
description=package_data["description"],
)
db.add(package)
db.flush()
package_map[(project_data["name"], package_data["name"])] = package
logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages")
# Create artifacts and tags
artifact_count = 0
tag_count = 0
for artifact_data in TEST_ARTIFACTS:
project = project_map[artifact_data["project"]]
package = package_map[(artifact_data["project"], artifact_data["package"])]
content = artifact_data["content"]
sha256_hash = hashlib.sha256(content).hexdigest()
size = len(content)
s3_key = f"fruits/{sha256_hash[:2]}/{sha256_hash[2:4]}/{sha256_hash}"
# Store in S3
try:
storage.client.put_object(
Bucket=storage.bucket,
Key=s3_key,
Body=content,
)
except Exception as e:
logger.warning(f"Failed to store artifact in S3: {e}")
continue
# Create artifact record
artifact = Artifact(
id=sha256_hash,
size=size,
content_type=artifact_data["content_type"],
original_name=artifact_data["filename"],
created_by="seed-user",
s3_key=s3_key,
ref_count=len(artifact_data["tags"]),
)
db.add(artifact)
# Create upload record
upload = Upload(
artifact_id=sha256_hash,
package_id=package.id,
original_name=artifact_data["filename"],
uploaded_by="seed-user",
)
db.add(upload)
artifact_count += 1
# Create tags
for tag_name in artifact_data["tags"]:
tag = Tag(
package_id=package.id,
name=tag_name,
artifact_id=sha256_hash,
created_by="seed-user",
)
db.add(tag)
tag_count += 1
db.commit()
logger.info(f"Created {artifact_count} artifacts and {tag_count} tags")
logger.info("Database seeding complete")

View File

@@ -1,5 +1,6 @@
import hashlib import hashlib
from typing import BinaryIO, Tuple import logging
from typing import BinaryIO, Tuple, Optional, Dict, Any, Generator
import boto3 import boto3
from botocore.config import Config from botocore.config import Config
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
@@ -7,6 +8,14 @@ from botocore.exceptions import ClientError
from .config import get_settings from .config import get_settings
settings = get_settings() settings = get_settings()
logger = logging.getLogger(__name__)
# Threshold for multipart upload (100MB)
MULTIPART_THRESHOLD = 100 * 1024 * 1024
# Chunk size for multipart upload (10MB)
MULTIPART_CHUNK_SIZE = 10 * 1024 * 1024
# Chunk size for streaming hash computation
HASH_CHUNK_SIZE = 8 * 1024 * 1024
class S3Storage: class S3Storage:
@@ -22,12 +31,23 @@ class S3Storage:
config=config, config=config,
) )
self.bucket = settings.s3_bucket self.bucket = settings.s3_bucket
# Store active multipart uploads for resumable support
self._active_uploads: Dict[str, Dict[str, Any]] = {}
def store(self, file: BinaryIO) -> Tuple[str, int]: def store(self, file: BinaryIO, content_length: Optional[int] = None) -> Tuple[str, int, str]:
""" """
Store a file and return its SHA256 hash and size. Store a file and return its SHA256 hash, size, and s3_key.
Content-addressable: if the file already exists, just return the hash. Content-addressable: if the file already exists, just return the hash.
Uses multipart upload for files larger than MULTIPART_THRESHOLD.
""" """
# For small files or unknown size, use the simple approach
if content_length is None or content_length < MULTIPART_THRESHOLD:
return self._store_simple(file)
else:
return self._store_multipart(file, content_length)
def _store_simple(self, file: BinaryIO) -> Tuple[str, int, str]:
"""Store a small file using simple put_object"""
# Read file and compute hash # Read file and compute hash
content = file.read() content = file.read()
sha256_hash = hashlib.sha256(content).hexdigest() sha256_hash = hashlib.sha256(content).hexdigest()
@@ -45,15 +65,300 @@ class S3Storage:
return sha256_hash, size, s3_key return sha256_hash, size, s3_key
def _store_multipart(self, file: BinaryIO, content_length: int) -> Tuple[str, int, str]:
"""Store a large file using S3 multipart upload with streaming hash computation"""
# First pass: compute hash by streaming through file
hasher = hashlib.sha256()
size = 0
# Read file in chunks to compute hash
while True:
chunk = file.read(HASH_CHUNK_SIZE)
if not chunk:
break
hasher.update(chunk)
size += len(chunk)
sha256_hash = hasher.hexdigest()
s3_key = f"fruits/{sha256_hash[:2]}/{sha256_hash[2:4]}/{sha256_hash}"
# Check if already exists (deduplication)
if self._exists(s3_key):
return sha256_hash, size, s3_key
# Seek back to start for upload
file.seek(0)
# Start multipart upload
mpu = self.client.create_multipart_upload(Bucket=self.bucket, Key=s3_key)
upload_id = mpu["UploadId"]
try:
parts = []
part_number = 1
while True:
chunk = file.read(MULTIPART_CHUNK_SIZE)
if not chunk:
break
response = self.client.upload_part(
Bucket=self.bucket,
Key=s3_key,
UploadId=upload_id,
PartNumber=part_number,
Body=chunk,
)
parts.append({
"PartNumber": part_number,
"ETag": response["ETag"],
})
part_number += 1
# Complete multipart upload
self.client.complete_multipart_upload(
Bucket=self.bucket,
Key=s3_key,
UploadId=upload_id,
MultipartUpload={"Parts": parts},
)
return sha256_hash, size, s3_key
except Exception as e:
# Abort multipart upload on failure
logger.error(f"Multipart upload failed: {e}")
self.client.abort_multipart_upload(
Bucket=self.bucket,
Key=s3_key,
UploadId=upload_id,
)
raise
def store_streaming(self, chunks: Generator[bytes, None, None]) -> Tuple[str, int, str]:
"""
Store a file from a stream of chunks.
First accumulates to compute hash, then uploads.
For truly large files, consider using initiate_resumable_upload instead.
"""
# Accumulate chunks and compute hash
hasher = hashlib.sha256()
all_chunks = []
size = 0
for chunk in chunks:
hasher.update(chunk)
all_chunks.append(chunk)
size += len(chunk)
sha256_hash = hasher.hexdigest()
s3_key = f"fruits/{sha256_hash[:2]}/{sha256_hash[2:4]}/{sha256_hash}"
# Check if already exists
if self._exists(s3_key):
return sha256_hash, size, s3_key
# Upload based on size
if size < MULTIPART_THRESHOLD:
content = b"".join(all_chunks)
self.client.put_object(Bucket=self.bucket, Key=s3_key, Body=content)
else:
# Use multipart for large files
mpu = self.client.create_multipart_upload(Bucket=self.bucket, Key=s3_key)
upload_id = mpu["UploadId"]
try:
parts = []
part_number = 1
buffer = b""
for chunk in all_chunks:
buffer += chunk
while len(buffer) >= MULTIPART_CHUNK_SIZE:
part_data = buffer[:MULTIPART_CHUNK_SIZE]
buffer = buffer[MULTIPART_CHUNK_SIZE:]
response = self.client.upload_part(
Bucket=self.bucket,
Key=s3_key,
UploadId=upload_id,
PartNumber=part_number,
Body=part_data,
)
parts.append({
"PartNumber": part_number,
"ETag": response["ETag"],
})
part_number += 1
# Upload remaining buffer
if buffer:
response = self.client.upload_part(
Bucket=self.bucket,
Key=s3_key,
UploadId=upload_id,
PartNumber=part_number,
Body=buffer,
)
parts.append({
"PartNumber": part_number,
"ETag": response["ETag"],
})
self.client.complete_multipart_upload(
Bucket=self.bucket,
Key=s3_key,
UploadId=upload_id,
MultipartUpload={"Parts": parts},
)
except Exception as e:
logger.error(f"Streaming multipart upload failed: {e}")
self.client.abort_multipart_upload(
Bucket=self.bucket,
Key=s3_key,
UploadId=upload_id,
)
raise
return sha256_hash, size, s3_key
def initiate_resumable_upload(self, expected_hash: str) -> Dict[str, Any]:
"""
Initiate a resumable upload session.
Returns upload session info including upload_id.
"""
s3_key = f"fruits/{expected_hash[:2]}/{expected_hash[2:4]}/{expected_hash}"
# Check if already exists
if self._exists(s3_key):
return {
"upload_id": None,
"s3_key": s3_key,
"already_exists": True,
"parts": [],
}
mpu = self.client.create_multipart_upload(Bucket=self.bucket, Key=s3_key)
upload_id = mpu["UploadId"]
session = {
"upload_id": upload_id,
"s3_key": s3_key,
"already_exists": False,
"parts": [],
"expected_hash": expected_hash,
}
self._active_uploads[upload_id] = session
return session
def upload_part(self, upload_id: str, part_number: int, data: bytes) -> Dict[str, Any]:
"""
Upload a part for a resumable upload.
Returns part info including ETag.
"""
session = self._active_uploads.get(upload_id)
if not session:
raise ValueError(f"Unknown upload session: {upload_id}")
response = self.client.upload_part(
Bucket=self.bucket,
Key=session["s3_key"],
UploadId=upload_id,
PartNumber=part_number,
Body=data,
)
part_info = {
"PartNumber": part_number,
"ETag": response["ETag"],
}
session["parts"].append(part_info)
return part_info
def complete_resumable_upload(self, upload_id: str) -> Tuple[str, str]:
"""
Complete a resumable upload.
Returns (sha256_hash, s3_key).
"""
session = self._active_uploads.get(upload_id)
if not session:
raise ValueError(f"Unknown upload session: {upload_id}")
# Sort parts by part number
sorted_parts = sorted(session["parts"], key=lambda x: x["PartNumber"])
self.client.complete_multipart_upload(
Bucket=self.bucket,
Key=session["s3_key"],
UploadId=upload_id,
MultipartUpload={"Parts": sorted_parts},
)
# Clean up session
del self._active_uploads[upload_id]
return session["expected_hash"], session["s3_key"]
def abort_resumable_upload(self, upload_id: str):
"""Abort a resumable upload"""
session = self._active_uploads.get(upload_id)
if session:
self.client.abort_multipart_upload(
Bucket=self.bucket,
Key=session["s3_key"],
UploadId=upload_id,
)
del self._active_uploads[upload_id]
def list_upload_parts(self, upload_id: str) -> list:
"""List uploaded parts for a resumable upload (for resume support)"""
session = self._active_uploads.get(upload_id)
if not session:
raise ValueError(f"Unknown upload session: {upload_id}")
response = self.client.list_parts(
Bucket=self.bucket,
Key=session["s3_key"],
UploadId=upload_id,
)
return response.get("Parts", [])
def get(self, s3_key: str) -> bytes: def get(self, s3_key: str) -> bytes:
"""Retrieve a file by its S3 key""" """Retrieve a file by its S3 key"""
response = self.client.get_object(Bucket=self.bucket, Key=s3_key) response = self.client.get_object(Bucket=self.bucket, Key=s3_key)
return response["Body"].read() return response["Body"].read()
def get_stream(self, s3_key: str): def get_stream(self, s3_key: str, range_header: Optional[str] = None):
"""Get a streaming response for a file""" """
response = self.client.get_object(Bucket=self.bucket, Key=s3_key) Get a streaming response for a file.
return response["Body"] Supports range requests for partial downloads.
Returns (stream, content_length, content_range, accept_ranges)
"""
kwargs = {"Bucket": self.bucket, "Key": s3_key}
if range_header:
kwargs["Range"] = range_header
response = self.client.get_object(**kwargs)
content_length = response.get("ContentLength", 0)
content_range = response.get("ContentRange")
return response["Body"], content_length, content_range
def get_object_info(self, s3_key: str) -> Dict[str, Any]:
"""Get object metadata without downloading content"""
try:
response = self.client.head_object(Bucket=self.bucket, Key=s3_key)
return {
"size": response.get("ContentLength", 0),
"content_type": response.get("ContentType"),
"last_modified": response.get("LastModified"),
"etag": response.get("ETag"),
}
except ClientError:
return None
def _exists(self, s3_key: str) -> bool: def _exists(self, s3_key: str) -> bool:
"""Check if an object exists""" """Check if an object exists"""

View File

@@ -1,4 +1,17 @@
import { Project, Package, Tag, Artifact, UploadResponse } from './types'; import {
Project,
Package,
Tag,
TagDetail,
Artifact,
ArtifactDetail,
UploadResponse,
PaginatedResponse,
ListParams,
TagListParams,
PackageListParams,
ArtifactListParams,
} from './types';
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
@@ -10,10 +23,27 @@ async function handleResponse<T>(response: Response): Promise<T> {
return response.json(); return response.json();
} }
function buildQueryString(params: Record<string, unknown>): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, String(value));
}
});
const query = searchParams.toString();
return query ? `?${query}` : '';
}
// Project API // Project API
export async function listProjects(): Promise<Project[]> { export async function listProjects(params: ListParams = {}): Promise<PaginatedResponse<Project>> {
const response = await fetch(`${API_BASE}/projects`); const query = buildQueryString(params as Record<string, unknown>);
return handleResponse<Project[]>(response); const response = await fetch(`${API_BASE}/projects${query}`);
return handleResponse<PaginatedResponse<Project>>(response);
}
export async function listProjectsSimple(params: ListParams = {}): Promise<Project[]> {
const data = await listProjects(params);
return data.items;
} }
export async function createProject(data: { name: string; description?: string; is_public?: boolean }): Promise<Project> { export async function createProject(data: { name: string; description?: string; is_public?: boolean }): Promise<Project> {
@@ -31,9 +61,20 @@ export async function getProject(name: string): Promise<Project> {
} }
// Package API // Package API
export async function listPackages(projectName: string): Promise<Package[]> { export async function listPackages(projectName: string, params: PackageListParams = {}): Promise<PaginatedResponse<Package>> {
const response = await fetch(`${API_BASE}/project/${projectName}/packages`); const query = buildQueryString(params as Record<string, unknown>);
return handleResponse<Package[]>(response); const response = await fetch(`${API_BASE}/project/${projectName}/packages${query}`);
return handleResponse<PaginatedResponse<Package>>(response);
}
export async function listPackagesSimple(projectName: string, params: PackageListParams = {}): Promise<Package[]> {
const data = await listPackages(projectName, params);
return data.items;
}
export async function getPackage(projectName: string, packageName: string): Promise<Package> {
const response = await fetch(`${API_BASE}/project/${projectName}/packages/${packageName}`);
return handleResponse<Package>(response);
} }
export async function createPackage(projectName: string, data: { name: string; description?: string }): Promise<Package> { export async function createPackage(projectName: string, data: { name: string; description?: string }): Promise<Package> {
@@ -46,9 +87,20 @@ export async function createPackage(projectName: string, data: { name: string; d
} }
// Tag API // Tag API
export async function listTags(projectName: string, packageName: string): Promise<Tag[]> { export async function listTags(projectName: string, packageName: string, params: TagListParams = {}): Promise<PaginatedResponse<TagDetail>> {
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/tags`); const query = buildQueryString(params as Record<string, unknown>);
return handleResponse<Tag[]>(response); const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/tags${query}`);
return handleResponse<PaginatedResponse<TagDetail>>(response);
}
export async function listTagsSimple(projectName: string, packageName: string, params: TagListParams = {}): Promise<TagDetail[]> {
const data = await listTags(projectName, packageName, params);
return data.items;
}
export async function getTag(projectName: string, packageName: string, tagName: string): Promise<TagDetail> {
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/tags/${tagName}`);
return handleResponse<TagDetail>(response);
} }
export async function createTag(projectName: string, packageName: string, data: { name: string; artifact_id: string }): Promise<Tag> { export async function createTag(projectName: string, packageName: string, data: { name: string; artifact_id: string }): Promise<Tag> {
@@ -61,9 +113,19 @@ export async function createTag(projectName: string, packageName: string, data:
} }
// Artifact API // Artifact API
export async function getArtifact(artifactId: string): Promise<Artifact> { export async function getArtifact(artifactId: string): Promise<ArtifactDetail> {
const response = await fetch(`${API_BASE}/artifact/${artifactId}`); const response = await fetch(`${API_BASE}/artifact/${artifactId}`);
return handleResponse<Artifact>(response); return handleResponse<ArtifactDetail>(response);
}
export async function listPackageArtifacts(
projectName: string,
packageName: string,
params: ArtifactListParams = {}
): Promise<PaginatedResponse<Artifact & { tags: string[] }>> {
const query = buildQueryString(params as Record<string, unknown>);
const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/artifacts${query}`);
return handleResponse<PaginatedResponse<Artifact & { tags: string[] }>>(response);
} }
// Upload // Upload

View File

@@ -0,0 +1,43 @@
/* Badge Component */
.badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 100px;
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.badge--default {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-primary);
}
.badge--success,
.badge--public {
background: var(--success-bg);
color: var(--success);
border: 1px solid rgba(34, 197, 94, 0.2);
}
.badge--warning,
.badge--private {
background: var(--warning-bg);
color: var(--warning);
border: 1px solid rgba(245, 158, 11, 0.2);
}
.badge--error {
background: var(--error-bg);
color: var(--error);
border: 1px solid rgba(239, 68, 68, 0.2);
}
.badge--info {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border: 1px solid rgba(59, 130, 246, 0.2);
}

View File

@@ -0,0 +1,17 @@
import './Badge.css';
type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'public' | 'private';
interface BadgeProps {
children: React.ReactNode;
variant?: BadgeVariant;
className?: string;
}
export function Badge({ children, variant = 'default', className = '' }: BadgeProps) {
return (
<span className={`badge badge--${variant} ${className}`.trim()}>
{children}
</span>
);
}

View File

@@ -0,0 +1,38 @@
/* Breadcrumb Component */
.breadcrumb {
margin-bottom: 24px;
}
.breadcrumb__list {
display: flex;
align-items: center;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;
font-size: 0.875rem;
}
.breadcrumb__item {
display: flex;
align-items: center;
gap: 8px;
}
.breadcrumb__link {
color: var(--text-secondary);
transition: color var(--transition-fast);
}
.breadcrumb__link:hover {
color: var(--accent-primary);
}
.breadcrumb__separator {
color: var(--text-muted);
}
.breadcrumb__current {
color: var(--text-primary);
font-weight: 500;
}

View File

@@ -0,0 +1,38 @@
import { Link } from 'react-router-dom';
import './Breadcrumb.css';
interface BreadcrumbItem {
label: string;
href?: string;
}
interface BreadcrumbProps {
items: BreadcrumbItem[];
className?: string;
}
export function Breadcrumb({ items, className = '' }: BreadcrumbProps) {
return (
<nav className={`breadcrumb ${className}`.trim()} aria-label="Breadcrumb">
<ol className="breadcrumb__list">
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li key={index} className="breadcrumb__item">
{!isLast && item.href ? (
<>
<Link to={item.href} className="breadcrumb__link">
{item.label}
</Link>
<span className="breadcrumb__separator">/</span>
</>
) : (
<span className="breadcrumb__current">{item.label}</span>
)}
</li>
);
})}
</ol>
</nav>
);
}

View File

@@ -0,0 +1,78 @@
/* Card Component */
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 24px;
transition: all var(--transition-normal);
}
.card--elevated {
box-shadow: var(--shadow-md);
}
.card--accent {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.05) 0%, rgba(5, 150, 105, 0.05) 100%);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.card--clickable {
display: block;
color: inherit;
position: relative;
overflow: hidden;
cursor: pointer;
}
.card--clickable::before {
content: '';
position: absolute;
inset: 0;
background: var(--accent-gradient);
opacity: 0;
transition: opacity var(--transition-normal);
border-radius: var(--radius-lg);
}
.card--clickable:hover {
border-color: var(--border-secondary);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
color: inherit;
}
.card--clickable:hover::before {
opacity: 0.03;
}
.card__header {
margin-bottom: 16px;
}
.card__header h3 {
color: var(--text-primary);
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 4px;
}
.card__header p {
color: var(--text-secondary);
font-size: 0.875rem;
line-height: 1.5;
}
.card__body {
color: var(--text-secondary);
font-size: 0.875rem;
}
.card__footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
padding-top: 16px;
border-top: 1px solid var(--border-primary);
margin-top: 16px;
}

View File

@@ -0,0 +1,59 @@
import { ReactNode } from 'react';
import './Card.css';
interface CardProps {
children: ReactNode;
className?: string;
onClick?: () => void;
href?: string;
variant?: 'default' | 'elevated' | 'accent';
}
export function Card({ children, className = '', onClick, href, variant = 'default' }: CardProps) {
const baseClass = `card card--${variant} ${className}`.trim();
if (href) {
return (
<a href={href} className={`${baseClass} card--clickable`}>
{children}
</a>
);
}
if (onClick) {
return (
<div className={`${baseClass} card--clickable`} onClick={onClick} role="button" tabIndex={0}>
{children}
</div>
);
}
return <div className={baseClass}>{children}</div>;
}
interface CardHeaderProps {
children: ReactNode;
className?: string;
}
export function CardHeader({ children, className = '' }: CardHeaderProps) {
return <div className={`card__header ${className}`.trim()}>{children}</div>;
}
interface CardBodyProps {
children: ReactNode;
className?: string;
}
export function CardBody({ children, className = '' }: CardBodyProps) {
return <div className={`card__body ${className}`.trim()}>{children}</div>;
}
interface CardFooterProps {
children: ReactNode;
className?: string;
}
export function CardFooter({ children, className = '' }: CardFooterProps) {
return <div className={`card__footer ${className}`.trim()}>{children}</div>;
}

View File

@@ -0,0 +1,100 @@
/* DataTable Component */
.data-table {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
.data-table table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 14px 20px;
text-align: left;
border-bottom: 1px solid var(--border-primary);
}
.data-table th {
background: var(--bg-tertiary);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
}
.data-table__th--sortable {
cursor: pointer;
user-select: none;
transition: color var(--transition-fast);
}
.data-table__th--sortable:hover {
color: var(--text-primary);
}
.data-table__th-content {
display: flex;
align-items: center;
gap: 6px;
}
.data-table__sort-icon {
transition: transform var(--transition-fast);
}
.data-table__sort-icon--desc {
transform: rotate(180deg);
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.data-table tbody tr {
transition: background var(--transition-fast);
}
.data-table tbody tr:hover {
background: var(--bg-tertiary);
}
.data-table td strong {
color: var(--accent-primary);
font-weight: 600;
}
/* Empty state */
.data-table__empty {
text-align: center;
padding: 48px 32px;
color: var(--text-tertiary);
background: var(--bg-secondary);
border: 1px dashed var(--border-secondary);
border-radius: var(--radius-lg);
}
.data-table__empty p {
font-size: 0.9375rem;
}
/* Utility classes for cells */
.data-table .cell-mono {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem;
color: var(--text-tertiary);
background: var(--bg-tertiary);
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.data-table .cell-truncate {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,86 @@
import { ReactNode } from 'react';
import './DataTable.css';
interface Column<T> {
key: string;
header: string;
render: (item: T) => ReactNode;
className?: string;
sortable?: boolean;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
keyExtractor: (item: T) => string;
emptyMessage?: string;
className?: string;
onSort?: (key: string) => void;
sortKey?: string;
sortOrder?: 'asc' | 'desc';
}
export function DataTable<T>({
data,
columns,
keyExtractor,
emptyMessage = 'No data available',
className = '',
onSort,
sortKey,
sortOrder,
}: DataTableProps<T>) {
if (data.length === 0) {
return (
<div className="data-table__empty">
<p>{emptyMessage}</p>
</div>
);
}
return (
<div className={`data-table ${className}`.trim()}>
<table>
<thead>
<tr>
{columns.map((column) => (
<th
key={column.key}
className={`${column.className || ''} ${column.sortable ? 'data-table__th--sortable' : ''}`}
onClick={() => column.sortable && onSort?.(column.key)}
>
<span className="data-table__th-content">
{column.header}
{column.sortable && sortKey === column.key && (
<svg
className={`data-table__sort-icon ${sortOrder === 'desc' ? 'data-table__sort-icon--desc' : ''}`}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="18 15 12 9 6 15" />
</svg>
)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{data.map((item) => (
<tr key={keyExtractor(item)}>
{columns.map((column) => (
<td key={column.key} className={column.className}>
{column.render(item)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,63 @@
/* FilterChip Component */
.filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px 4px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 100px;
font-size: 0.75rem;
}
.filter-chip__label {
color: var(--text-muted);
}
.filter-chip__value {
color: var(--text-primary);
font-weight: 500;
}
.filter-chip__remove {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
background: transparent;
border: none;
border-radius: 50%;
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.filter-chip__remove:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* FilterChipGroup */
.filter-chip-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.filter-chip-group__clear {
padding: 4px 10px;
background: transparent;
border: none;
font-size: 0.75rem;
color: var(--text-muted);
cursor: pointer;
transition: color var(--transition-fast);
}
.filter-chip-group__clear:hover {
color: var(--error);
}

View File

@@ -0,0 +1,47 @@
import './FilterChip.css';
interface FilterChipProps {
label: string;
value: string;
onRemove: () => void;
className?: string;
}
export function FilterChip({ label, value, onRemove, className = '' }: FilterChipProps) {
return (
<span className={`filter-chip ${className}`.trim()}>
<span className="filter-chip__label">{label}:</span>
<span className="filter-chip__value">{value}</span>
<button
type="button"
className="filter-chip__remove"
onClick={onRemove}
aria-label={`Remove ${label} filter`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</span>
);
}
interface FilterChipGroupProps {
children: React.ReactNode;
onClearAll?: () => void;
className?: string;
}
export function FilterChipGroup({ children, onClearAll, className = '' }: FilterChipGroupProps) {
return (
<div className={`filter-chip-group ${className}`.trim()}>
{children}
{onClearAll && (
<button type="button" className="filter-chip-group__clear" onClick={onClearAll}>
Clear all
</button>
)}
</div>
);
}

View File

@@ -16,18 +16,18 @@ function Layout({ children }: LayoutProps) {
<Link to="/" className="logo"> <Link to="/" className="logo">
<div className="logo-icon"> <div className="logo-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Three trees representing an orchard */} {/* Three fruit trees representing an orchard */}
{/* Left tree */} {/* Left tree - rounded canopy */}
<ellipse cx="6" cy="9" rx="4" ry="5" fill="currentColor" opacity="0.7"/> <path d="M6 14 Q6 8 3 8 Q6 4 6 4 Q6 4 9 8 Q6 8 6 14" fill="currentColor" opacity="0.6"/>
<rect x="5" y="13" width="2" height="5" fill="currentColor"/> <rect x="5.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
{/* Center tree (larger) */} {/* Center tree - larger rounded canopy */}
<ellipse cx="12" cy="7" rx="5" ry="6" fill="currentColor"/> <path d="M12 12 Q12 5 8 5 Q12 1 12 1 Q12 1 16 5 Q12 5 12 12" fill="currentColor"/>
<rect x="11" y="12" width="2" height="6" fill="currentColor"/> <rect x="11.25" y="11" width="1.5" height="5" fill="currentColor"/>
{/* Right tree */} {/* Right tree - rounded canopy */}
<ellipse cx="18" cy="9" rx="4" ry="5" fill="currentColor" opacity="0.7"/> <path d="M18 14 Q18 8 15 8 Q18 4 18 4 Q18 4 21 8 Q18 8 18 14" fill="currentColor" opacity="0.6"/>
<rect x="17" y="13" width="2" height="5" fill="currentColor"/> <rect x="17.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
{/* Ground line */} {/* Ground */}
<line x1="2" y1="18" x2="22" y2="18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/> <ellipse cx="12" cy="19" rx="9" ry="1.5" fill="currentColor" opacity="0.3"/>
</svg> </svg>
</div> </div>
<span className="logo-text">Orchard</span> <span className="logo-text">Orchard</span>

View File

@@ -0,0 +1,64 @@
/* Pagination Component */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
margin-top: 16px;
}
.pagination__info {
font-size: 0.8125rem;
color: var(--text-muted);
}
.pagination__controls {
display: flex;
align-items: center;
gap: 4px;
}
.pagination__btn {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.8125rem;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.pagination__btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-secondary);
}
.pagination__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pagination__page--active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.pagination__page--active:hover {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.pagination__ellipsis {
padding: 0 8px;
color: var(--text-muted);
font-size: 0.8125rem;
}

View File

@@ -0,0 +1,98 @@
import './Pagination.css';
interface PaginationProps {
page: number;
totalPages: number;
total: number;
limit: number;
onPageChange: (page: number) => void;
className?: string;
}
export function Pagination({ page, totalPages, total, limit, onPageChange, className = '' }: PaginationProps) {
const start = (page - 1) * limit + 1;
const end = Math.min(page * limit, total);
if (totalPages <= 1) {
return null;
}
const getPageNumbers = (): (number | 'ellipsis')[] => {
const pages: (number | 'ellipsis')[] = [];
const showEllipsisStart = page > 3;
const showEllipsisEnd = page < totalPages - 2;
pages.push(1);
if (showEllipsisStart) {
pages.push('ellipsis');
}
for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
if (!pages.includes(i)) {
pages.push(i);
}
}
if (showEllipsisEnd) {
pages.push('ellipsis');
}
if (totalPages > 1 && !pages.includes(totalPages)) {
pages.push(totalPages);
}
return pages;
};
return (
<div className={`pagination ${className}`.trim()}>
<span className="pagination__info">
Showing {start}-{end} of {total}
</span>
<div className="pagination__controls">
<button
type="button"
className="pagination__btn"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
aria-label="Previous page"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
{getPageNumbers().map((pageNum, index) =>
pageNum === 'ellipsis' ? (
<span key={`ellipsis-${index}`} className="pagination__ellipsis">
...
</span>
) : (
<button
key={pageNum}
type="button"
className={`pagination__btn pagination__page ${pageNum === page ? 'pagination__page--active' : ''}`}
onClick={() => onPageChange(pageNum)}
>
{pageNum}
</button>
)
)}
<button
type="button"
className="pagination__btn"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
aria-label="Next page"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
/* SearchInput Component */
.search-input {
position: relative;
display: flex;
align-items: center;
}
.search-input__icon {
position: absolute;
left: 12px;
color: var(--text-muted);
pointer-events: none;
}
.search-input__field {
width: 100%;
padding: 10px 36px 10px 40px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
color: var(--text-primary);
transition: all var(--transition-fast);
}
.search-input__field::placeholder {
color: var(--text-muted);
}
.search-input__field:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
background: var(--bg-elevated);
}
.search-input__clear {
position: absolute;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.search-input__clear:hover {
background: var(--bg-hover);
color: var(--text-primary);
}

View File

@@ -0,0 +1,74 @@
import { useState, useEffect } from 'react';
import './SearchInput.css';
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
debounceMs?: number;
className?: string;
}
export function SearchInput({
value,
onChange,
placeholder = 'Search...',
debounceMs = 300,
className = '',
}: SearchInputProps) {
const [localValue, setLocalValue] = useState(value);
useEffect(() => {
setLocalValue(value);
}, [value]);
useEffect(() => {
const timer = setTimeout(() => {
if (localValue !== value) {
onChange(localValue);
}
}, debounceMs);
return () => clearTimeout(timer);
}, [localValue, debounceMs, onChange, value]);
return (
<div className={`search-input ${className}`.trim()}>
<svg
className="search-input__icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
placeholder={placeholder}
className="search-input__field"
/>
{localValue && (
<button
type="button"
className="search-input__clear"
onClick={() => {
setLocalValue('');
onChange('');
}}
aria-label="Clear search"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
/* SortDropdown Component */
.sort-dropdown {
display: flex;
align-items: center;
gap: 4px;
position: relative;
}
.sort-dropdown__trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.8125rem;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.sort-dropdown__trigger:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-secondary);
}
.sort-dropdown__chevron {
transition: transform var(--transition-fast);
}
.sort-dropdown__chevron--open {
transform: rotate(180deg);
}
.sort-dropdown__order {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.sort-dropdown__order:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-secondary);
}
.sort-dropdown__menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 180px;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
overflow: hidden;
}
.sort-dropdown__option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 14px;
background: transparent;
border: none;
font-size: 0.8125rem;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
}
.sort-dropdown__option:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sort-dropdown__option--selected {
color: var(--accent-primary);
font-weight: 500;
}

View File

@@ -0,0 +1,108 @@
import { useState, useRef, useEffect } from 'react';
import './SortDropdown.css';
export interface SortOption {
value: string;
label: string;
}
interface SortDropdownProps {
options: SortOption[];
value: string;
order: 'asc' | 'desc';
onChange: (value: string, order: 'asc' | 'desc') => void;
className?: string;
}
export function SortDropdown({ options, value, order, onChange, className = '' }: SortDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const selectedOption = options.find((o) => o.value === value) || options[0];
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const toggleOrder = () => {
onChange(value, order === 'asc' ? 'desc' : 'asc');
};
return (
<div className={`sort-dropdown ${className}`.trim()} ref={dropdownRef}>
<button
type="button"
className="sort-dropdown__trigger"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="4" y1="6" x2="20" y2="6" />
<line x1="4" y1="12" x2="14" y2="12" />
<line x1="4" y1="18" x2="8" y2="18" />
</svg>
<span>Sort: {selectedOption.label}</span>
<svg
className={`sort-dropdown__chevron ${isOpen ? 'sort-dropdown__chevron--open' : ''}`}
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<button
type="button"
className="sort-dropdown__order"
onClick={toggleOrder}
title={order === 'asc' ? 'Ascending' : 'Descending'}
>
{order === 'asc' ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<polyline points="19 12 12 19 5 12" />
</svg>
)}
</button>
{isOpen && (
<div className="sort-dropdown__menu">
{options.map((option) => (
<button
key={option.value}
type="button"
className={`sort-dropdown__option ${option.value === value ? 'sort-dropdown__option--selected' : ''}`}
onClick={() => {
onChange(option.value, order);
setIsOpen(false);
}}
>
{option.label}
{option.value === value && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,9 @@
export { Card, CardHeader, CardBody, CardFooter } from './Card';
export { Badge } from './Badge';
export { Breadcrumb } from './Breadcrumb';
export { SearchInput } from './SearchInput';
export { SortDropdown } from './SortDropdown';
export type { SortOption } from './SortDropdown';
export { FilterChip, FilterChipGroup } from './FilterChip';
export { DataTable } from './DataTable';
export { Pagination } from './Pagination';

View File

@@ -272,6 +272,179 @@
color: var(--text-muted); color: var(--text-muted);
} }
.owner {
color: var(--text-muted);
font-size: 0.75rem;
}
.project-meta__dates {
display: flex;
flex-direction: column;
gap: 2px;
text-align: right;
}
.project-meta__owner {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-primary);
}
/* List Controls */
.list-controls {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.list-controls__search {
flex: 1;
min-width: 200px;
max-width: 400px;
}
/* Stats in project cards */
.project-stats {
display: flex;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-primary);
}
.project-stats__item {
display: flex;
flex-direction: column;
gap: 2px;
}
.project-stats__value {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.project-stats__label {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Page header enhancements */
.page-header__info {
flex: 1;
}
.page-header__title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
}
.page-header__meta {
display: flex;
gap: 16px;
margin-top: 8px;
font-size: 0.8125rem;
color: var(--text-muted);
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
/* Package card styles */
.package-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.package-card__header h3 {
margin-bottom: 0;
}
.package-stats {
display: flex;
gap: 20px;
margin: 16px 0;
padding: 12px 0;
border-top: 1px solid var(--border-primary);
border-bottom: 1px solid var(--border-primary);
}
.package-stats__item {
display: flex;
flex-direction: column;
gap: 2px;
}
.package-stats__value {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.package-stats__label {
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.latest-tag {
font-size: 0.75rem;
color: var(--text-secondary);
}
.latest-tag strong {
color: var(--accent-primary);
}
/* List controls select */
.list-controls__select {
padding: 8px 32px 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-size: 0.8125rem;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
}
.list-controls__select:hover {
background-color: var(--bg-hover);
border-color: var(--border-secondary);
}
.list-controls__select:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
}
/* Form row for side-by-side inputs */
.form-row {
display: flex;
gap: 16px;
}
.form-row .form-group {
flex: 1;
}
/* Breadcrumb */ /* Breadcrumb */
.breadcrumb { .breadcrumb {
display: flex; display: flex;

View File

@@ -1,33 +1,67 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { Project } from '../types'; import { Project, PaginatedResponse } from '../types';
import { listProjects, createProject } from '../api'; import { listProjects, createProject } from '../api';
import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput';
import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { Pagination } from '../components/Pagination';
import './Home.css'; import './Home.css';
const SORT_OPTIONS: SortOption[] = [
{ value: 'name', label: 'Name' },
{ value: 'created_at', label: 'Created' },
{ value: 'updated_at', label: 'Updated' },
];
function Home() { function Home() {
const [projects, setProjects] = useState<Project[]>([]); const [searchParams, setSearchParams] = useSearchParams();
const [projectsData, setProjectsData] = useState<PaginatedResponse<Project> | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [newProject, setNewProject] = useState({ name: '', description: '', is_public: true }); const [newProject, setNewProject] = useState({ name: '', description: '', is_public: true });
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
useEffect(() => { // Get params from URL
loadProjects(); const page = parseInt(searchParams.get('page') || '1', 10);
}, []); const search = searchParams.get('search') || '';
const sort = searchParams.get('sort') || 'name';
const order = (searchParams.get('order') || 'asc') as 'asc' | 'desc';
async function loadProjects() { const updateParams = useCallback(
(updates: Record<string, string | undefined>) => {
const newParams = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === undefined || value === '' || (key === 'page' && value === '1')) {
newParams.delete(key);
} else {
newParams.set(key, value);
}
});
setSearchParams(newParams);
},
[searchParams, setSearchParams]
);
const loadProjects = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const data = await listProjects(); const data = await listProjects({ page, search, sort, order });
setProjects(data); setProjectsData(data);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load projects'); setError(err instanceof Error ? err.message : 'Failed to load projects');
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [page, search, sort, order]);
useEffect(() => {
loadProjects();
}, [loadProjects]);
async function handleCreateProject(e: React.FormEvent) { async function handleCreateProject(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -44,7 +78,27 @@ function Home() {
} }
} }
if (loading) { const handleSearchChange = (value: string) => {
updateParams({ search: value, page: '1' });
};
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => {
updateParams({ sort: newSort, order: newOrder, page: '1' });
};
const handlePageChange = (newPage: number) => {
updateParams({ page: String(newPage) });
};
const clearFilters = () => {
setSearchParams({});
};
const hasActiveFilters = search !== '';
const projects = projectsData?.items || [];
const pagination = projectsData?.pagination;
if (loading && !projectsData) {
return <div className="loading">Loading projects...</div>; return <div className="loading">Loading projects...</div>;
} }
@@ -99,27 +153,65 @@ function Home() {
</form> </form>
)} )}
<div className="list-controls">
<SearchInput
value={search}
onChange={handleSearchChange}
placeholder="Search projects..."
className="list-controls__search"
/>
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
</div>
{hasActiveFilters && (
<FilterChipGroup onClearAll={clearFilters}>
{search && <FilterChip label="Search" value={search} onRemove={() => handleSearchChange('')} />}
</FilterChipGroup>
)}
{projects.length === 0 ? ( {projects.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<p>No projects yet. Create your first project to get started!</p> {hasActiveFilters ? (
<p>No projects match your filters. Try adjusting your search.</p>
) : (
<p>No projects yet. Create your first project to get started!</p>
)}
</div> </div>
) : ( ) : (
<div className="project-grid"> <>
{projects.map((project) => ( <div className="project-grid">
<Link to={`/project/${project.name}`} key={project.id} className="project-card card"> {projects.map((project) => (
<h3>{project.name}</h3> <Link to={`/project/${project.name}`} key={project.id} className="project-card card">
{project.description && <p>{project.description}</p>} <h3>{project.name}</h3>
<div className="project-meta"> {project.description && <p>{project.description}</p>}
<span className={`badge ${project.is_public ? 'badge-public' : 'badge-private'}`}> <div className="project-meta">
{project.is_public ? 'Public' : 'Private'} <Badge variant={project.is_public ? 'public' : 'private'}>
</span> {project.is_public ? 'Public' : 'Private'}
<span className="date"> </Badge>
Created {new Date(project.created_at).toLocaleDateString()} <div className="project-meta__dates">
</span> <span className="date">Created {new Date(project.created_at).toLocaleDateString()}</span>
</div> {project.updated_at !== project.created_at && (
</Link> <span className="date">Updated {new Date(project.updated_at).toLocaleDateString()}</span>
))} )}
</div> </div>
</div>
<div className="project-meta__owner">
<span className="owner">by {project.created_by}</span>
</div>
</Link>
))}
</div>
{pagination && pagination.total_pages > 1 && (
<Pagination
page={pagination.page}
totalPages={pagination.total_pages}
total={pagination.total}
limit={pagination.limit}
onPageChange={handlePageChange}
/>
)}
</>
)} )}
</div> </div>
); );

View File

@@ -206,6 +206,92 @@ h2 {
color: var(--text-primary); color: var(--text-primary);
} }
/* Section header */
.section-header {
margin-bottom: 16px;
}
.section-header h2 {
margin-bottom: 0;
}
/* Package header stats */
.package-header-stats {
display: flex;
gap: 20px;
margin-top: 12px;
font-size: 0.875rem;
color: var(--text-secondary);
}
.stat-item strong {
color: var(--text-primary);
}
.stat-item strong.accent {
color: var(--accent-primary);
}
/* Artifact ID cell */
.artifact-id-cell {
display: flex;
align-items: center;
gap: 8px;
}
/* Copy button */
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
opacity: 0;
transition: all var(--transition-fast);
}
.artifact-id-cell:hover .copy-btn,
tr:hover .copy-btn {
opacity: 1;
}
.copy-btn:hover {
background: var(--bg-hover);
color: var(--accent-primary);
}
/* Content type */
.content-type {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Created cell */
.created-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.created-by {
font-size: 0.75rem;
color: var(--text-muted);
}
/* Cell truncate */
.cell-truncate {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.upload-form { .upload-form {
@@ -217,11 +303,8 @@ h2 {
min-width: 100%; min-width: 100%;
} }
.tags-table { .package-header-stats {
overflow-x: auto; flex-wrap: wrap;
} gap: 12px;
.tags-table table {
min-width: 500px;
} }
} }

View File

@@ -1,13 +1,64 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { Tag } from '../types'; import { TagDetail, Package, PaginatedResponse } from '../types';
import { listTags, uploadArtifact, getDownloadUrl } from '../api'; import { listTags, uploadArtifact, getDownloadUrl, getPackage } from '../api';
import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput';
import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { DataTable } from '../components/DataTable';
import { Pagination } from '../components/Pagination';
import './Home.css'; import './Home.css';
import './PackagePage.css'; import './PackagePage.css';
const SORT_OPTIONS: SortOption[] = [
{ value: 'name', label: 'Name' },
{ value: 'created_at', label: 'Created' },
];
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button className="copy-btn" onClick={handleCopy} title="Copy to clipboard">
{copied ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
);
}
function PackagePage() { function PackagePage() {
const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>(); const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>();
const [tags, setTags] = useState<Tag[]>([]); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [pkg, setPkg] = useState<Package | null>(null);
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -15,24 +66,61 @@ function PackagePage() {
const [tag, setTag] = useState(''); const [tag, setTag] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { // Get params from URL
if (projectName && packageName) { const page = parseInt(searchParams.get('page') || '1', 10);
loadTags(); const search = searchParams.get('search') || '';
} const sort = searchParams.get('sort') || 'name';
}, [projectName, packageName]); const order = (searchParams.get('order') || 'asc') as 'asc' | 'desc';
const updateParams = useCallback(
(updates: Record<string, string | undefined>) => {
const newParams = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === undefined || value === '' || (key === 'page' && value === '1')) {
newParams.delete(key);
} else {
newParams.set(key, value);
}
});
setSearchParams(newParams);
},
[searchParams, setSearchParams]
);
const loadData = useCallback(async () => {
if (!projectName || !packageName) return;
async function loadTags() {
try { try {
setLoading(true); setLoading(true);
const data = await listTags(projectName!, packageName!); const [pkgData, tagsResult] = await Promise.all([
setTags(data); getPackage(projectName, packageName),
listTags(projectName, packageName, { page, search, sort, order }),
]);
setPkg(pkgData);
setTagsData(tagsResult);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load tags'); setError(err instanceof Error ? err.message : 'Failed to load data');
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [projectName, packageName, page, search, sort, order]);
useEffect(() => {
loadData();
}, [loadData]);
// Keyboard navigation - go back with backspace
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Backspace' && !['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) {
e.preventDefault();
navigate(`/project/${projectName}`);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [navigate, projectName]);
async function handleUpload(e: React.FormEvent) { async function handleUpload(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -51,7 +139,7 @@ function PackagePage() {
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
loadTags(); loadData();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed'); setError(err instanceof Error ? err.message : 'Upload failed');
} finally { } finally {
@@ -59,18 +147,148 @@ function PackagePage() {
} }
} }
if (loading) { const handleSearchChange = (value: string) => {
updateParams({ search: value, page: '1' });
};
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => {
updateParams({ sort: newSort, order: newOrder, page: '1' });
};
const handlePageChange = (newPage: number) => {
updateParams({ page: String(newPage) });
};
const clearFilters = () => {
setSearchParams({});
};
const hasActiveFilters = search !== '';
const tags = tagsData?.items || [];
const pagination = tagsData?.pagination;
const columns = [
{
key: 'name',
header: 'Tag',
sortable: true,
render: (t: TagDetail) => <strong>{t.name}</strong>,
},
{
key: 'artifact_id',
header: 'Artifact ID',
render: (t: TagDetail) => (
<div className="artifact-id-cell">
<code className="artifact-id">{t.artifact_id.substring(0, 12)}...</code>
<CopyButton text={t.artifact_id} />
</div>
),
},
{
key: 'size',
header: 'Size',
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
},
{
key: 'content_type',
header: 'Type',
render: (t: TagDetail) => (
<span className="content-type">{t.artifact_content_type || '-'}</span>
),
},
{
key: 'original_name',
header: 'Filename',
className: 'cell-truncate',
render: (t: TagDetail) => (
<span title={t.artifact_original_name || undefined}>{t.artifact_original_name || '-'}</span>
),
},
{
key: 'created_at',
header: 'Created',
sortable: true,
render: (t: TagDetail) => (
<div className="created-cell">
<span>{new Date(t.created_at).toLocaleString()}</span>
<span className="created-by">by {t.created_by}</span>
</div>
),
},
{
key: 'actions',
header: 'Actions',
render: (t: TagDetail) => (
<a
href={getDownloadUrl(projectName!, packageName!, t.name)}
className="btn btn-secondary btn-small"
download
>
Download
</a>
),
},
];
if (loading && !tagsData) {
return <div className="loading">Loading...</div>; return <div className="loading">Loading...</div>;
} }
return ( return (
<div className="home"> <div className="home">
<nav className="breadcrumb"> <Breadcrumb
<Link to="/">Projects</Link> / <Link to={`/project/${projectName}`}>{projectName}</Link> / <span>{packageName}</span> items={[
</nav> { label: 'Projects', href: '/' },
{ label: projectName!, href: `/project/${projectName}` },
{ label: packageName! },
]}
/>
<div className="page-header"> <div className="page-header">
<h1>{packageName}</h1> <div className="page-header__info">
<div className="page-header__title-row">
<h1>{packageName}</h1>
{pkg && <Badge variant="default">{pkg.format}</Badge>}
</div>
{pkg?.description && <p className="description">{pkg.description}</p>}
<div className="page-header__meta">
<span className="meta-item">
in <a href={`/project/${projectName}`}>{projectName}</a>
</span>
{pkg && (
<>
<span className="meta-item">Created {new Date(pkg.created_at).toLocaleDateString()}</span>
{pkg.updated_at !== pkg.created_at && (
<span className="meta-item">Updated {new Date(pkg.updated_at).toLocaleDateString()}</span>
)}
</>
)}
</div>
{pkg && (pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && (
<div className="package-header-stats">
{pkg.tag_count !== undefined && (
<span className="stat-item">
<strong>{pkg.tag_count}</strong> tags
</span>
)}
{pkg.artifact_count !== undefined && (
<span className="stat-item">
<strong>{pkg.artifact_count}</strong> artifacts
</span>
)}
{pkg.total_size !== undefined && pkg.total_size > 0 && (
<span className="stat-item">
<strong>{formatBytes(pkg.total_size)}</strong> total
</span>
)}
{pkg.latest_tag && (
<span className="stat-item">
Latest: <strong className="accent">{pkg.latest_tag}</strong>
</span>
)}
</div>
)}
</div>
</div> </div>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
@@ -81,12 +299,7 @@ function PackagePage() {
<form onSubmit={handleUpload} className="upload-form"> <form onSubmit={handleUpload} className="upload-form">
<div className="form-group"> <div className="form-group">
<label htmlFor="file">File</label> <label htmlFor="file">File</label>
<input <input id="file" type="file" ref={fileInputRef} required />
id="file"
type="file"
ref={fileInputRef}
required
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="tag">Tag (optional)</label> <label htmlFor="tag">Tag (optional)</label>
@@ -104,42 +317,54 @@ function PackagePage() {
</form> </form>
</div> </div>
<h2>Tags / Versions</h2> <div className="section-header">
{tags.length === 0 ? ( <h2>Tags / Versions</h2>
<div className="empty-state"> </div>
<p>No tags yet. Upload an artifact with a tag to create one!</p>
</div> <div className="list-controls">
) : ( <SearchInput
<div className="tags-table"> value={search}
<table> onChange={handleSearchChange}
<thead> placeholder="Search tags..."
<tr> className="list-controls__search"
<th>Tag</th> />
<th>Artifact ID</th> <SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
<th>Created</th> </div>
<th>Actions</th>
</tr> {hasActiveFilters && (
</thead> <FilterChipGroup onClearAll={clearFilters}>
<tbody> {search && <FilterChip label="Search" value={search} onRemove={() => handleSearchChange('')} />}
{tags.map((t) => ( </FilterChipGroup>
<tr key={t.id}> )}
<td><strong>{t.name}</strong></td>
<td className="artifact-id">{t.artifact_id.substring(0, 12)}...</td> <DataTable
<td>{new Date(t.created_at).toLocaleString()}</td> data={tags}
<td> columns={columns}
<a keyExtractor={(t) => t.id}
href={getDownloadUrl(projectName!, packageName!, t.name)} emptyMessage={
className="btn btn-secondary btn-small" hasActiveFilters
download ? 'No tags match your filters. Try adjusting your search.'
> : 'No tags yet. Upload an artifact with a tag to create one!'
Download }
</a> onSort={(key) => {
</td> if (key === sort) {
</tr> handleSortChange(key, order === 'asc' ? 'desc' : 'asc');
))} } else {
</tbody> handleSortChange(key, 'asc');
</table> }
</div> }}
sortKey={sort}
sortOrder={order}
/>
{pagination && pagination.total_pages > 1 && (
<Pagination
page={pagination.page}
totalPages={pagination.total_pages}
total={pagination.total}
limit={pagination.limit}
onPageChange={handlePageChange}
/>
)} )}
<div className="usage-section card"> <div className="usage-section card">

View File

@@ -1,48 +1,107 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom';
import { Project, Package } from '../types'; import { Project, Package, PaginatedResponse } from '../types';
import { getProject, listPackages, createPackage } from '../api'; import { getProject, listPackages, createPackage } from '../api';
import { Breadcrumb } from '../components/Breadcrumb';
import { Badge } from '../components/Badge';
import { SearchInput } from '../components/SearchInput';
import { SortDropdown, SortOption } from '../components/SortDropdown';
import { FilterChip, FilterChipGroup } from '../components/FilterChip';
import { Pagination } from '../components/Pagination';
import './Home.css'; import './Home.css';
const SORT_OPTIONS: SortOption[] = [
{ value: 'name', label: 'Name' },
{ value: 'created_at', label: 'Created' },
{ value: 'updated_at', label: 'Updated' },
];
const FORMAT_OPTIONS = ['generic', 'npm', 'pypi', 'docker', 'deb', 'rpm', 'maven', 'nuget', 'helm'];
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function ProjectPage() { function ProjectPage() {
const { projectName } = useParams<{ projectName: string }>(); const { projectName } = useParams<{ projectName: string }>();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [project, setProject] = useState<Project | null>(null); const [project, setProject] = useState<Project | null>(null);
const [packages, setPackages] = useState<Package[]>([]); const [packagesData, setPackagesData] = useState<PaginatedResponse<Package> | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [newPackage, setNewPackage] = useState({ name: '', description: '' }); const [newPackage, setNewPackage] = useState({ name: '', description: '', format: 'generic', platform: 'any' });
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
useEffect(() => { // Get params from URL
if (projectName) { const page = parseInt(searchParams.get('page') || '1', 10);
loadData(); const search = searchParams.get('search') || '';
} const sort = searchParams.get('sort') || 'name';
}, [projectName]); const order = (searchParams.get('order') || 'asc') as 'asc' | 'desc';
const format = searchParams.get('format') || '';
const updateParams = useCallback(
(updates: Record<string, string | undefined>) => {
const newParams = new URLSearchParams(searchParams);
Object.entries(updates).forEach(([key, value]) => {
if (value === undefined || value === '' || (key === 'page' && value === '1')) {
newParams.delete(key);
} else {
newParams.set(key, value);
}
});
setSearchParams(newParams);
},
[searchParams, setSearchParams]
);
const loadData = useCallback(async () => {
if (!projectName) return;
async function loadData() {
try { try {
setLoading(true); setLoading(true);
const [projectData, packagesData] = await Promise.all([ const [projectData, packagesResult] = await Promise.all([
getProject(projectName!), getProject(projectName),
listPackages(projectName!), listPackages(projectName, { page, search, sort, order, format: format || undefined }),
]); ]);
setProject(projectData); setProject(projectData);
setPackages(packagesData); setPackagesData(packagesResult);
setError(null); setError(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data'); setError(err instanceof Error ? err.message : 'Failed to load data');
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [projectName, page, search, sort, order, format]);
useEffect(() => {
loadData();
}, [loadData]);
// Keyboard navigation - go back with backspace
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Backspace' && !['INPUT', 'TEXTAREA'].includes((e.target as HTMLElement).tagName)) {
e.preventDefault();
navigate('/');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [navigate]);
async function handleCreatePackage(e: React.FormEvent) { async function handleCreatePackage(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
try { try {
setCreating(true); setCreating(true);
await createPackage(projectName!, newPackage); await createPackage(projectName!, newPackage);
setNewPackage({ name: '', description: '' }); setNewPackage({ name: '', description: '', format: 'generic', platform: 'any' });
setShowForm(false); setShowForm(false);
loadData(); loadData();
} catch (err) { } catch (err) {
@@ -52,7 +111,31 @@ function ProjectPage() {
} }
} }
if (loading) { const handleSearchChange = (value: string) => {
updateParams({ search: value, page: '1' });
};
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => {
updateParams({ sort: newSort, order: newOrder, page: '1' });
};
const handleFormatChange = (value: string) => {
updateParams({ format: value, page: '1' });
};
const handlePageChange = (newPage: number) => {
updateParams({ page: String(newPage) });
};
const clearFilters = () => {
setSearchParams({});
};
const hasActiveFilters = search !== '' || format !== '';
const packages = packagesData?.items || [];
const pagination = packagesData?.pagination;
if (loading && !packagesData) {
return <div className="loading">Loading...</div>; return <div className="loading">Loading...</div>;
} }
@@ -62,14 +145,29 @@ function ProjectPage() {
return ( return (
<div className="home"> <div className="home">
<nav className="breadcrumb"> <Breadcrumb
<Link to="/">Projects</Link> / <span>{project.name}</span> items={[
</nav> { label: 'Projects', href: '/' },
{ label: project.name },
]}
/>
<div className="page-header"> <div className="page-header">
<div> <div className="page-header__info">
<h1>{project.name}</h1> <div className="page-header__title-row">
<h1>{project.name}</h1>
<Badge variant={project.is_public ? 'public' : 'private'}>
{project.is_public ? 'Public' : 'Private'}
</Badge>
</div>
{project.description && <p className="description">{project.description}</p>} {project.description && <p className="description">{project.description}</p>}
<div className="page-header__meta">
<span className="meta-item">Created {new Date(project.created_at).toLocaleDateString()}</span>
{project.updated_at !== project.created_at && (
<span className="meta-item">Updated {new Date(project.updated_at).toLocaleDateString()}</span>
)}
<span className="meta-item">by {project.created_by}</span>
</div>
</div> </div>
<button className="btn btn-primary" onClick={() => setShowForm(!showForm)}> <button className="btn btn-primary" onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : '+ New Package'} {showForm ? 'Cancel' : '+ New Package'}
@@ -81,16 +179,32 @@ function ProjectPage() {
{showForm && ( {showForm && (
<form className="form card" onSubmit={handleCreatePackage}> <form className="form card" onSubmit={handleCreatePackage}>
<h3>Create New Package</h3> <h3>Create New Package</h3>
<div className="form-group"> <div className="form-row">
<label htmlFor="name">Name</label> <div className="form-group">
<input <label htmlFor="name">Name</label>
id="name" <input
type="text" id="name"
value={newPackage.name} type="text"
onChange={(e) => setNewPackage({ ...newPackage, name: e.target.value })} value={newPackage.name}
placeholder="releases" onChange={(e) => setNewPackage({ ...newPackage, name: e.target.value })}
required placeholder="releases"
/> required
/>
</div>
<div className="form-group">
<label htmlFor="format">Format</label>
<select
id="format"
value={newPackage.format}
onChange={(e) => setNewPackage({ ...newPackage, format: e.target.value })}
>
{FORMAT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="description">Description</label> <label htmlFor="description">Description</label>
@@ -108,24 +222,99 @@ function ProjectPage() {
</form> </form>
)} )}
<div className="list-controls">
<SearchInput
value={search}
onChange={handleSearchChange}
placeholder="Search packages..."
className="list-controls__search"
/>
<select
className="list-controls__select"
value={format}
onChange={(e) => handleFormatChange(e.target.value)}
>
<option value="">All formats</option>
{FORMAT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
</div>
{hasActiveFilters && (
<FilterChipGroup onClearAll={clearFilters}>
{search && <FilterChip label="Search" value={search} onRemove={() => handleSearchChange('')} />}
{format && <FilterChip label="Format" value={format} onRemove={() => handleFormatChange('')} />}
</FilterChipGroup>
)}
{packages.length === 0 ? ( {packages.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<p>No packages yet. Create your first package to start uploading artifacts!</p> {hasActiveFilters ? (
<p>No packages match your filters. Try adjusting your search.</p>
) : (
<p>No packages yet. Create your first package to start uploading artifacts!</p>
)}
</div> </div>
) : ( ) : (
<div className="project-grid"> <>
{packages.map((pkg) => ( <div className="project-grid">
<Link to={`/project/${projectName}/${pkg.name}`} key={pkg.id} className="project-card card"> {packages.map((pkg) => (
<h3>{pkg.name}</h3> <Link to={`/project/${projectName}/${pkg.name}`} key={pkg.id} className="project-card card">
{pkg.description && <p>{pkg.description}</p>} <div className="package-card__header">
<div className="project-meta"> <h3>{pkg.name}</h3>
<span className="date"> <Badge variant="default">{pkg.format}</Badge>
Created {new Date(pkg.created_at).toLocaleDateString()} </div>
</span> {pkg.description && <p>{pkg.description}</p>}
</div>
</Link> {(pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && (
))} <div className="package-stats">
</div> {pkg.tag_count !== undefined && (
<div className="package-stats__item">
<span className="package-stats__value">{pkg.tag_count}</span>
<span className="package-stats__label">Tags</span>
</div>
)}
{pkg.artifact_count !== undefined && (
<div className="package-stats__item">
<span className="package-stats__value">{pkg.artifact_count}</span>
<span className="package-stats__label">Artifacts</span>
</div>
)}
{pkg.total_size !== undefined && pkg.total_size > 0 && (
<div className="package-stats__item">
<span className="package-stats__value">{formatBytes(pkg.total_size)}</span>
<span className="package-stats__label">Size</span>
</div>
)}
</div>
)}
<div className="project-meta">
{pkg.latest_tag && (
<span className="latest-tag">
Latest: <strong>{pkg.latest_tag}</strong>
</span>
)}
<span className="date">Created {new Date(pkg.created_at).toLocaleDateString()}</span>
</div>
</Link>
))}
</div>
{pagination && pagination.total_pages > 1 && (
<Pagination
page={pagination.page}
totalPages={pagination.total_pages}
total={pagination.total}
limit={pagination.limit}
onPageChange={handlePageChange}
/>
)}
</>
)} )}
</div> </div>
); );

View File

@@ -8,13 +8,28 @@ export interface Project {
created_by: string; created_by: string;
} }
export interface TagSummary {
name: string;
artifact_id: string;
created_at: string;
}
export interface Package { export interface Package {
id: string; id: string;
project_id: string; project_id: string;
name: string; name: string;
description: string | null; description: string | null;
format: string;
platform: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
// Aggregated fields (from PackageDetailResponse)
tag_count?: number;
artifact_count?: number;
total_size?: number;
latest_tag?: string | null;
latest_upload_at?: string | null;
recent_tags?: TagSummary[];
} }
export interface Artifact { export interface Artifact {
@@ -36,6 +51,57 @@ export interface Tag {
created_by: string; created_by: string;
} }
export interface TagDetail extends Tag {
artifact_size: number;
artifact_content_type: string | null;
artifact_original_name: string | null;
artifact_created_at: string;
artifact_format_metadata: Record<string, unknown> | null;
}
export interface ArtifactTagInfo {
id: string;
name: string;
package_id: string;
package_name: string;
project_name: string;
}
export interface ArtifactDetail extends Artifact {
tags: ArtifactTagInfo[];
}
export interface PaginatedResponse<T> {
items: T[];
pagination: {
page: number;
limit: number;
total: number;
total_pages: number;
};
}
export interface ListParams {
page?: number;
limit?: number;
search?: string;
sort?: string;
order?: 'asc' | 'desc';
}
export interface TagListParams extends ListParams {}
export interface PackageListParams extends ListParams {
format?: string;
platform?: string;
}
export interface ArtifactListParams extends ListParams {
content_type?: string;
created_after?: string;
created_before?: string;
}
export interface Consumer { export interface Consumer {
id: string; id: string;
package_id: string; package_id: string;