4 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
27 changed files with 2559 additions and 181 deletions

139
README.md
View File

@@ -27,7 +27,13 @@ Orchard is a centralized binary artifact storage system that provides content-ad
- **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 - **Multipart Upload** - Automatic multipart upload for files larger than 100MB
@@ -56,10 +62,13 @@ Orchard is a centralized binary artifact storage system that provides content-ad
| `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 (supports Range header) | | `GET` | `/api/v1/project/:project/:package/+/:ref` | Download an artifact (supports Range header) |
| `HEAD` | `/api/v1/project/:project/:package/+/:ref` | Get artifact metadata without downloading | | `HEAD` | `/api/v1/project/:project/:package/+/:ref` | Get artifact metadata without downloading |
| `GET` | `/api/v1/project/:project/:package/tags` | List all tags | | `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 #### Resumable Upload Endpoints
@@ -290,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
``` ```
@@ -314,10 +430,21 @@ orchard/
│ └── 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

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request, Query, Header, Response from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request, Query, Header, Response
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,13 +11,13 @@ import hashlib
from .database import get_db from .database import get_db
from .storage import get_storage, S3Storage, MULTIPART_CHUNK_SIZE from .storage import get_storage, S3Storage, MULTIPART_CHUNK_SIZE
from .models import Project, Package, Artifact, Tag, Upload, Consumer from .models import Project, Package, Artifact, Tag, TagHistory, Upload, Consumer
from .schemas import ( from .schemas import (
ProjectCreate, ProjectResponse, ProjectCreate, ProjectResponse,
PackageCreate, PackageResponse, PackageDetailResponse, TagSummary, PackageCreate, PackageResponse, PackageDetailResponse, TagSummary,
PACKAGE_FORMATS, PACKAGE_PLATFORMS, PACKAGE_FORMATS, PACKAGE_PLATFORMS,
ArtifactResponse, ArtifactResponse, ArtifactDetailResponse, ArtifactTagInfo, PackageArtifactResponse,
TagCreate, TagResponse, TagCreate, TagResponse, TagDetailResponse, TagHistoryResponse,
UploadResponse, UploadResponse,
ConsumerResponse, ConsumerResponse,
HealthResponse, HealthResponse,
@@ -850,8 +851,17 @@ def download_artifact_compat(
# Tag routes # Tag routes
@router.get("/api/v1/project/{project_name}/{package_name}/tags", response_model=List[TagResponse]) @router.get("/api/v1/project/{project_name}/{package_name}/tags", response_model=PaginatedResponse[TagDetailResponse])
def list_tags(project_name: str, package_name: str, db: Session = Depends(get_db)): def list_tags(
project_name: str,
package_name: str,
page: int = Query(default=1, ge=1, description="Page number"),
limit: int = Query(default=20, ge=1, le=100, description="Items per page"),
search: Optional[str] = Query(default=None, description="Search by tag name"),
sort: str = Query(default="name", description="Sort field (name, created_at)"),
order: str = Query(default="asc", description="Sort order (asc, desc)"),
db: Session = Depends(get_db),
):
project = db.query(Project).filter(Project.name == project_name).first() project = db.query(Project).filter(Project.name == project_name).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
@@ -860,8 +870,65 @@ def list_tags(project_name: str, package_name: str, db: Session = Depends(get_db
if not package: if not package:
raise HTTPException(status_code=404, detail="Package not found") raise HTTPException(status_code=404, detail="Package not found")
tags = db.query(Tag).filter(Tag.package_id == package.id).order_by(Tag.name).all() # Validate sort field
return tags valid_sort_fields = {"name": Tag.name, "created_at": Tag.created_at}
if sort not in valid_sort_fields:
raise HTTPException(status_code=400, detail=f"Invalid sort field. Must be one of: {', '.join(valid_sort_fields.keys())}")
# Validate order
if order not in ("asc", "desc"):
raise HTTPException(status_code=400, detail="Invalid order. Must be 'asc' or 'desc'")
# Base query with JOIN to artifact for metadata
query = db.query(Tag, Artifact).join(Artifact, Tag.artifact_id == Artifact.id).filter(Tag.package_id == package.id)
# Apply search filter (case-insensitive on tag name)
if search:
query = query.filter(func.lower(Tag.name).contains(search.lower()))
# Get total count before pagination
total = query.count()
# Apply sorting
sort_column = valid_sort_fields[sort]
if order == "desc":
query = query.order_by(sort_column.desc())
else:
query = query.order_by(sort_column.asc())
# Apply pagination
offset = (page - 1) * limit
results = query.offset(offset).limit(limit).all()
# Calculate total pages
total_pages = math.ceil(total / limit) if total > 0 else 1
# Build detailed responses with artifact metadata
detailed_tags = []
for tag, artifact in results:
detailed_tags.append(TagDetailResponse(
id=tag.id,
package_id=tag.package_id,
name=tag.name,
artifact_id=tag.artifact_id,
created_at=tag.created_at,
created_by=tag.created_by,
artifact_size=artifact.size,
artifact_content_type=artifact.content_type,
artifact_original_name=artifact.original_name,
artifact_created_at=artifact.created_at,
artifact_format_metadata=artifact.format_metadata,
))
return PaginatedResponse(
items=detailed_tags,
pagination=PaginationMeta(
page=page,
limit=limit,
total=total,
total_pages=total_pages,
),
)
@router.post("/api/v1/project/{project_name}/{package_name}/tags", response_model=TagResponse) @router.post("/api/v1/project/{project_name}/{package_name}/tags", response_model=TagResponse)
@@ -908,6 +975,70 @@ def create_tag(
return db_tag return db_tag
@router.get("/api/v1/project/{project_name}/{package_name}/tags/{tag_name}", response_model=TagDetailResponse)
def get_tag(
project_name: str,
package_name: str,
tag_name: str,
db: Session = Depends(get_db),
):
"""Get a single tag with full artifact metadata"""
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
package = db.query(Package).filter(Package.project_id == project.id, Package.name == package_name).first()
if not package:
raise HTTPException(status_code=404, detail="Package not found")
result = db.query(Tag, Artifact).join(Artifact, Tag.artifact_id == Artifact.id).filter(
Tag.package_id == package.id,
Tag.name == tag_name
).first()
if not result:
raise HTTPException(status_code=404, detail="Tag not found")
tag, artifact = result
return TagDetailResponse(
id=tag.id,
package_id=tag.package_id,
name=tag.name,
artifact_id=tag.artifact_id,
created_at=tag.created_at,
created_by=tag.created_by,
artifact_size=artifact.size,
artifact_content_type=artifact.content_type,
artifact_original_name=artifact.original_name,
artifact_created_at=artifact.created_at,
artifact_format_metadata=artifact.format_metadata,
)
@router.get("/api/v1/project/{project_name}/{package_name}/tags/{tag_name}/history", response_model=List[TagHistoryResponse])
def get_tag_history(
project_name: str,
package_name: str,
tag_name: str,
db: Session = Depends(get_db),
):
"""Get the history of artifact assignments for a tag"""
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
package = db.query(Package).filter(Package.project_id == project.id, Package.name == package_name).first()
if not package:
raise HTTPException(status_code=404, detail="Package not found")
tag = db.query(Tag).filter(Tag.package_id == package.id, Tag.name == tag_name).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag not found")
history = db.query(TagHistory).filter(TagHistory.tag_id == tag.id).order_by(TagHistory.changed_at.desc()).all()
return history
# Consumer routes # Consumer routes
@router.get("/api/v1/project/{project_name}/{package_name}/consumers", response_model=List[ConsumerResponse]) @router.get("/api/v1/project/{project_name}/{package_name}/consumers", response_model=List[ConsumerResponse])
def get_consumers(project_name: str, package_name: str, db: Session = Depends(get_db)): def get_consumers(project_name: str, package_name: str, db: Session = Depends(get_db)):
@@ -923,10 +1054,122 @@ def get_consumers(project_name: str, package_name: str, db: Session = Depends(ge
return consumers return consumers
# Package artifacts
@router.get("/api/v1/project/{project_name}/{package_name}/artifacts", response_model=PaginatedResponse[PackageArtifactResponse])
def list_package_artifacts(
project_name: str,
package_name: str,
page: int = Query(default=1, ge=1, description="Page number"),
limit: int = Query(default=20, ge=1, le=100, description="Items per page"),
content_type: Optional[str] = Query(default=None, description="Filter by content type"),
created_after: Optional[datetime] = Query(default=None, description="Filter artifacts created after this date"),
created_before: Optional[datetime] = Query(default=None, description="Filter artifacts created before this date"),
db: Session = Depends(get_db),
):
"""List all unique artifacts uploaded to a package"""
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
package = db.query(Package).filter(Package.project_id == project.id, Package.name == package_name).first()
if not package:
raise HTTPException(status_code=404, detail="Package not found")
# Get distinct artifacts uploaded to this package via uploads table
artifact_ids_subquery = db.query(func.distinct(Upload.artifact_id)).filter(
Upload.package_id == package.id
).subquery()
query = db.query(Artifact).filter(Artifact.id.in_(artifact_ids_subquery))
# Apply content_type filter
if content_type:
query = query.filter(Artifact.content_type == content_type)
# Apply date range filters
if created_after:
query = query.filter(Artifact.created_at >= created_after)
if created_before:
query = query.filter(Artifact.created_at <= created_before)
# Get total count before pagination
total = query.count()
# Apply pagination
offset = (page - 1) * limit
artifacts = query.order_by(Artifact.created_at.desc()).offset(offset).limit(limit).all()
# Calculate total pages
total_pages = math.ceil(total / limit) if total > 0 else 1
# Build responses with tag info
artifact_responses = []
for artifact in artifacts:
# Get tags pointing to this artifact in this package
tags = db.query(Tag.name).filter(
Tag.package_id == package.id,
Tag.artifact_id == artifact.id
).all()
tag_names = [t.name for t in tags]
artifact_responses.append(PackageArtifactResponse(
id=artifact.id,
size=artifact.size,
content_type=artifact.content_type,
original_name=artifact.original_name,
created_at=artifact.created_at,
created_by=artifact.created_by,
format_metadata=artifact.format_metadata,
tags=tag_names,
))
return PaginatedResponse(
items=artifact_responses,
pagination=PaginationMeta(
page=page,
limit=limit,
total=total,
total_pages=total_pages,
),
)
# Artifact by ID # Artifact by ID
@router.get("/api/v1/artifact/{artifact_id}", response_model=ArtifactResponse) @router.get("/api/v1/artifact/{artifact_id}", response_model=ArtifactDetailResponse)
def get_artifact(artifact_id: str, db: Session = Depends(get_db)): def get_artifact(artifact_id: str, db: Session = Depends(get_db)):
"""Get artifact metadata including list of packages/tags referencing it"""
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first() artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
if not artifact: if not artifact:
raise HTTPException(status_code=404, detail="Artifact not found") raise HTTPException(status_code=404, detail="Artifact not found")
return artifact
# Get all tags referencing this artifact with package and project info
tags_with_context = db.query(Tag, Package, Project).join(
Package, Tag.package_id == Package.id
).join(
Project, Package.project_id == Project.id
).filter(
Tag.artifact_id == artifact_id
).all()
tag_infos = [
ArtifactTagInfo(
id=tag.id,
name=tag.name,
package_id=package.id,
package_name=package.name,
project_name=project.name,
)
for tag, package, project in tags_with_context
]
return ArtifactDetailResponse(
id=artifact.id,
size=artifact.size,
content_type=artifact.content_type,
original_name=artifact.original_name,
created_at=artifact.created_at,
created_by=artifact.created_by,
ref_count=artifact.ref_count,
format_metadata=artifact.format_metadata,
tags=tag_infos,
)

View File

@@ -129,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

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,21 +23,26 @@ async function handleResponse<T>(response: Response): Promise<T> {
return response.json(); return response.json();
} }
// Paginated response type function buildQueryString(params: Record<string, unknown>): string {
interface PaginatedResponse<T> { const searchParams = new URLSearchParams();
items: T[]; Object.entries(params).forEach(([key, value]) => {
pagination: { if (value !== undefined && value !== null && value !== '') {
page: number; searchParams.append(key, String(value));
limit: number; }
total: number; });
total_pages: number; 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>);
const data = await handleResponse<PaginatedResponse<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; return data.items;
} }
@@ -43,12 +61,22 @@ 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>);
const data = await handleResponse<PaginatedResponse<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; 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> {
const response = await fetch(`${API_BASE}/project/${projectName}/packages`, { const response = await fetch(`${API_BASE}/project/${projectName}/packages`, {
method: 'POST', method: 'POST',
@@ -59,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> {
@@ -74,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

@@ -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

@@ -51,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;