2 Commits

Author SHA1 Message Date
Mondo Diaz
ec0789c92f Update documentation for upload/download API enhancements
- Add resumable upload endpoints to API table
- Add HEAD request endpoint documentation
- Document format-specific metadata extraction feature
- Add resumable upload usage examples
- Add range request and HEAD request examples
- Update project structure with metadata.py
- Update Future Work section (remove completed items)
2025-12-11 17:09:33 -06:00
Mondo Diaz
6eb2f9db7b Implement backend upload/download API enhancements
- Add S3 multipart upload support for files > 100MB
- Add resumable upload API endpoints (init, upload part, complete, abort, status)
- Add HTTP range request support for partial downloads
- Add HEAD request endpoint for artifact metadata
- Add format-specific metadata extraction (deb, rpm, tar.gz, wheel, jar, zip)
- Add format_metadata column to artifacts table
- Add database migration for schema updates
- Add deduplication indicator in upload response
- Set Accept-Ranges header on downloads
- Return Content-Length header on all downloads
2025-12-11 17:07:10 -06:00
16 changed files with 184 additions and 1290 deletions

8
.gitignore vendored
View File

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

View File

@@ -22,8 +22,6 @@ Orchard is a centralized binary artifact storage system that provides content-ad
- **Package** - Named collection within a project
- **Artifact** - Specific content instance identified by SHA256
- **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
- **PostgreSQL Metadata** - Relational database for metadata, access control, and audit trails
- **REST API** - Full HTTP API for all operations
@@ -50,8 +48,7 @@ Orchard is a centralized binary artifact storage system that provides content-ad
| `GET` | `/api/v1/projects` | List all projects |
| `POST` | `/api/v1/projects` | Create a new project |
| `GET` | `/api/v1/projects/:project` | Get project details |
| `GET` | `/api/v1/project/:project/packages` | List packages (with pagination, search, filtering) |
| `GET` | `/api/v1/project/:project/packages/:package` | Get single package with metadata |
| `GET` | `/api/v1/project/:project/packages` | List packages in a project |
| `POST` | `/api/v1/project/:project/packages` | Create a new package |
| `POST` | `/api/v1/project/:project/:package/upload` | Upload an artifact |
| `GET` | `/api/v1/project/:project/:package/+/:ref` | Download an artifact (supports Range header) |
@@ -154,61 +151,7 @@ curl -X POST http://localhost:8080/api/v1/projects \
```bash
curl -X POST http://localhost:8080/api/v1/project/my-project/packages \
-H "Content-Type: application/json" \
-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"
-d '{"name": "releases", "description": "Release builds"}'
```
### Upload an Artifact

View File

@@ -3,9 +3,6 @@ from functools import lru_cache
class Settings(BaseSettings):
# Environment
env: str = "development" # "development" or "production"
# Server
server_host: str = "0.0.0.0"
server_port: int = 8080
@@ -31,14 +28,6 @@ class Settings(BaseSettings):
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}"
@property
def is_development(self) -> bool:
return self.env.lower() == "development"
@property
def is_production(self) -> bool:
return self.env.lower() == "production"
class Config:
env_prefix = "ORCHARD_"
case_sensitive = False

View File

@@ -36,32 +36,6 @@ def _run_migrations():
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:

View File

@@ -2,35 +2,19 @@ from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from contextlib import asynccontextmanager
import logging
import os
from .config import get_settings
from .database import init_db, SessionLocal
from .database import init_db
from .routes import router
from .seed import seed_database
settings = get_settings()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: initialize database
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
# Shutdown: cleanup if needed
@@ -57,12 +41,11 @@ if os.path.exists(static_dir):
# Catch-all for SPA routing (must be last)
@app.get("/{full_path:path}")
async def serve_spa_routes(full_path: str):
# Don't catch API routes or health endpoint
if full_path.startswith("api/") or full_path.startswith("health"):
# Don't catch API routes
if full_path.startswith("api/") or full_path.startswith("health") or full_path.startswith("project/"):
from fastapi import HTTPException
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")
if os.path.exists(index_path):
return FileResponse(index_path)

View File

@@ -38,8 +38,6 @@ class Package(Base):
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
name = Column(String(255), nullable=False)
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)
updated_at = Column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -51,16 +49,6 @@ class Package(Base):
__table_args__ = (
Index("idx_packages_project_id", "project_id"),
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},
)

View File

@@ -1,9 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request, Query, Header, Response
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Request, Header, Response
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import or_, func
from sqlalchemy import or_
from typing import List, Optional
import math
import re
import io
import hashlib
@@ -13,14 +12,12 @@ from .storage import get_storage, S3Storage, MULTIPART_CHUNK_SIZE
from .models import Project, Package, Artifact, Tag, Upload, Consumer
from .schemas import (
ProjectCreate, ProjectResponse,
PackageCreate, PackageResponse, PackageDetailResponse, TagSummary,
PACKAGE_FORMATS, PACKAGE_PLATFORMS,
PackageCreate, PackageResponse,
ArtifactResponse,
TagCreate, TagResponse,
UploadResponse,
ConsumerResponse,
HealthResponse,
PaginatedResponse, PaginationMeta,
ResumableUploadInitRequest,
ResumableUploadInitResponse,
ResumableUploadPartResponse,
@@ -51,44 +48,13 @@ def health_check():
# Project routes
@router.get("/api/v1/projects", response_model=PaginatedResponse[ProjectResponse])
def list_projects(
request: Request,
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 project name"),
db: Session = Depends(get_db),
):
@router.get("/api/v1/projects", response_model=List[ProjectResponse])
def list_projects(request: Request, db: Session = Depends(get_db)):
user_id = get_user_id(request)
# Base query - filter by access
query = db.query(Project).filter(
projects = db.query(Project).filter(
or_(Project.is_public == True, Project.created_by == user_id)
)
# Apply search filter (case-insensitive)
if search:
query = query.filter(func.lower(Project.name).contains(search.lower()))
# Get total count before pagination
total = query.count()
# Apply pagination
offset = (page - 1) * limit
projects = query.order_by(Project.name).offset(offset).limit(limit).all()
# Calculate total pages
total_pages = math.ceil(total / limit) if total > 0 else 1
return PaginatedResponse(
items=projects,
pagination=PaginationMeta(
page=page,
limit=limit,
total=total,
total_pages=total_pages,
),
)
).order_by(Project.name).all()
return projects
@router.post("/api/v1/projects", response_model=ProjectResponse)
@@ -120,210 +86,14 @@ def get_project(project_name: str, db: Session = Depends(get_db)):
# Package routes
@router.get("/api/v1/project/{project_name}/packages", response_model=PaginatedResponse[PackageDetailResponse])
def list_packages(
project_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 name or description"),
sort: str = Query(default="name", description="Sort field (name, created_at, updated_at)"),
order: str = Query(default="asc", description="Sort order (asc, desc)"),
format: Optional[str] = Query(default=None, description="Filter by package format"),
platform: Optional[str] = Query(default=None, description="Filter by platform"),
db: Session = Depends(get_db),
):
@router.get("/api/v1/project/{project_name}/packages", response_model=List[PackageResponse])
def list_packages(project_name: str, db: Session = Depends(get_db)):
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Validate sort field
valid_sort_fields = {"name": Package.name, "created_at": Package.created_at, "updated_at": Package.updated_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'")
# Validate format filter
if format and format not in PACKAGE_FORMATS:
raise HTTPException(status_code=400, detail=f"Invalid format. Must be one of: {', '.join(PACKAGE_FORMATS)}")
# Validate platform filter
if platform and platform not in PACKAGE_PLATFORMS:
raise HTTPException(status_code=400, detail=f"Invalid platform. Must be one of: {', '.join(PACKAGE_PLATFORMS)}")
# Base query
query = db.query(Package).filter(Package.project_id == project.id)
# Apply search filter (case-insensitive on name and description)
if search:
search_lower = search.lower()
query = query.filter(
or_(
func.lower(Package.name).contains(search_lower),
func.lower(Package.description).contains(search_lower)
)
)
# Apply format filter
if format:
query = query.filter(Package.format == format)
# Apply platform filter
if platform:
query = query.filter(Package.platform == platform)
# 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
packages = query.offset(offset).limit(limit).all()
# Calculate total pages
total_pages = math.ceil(total / limit) if total > 0 else 1
# Build detailed responses with aggregated data
detailed_packages = []
for pkg in packages:
# Get tag count
tag_count = db.query(func.count(Tag.id)).filter(Tag.package_id == pkg.id).scalar() or 0
# Get unique artifact count and total size via uploads
artifact_stats = db.query(
func.count(func.distinct(Upload.artifact_id)),
func.coalesce(func.sum(Artifact.size), 0)
).join(Artifact, Upload.artifact_id == Artifact.id).filter(
Upload.package_id == pkg.id
).first()
artifact_count = artifact_stats[0] if artifact_stats else 0
total_size = artifact_stats[1] if artifact_stats else 0
# Get latest tag
latest_tag_obj = db.query(Tag).filter(
Tag.package_id == pkg.id
).order_by(Tag.created_at.desc()).first()
latest_tag = latest_tag_obj.name if latest_tag_obj else None
# Get latest upload timestamp
latest_upload = db.query(func.max(Upload.uploaded_at)).filter(
Upload.package_id == pkg.id
).scalar()
# Get recent tags (limit 5)
recent_tags_objs = db.query(Tag).filter(
Tag.package_id == pkg.id
).order_by(Tag.created_at.desc()).limit(5).all()
recent_tags = [
TagSummary(name=t.name, artifact_id=t.artifact_id, created_at=t.created_at)
for t in recent_tags_objs
]
detailed_packages.append(PackageDetailResponse(
id=pkg.id,
project_id=pkg.project_id,
name=pkg.name,
description=pkg.description,
format=pkg.format,
platform=pkg.platform,
created_at=pkg.created_at,
updated_at=pkg.updated_at,
tag_count=tag_count,
artifact_count=artifact_count,
total_size=total_size,
latest_tag=latest_tag,
latest_upload_at=latest_upload,
recent_tags=recent_tags,
))
return PaginatedResponse(
items=detailed_packages,
pagination=PaginationMeta(
page=page,
limit=limit,
total=total,
total_pages=total_pages,
),
)
@router.get("/api/v1/project/{project_name}/packages/{package_name}", response_model=PackageDetailResponse)
def get_package(
project_name: str,
package_name: str,
include_tags: bool = Query(default=False, description="Include all tags (not just recent 5)"),
db: Session = Depends(get_db),
):
"""Get a single package with full metadata"""
project = db.query(Project).filter(Project.name == project_name).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
pkg = db.query(Package).filter(
Package.project_id == project.id,
Package.name == package_name
).first()
if not pkg:
raise HTTPException(status_code=404, detail="Package not found")
# Get tag count
tag_count = db.query(func.count(Tag.id)).filter(Tag.package_id == pkg.id).scalar() or 0
# Get unique artifact count and total size via uploads
artifact_stats = db.query(
func.count(func.distinct(Upload.artifact_id)),
func.coalesce(func.sum(Artifact.size), 0)
).join(Artifact, Upload.artifact_id == Artifact.id).filter(
Upload.package_id == pkg.id
).first()
artifact_count = artifact_stats[0] if artifact_stats else 0
total_size = artifact_stats[1] if artifact_stats else 0
# Get latest tag
latest_tag_obj = db.query(Tag).filter(
Tag.package_id == pkg.id
).order_by(Tag.created_at.desc()).first()
latest_tag = latest_tag_obj.name if latest_tag_obj else None
# Get latest upload timestamp
latest_upload = db.query(func.max(Upload.uploaded_at)).filter(
Upload.package_id == pkg.id
).scalar()
# Get tags (all if include_tags=true, else limit 5)
tags_query = db.query(Tag).filter(Tag.package_id == pkg.id).order_by(Tag.created_at.desc())
if not include_tags:
tags_query = tags_query.limit(5)
tags_objs = tags_query.all()
recent_tags = [
TagSummary(name=t.name, artifact_id=t.artifact_id, created_at=t.created_at)
for t in tags_objs
]
return PackageDetailResponse(
id=pkg.id,
project_id=pkg.project_id,
name=pkg.name,
description=pkg.description,
format=pkg.format,
platform=pkg.platform,
created_at=pkg.created_at,
updated_at=pkg.updated_at,
tag_count=tag_count,
artifact_count=artifact_count,
total_size=total_size,
latest_tag=latest_tag,
latest_upload_at=latest_upload,
recent_tags=recent_tags,
)
packages = db.query(Package).filter(Package.project_id == project.id).order_by(Package.name).all()
return packages
@router.post("/api/v1/project/{project_name}/packages", response_model=PackageResponse)
@@ -332,14 +102,6 @@ def create_package(project_name: str, package: PackageCreate, db: Session = Depe
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Validate format
if package.format not in PACKAGE_FORMATS:
raise HTTPException(status_code=400, detail=f"Invalid format. Must be one of: {', '.join(PACKAGE_FORMATS)}")
# Validate platform
if package.platform not in PACKAGE_PLATFORMS:
raise HTTPException(status_code=400, detail=f"Invalid platform. Must be one of: {', '.join(PACKAGE_PLATFORMS)}")
existing = db.query(Package).filter(Package.project_id == project.id, Package.name == package.name).first()
if existing:
raise HTTPException(status_code=400, detail="Package already exists in this project")
@@ -348,8 +110,6 @@ def create_package(project_name: str, package: PackageCreate, db: Session = Depe
project_id=project.id,
name=package.name,
description=package.description,
format=package.format,
platform=package.platform,
)
db.add(db_package)
db.commit()

View File

@@ -1,23 +1,8 @@
from datetime import datetime
from typing import Optional, List, Dict, Any, Generic, TypeVar
from typing import Optional, List, Dict, Any
from pydantic import BaseModel
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
class ProjectCreate(BaseModel):
@@ -39,17 +24,10 @@ class ProjectResponse(BaseModel):
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
class PackageCreate(BaseModel):
name: str
description: Optional[str] = None
format: str = "generic"
platform: str = "any"
class PackageResponse(BaseModel):
@@ -57,8 +35,6 @@ class PackageResponse(BaseModel):
project_id: UUID
name: str
description: Optional[str]
format: str
platform: str
created_at: datetime
updated_at: datetime
@@ -66,36 +42,6 @@ class PackageResponse(BaseModel):
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
class ArtifactResponse(BaseModel):
id: str

View File

@@ -1,222 +0,0 @@
"""
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

@@ -10,22 +10,10 @@ async function handleResponse<T>(response: Response): Promise<T> {
return response.json();
}
// Paginated response type
interface PaginatedResponse<T> {
items: T[];
pagination: {
page: number;
limit: number;
total: number;
total_pages: number;
};
}
// Project API
export async function listProjects(): Promise<Project[]> {
const response = await fetch(`${API_BASE}/projects`);
const data = await handleResponse<PaginatedResponse<Project>>(response);
return data.items;
return handleResponse<Project[]>(response);
}
export async function createProject(data: { name: string; description?: string; is_public?: boolean }): Promise<Project> {
@@ -45,8 +33,7 @@ export async function getProject(name: string): Promise<Project> {
// Package API
export async function listPackages(projectName: string): Promise<Package[]> {
const response = await fetch(`${API_BASE}/project/${projectName}/packages`);
const data = await handleResponse<PaginatedResponse<Package>>(response);
return data.items;
return handleResponse<Package[]>(response);
}
export async function createPackage(projectName: string, data: { name: string; description?: string }): Promise<Package> {

View File

@@ -2,174 +2,64 @@
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-primary);
}
/* Header */
.header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
padding: 0;
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(12px);
background: rgba(17, 17, 19, 0.85);
background-color: var(--primary);
color: white;
padding: 1rem 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 64px;
}
/* Logo */
.logo {
display: flex;
align-items: center;
gap: 12px;
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 600;
gap: 0.5rem;
color: white;
font-size: 1.5rem;
font-weight: bold;
text-decoration: none;
transition: opacity var(--transition-fast);
}
.logo:hover {
opacity: 0.9;
color: var(--text-primary);
text-decoration: none;
}
.logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--accent-gradient);
border-radius: var(--radius-md);
color: white;
box-shadow: var(--shadow-glow);
font-size: 2rem;
}
.logo-text {
letter-spacing: -0.02em;
}
/* Navigation */
.nav {
display: flex;
gap: 8px;
gap: 1.5rem;
}
.nav a {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--radius-md);
transition: all var(--transition-fast);
color: white;
opacity: 0.9;
}
.nav a:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.nav a.active {
color: var(--accent-primary);
background: rgba(16, 185, 129, 0.1);
}
.nav a svg {
opacity: 0.8;
}
.nav a:hover svg,
.nav a.active svg {
opacity: 1;
text-decoration: none;
}
.nav-link-muted {
opacity: 0.7;
}
/* Main content */
.main {
flex: 1;
padding: 32px 0 64px;
padding: 2rem 0;
}
/* Footer */
.footer {
background: var(--bg-secondary);
border-top: 1px solid var(--border-primary);
padding: 24px 0;
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.footer-brand {
display: flex;
align-items: center;
gap: 12px;
}
.footer-logo {
font-weight: 600;
color: var(--text-primary);
}
.footer-tagline {
color: var(--text-secondary);
font-size: 0.875rem;
}
.footer-links {
display: flex;
gap: 24px;
}
.footer-links a {
color: var(--text-secondary);
font-size: 0.875rem;
transition: color var(--transition-fast);
}
.footer-links a:hover {
color: var(--text-primary);
}
/* Responsive */
@media (max-width: 640px) {
.header-content {
height: 56px;
}
.logo-text {
display: none;
}
.nav a span {
display: none;
}
.footer-content {
flex-direction: column;
gap: 16px;
background-color: var(--primary-dark);
color: white;
padding: 1rem 0;
text-align: center;
}
.footer-brand {
flex-direction: column;
gap: 4px;
}
opacity: 0.9;
font-size: 0.875rem;
}

View File

@@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Link } from 'react-router-dom';
import './Layout.css';
interface LayoutProps {
@@ -7,48 +7,16 @@ interface LayoutProps {
}
function Layout({ children }: LayoutProps) {
const location = useLocation();
return (
<div className="layout">
<header className="header">
<div className="container header-content">
<Link to="/" className="logo">
<div className="logo-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Three fruit trees representing an orchard */}
{/* Left tree - rounded canopy */}
<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.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
{/* Center tree - larger rounded canopy */}
<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.25" y="11" width="1.5" height="5" fill="currentColor"/>
{/* Right tree - rounded canopy */}
<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.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
{/* Ground */}
<ellipse cx="12" cy="19" rx="9" ry="1.5" fill="currentColor" opacity="0.3"/>
</svg>
</div>
<span className="logo-icon">🌳</span>
<span className="logo-text">Orchard</span>
</Link>
<nav className="nav">
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9,22 9,12 15,12 15,22"/>
</svg>
Projects
</Link>
<a href="/docs" className="nav-link-muted">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14,2 14,8 20,8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
Docs
</a>
<Link to="/">Groves</Link>
</nav>
</div>
</header>
@@ -58,15 +26,8 @@ function Layout({ children }: LayoutProps) {
</div>
</main>
<footer className="footer">
<div className="container footer-content">
<div className="footer-brand">
<span className="footer-logo">Orchard</span>
<span className="footer-tagline">Content-Addressable Storage</span>
</div>
<div className="footer-links">
<a href="/docs">Documentation</a>
<a href="/api/v1">API</a>
</div>
<div className="container">
<p>Orchard - Content-Addressable Storage System</p>
</div>
</footer>
</div>

View File

@@ -5,97 +5,34 @@
}
:root {
/* Dark mode color palette */
--bg-primary: #0a0a0b;
--bg-secondary: #111113;
--bg-tertiary: #1a1a1d;
--bg-elevated: #222225;
--bg-hover: #2a2a2e;
/* Accent colors - Green/Emerald theme */
--accent-primary: #10b981;
--accent-primary-hover: #34d399;
--accent-secondary: #059669;
--accent-gradient: linear-gradient(135deg, #10b981 0%, #059669 100%);
/* Text colors - improved contrast */
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-tertiary: #9ca3af;
--text-muted: #6b7280;
/* Border colors */
--border-primary: #27272a;
--border-secondary: #3f3f46;
--border-accent: #10b981;
/* Status colors */
--success: #22c55e;
--success-bg: rgba(34, 197, 94, 0.1);
--error: #ef4444;
--error-bg: rgba(239, 68, 68, 0.1);
--warning: #f59e0b;
--warning-bg: rgba(245, 158, 11, 0.1);
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 20px rgba(16, 185, 129, 0.3);
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 350ms ease;
/* Border radius */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
}
html {
color-scheme: dark;
--primary: #2d5a27;
--primary-light: #4a8c3f;
--primary-dark: #1e3d1a;
--secondary: #8b4513;
--background: #f5f5f0;
--surface: #ffffff;
--text: #333333;
--text-light: #666666;
--border: #e0e0e0;
--success: #28a745;
--error: #dc3545;
--warning: #ffc107;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--background);
color: var(--text);
line-height: 1.6;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
a {
color: var(--accent-primary);
color: var(--primary);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--accent-primary-hover);
text-decoration: underline;
}
button {
@@ -104,13 +41,7 @@ button {
}
.container {
max-width: 1280px;
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
/* Selection */
::selection {
background: var(--accent-primary);
color: white;
padding: 0 20px;
}

View File

@@ -1,4 +1,3 @@
/* Page Layout */
.home {
max-width: 1000px;
margin: 0 auto;
@@ -8,92 +7,71 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.02em;
color: var(--primary-dark);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
padding: 0.625rem 1.25rem;
border: none;
border-radius: var(--radius-md);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
transition: all var(--transition-fast);
transition: all 0.2s;
}
.btn-primary {
background: var(--accent-gradient);
background-color: var(--primary);
color: white;
box-shadow: var(--shadow-sm), 0 0 20px rgba(16, 185, 129, 0.2);
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md), 0 0 30px rgba(16, 185, 129, 0.3);
}
.btn-primary:active {
transform: translateY(0);
background-color: var(--primary-dark);
}
.btn-primary:disabled {
opacity: 0.5;
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-primary);
background-color: var(--border);
color: var(--text);
}
.btn-secondary:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--border-secondary);
background-color: #d0d0d0;
}
/* Cards */
.card {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
padding: 24px;
transition: all var(--transition-normal);
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
}
/* Forms */
.form {
margin-bottom: 32px;
margin-bottom: 2rem;
}
.form h3 {
margin-bottom: 20px;
color: var(--text-primary);
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--primary-dark);
}
.form-group {
margin-bottom: 16px;
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 8px;
margin-bottom: 0.375rem;
font-weight: 500;
color: var(--text-secondary);
color: var(--text-light);
font-size: 0.875rem;
}
@@ -102,138 +80,82 @@
.form-group select,
.form-group textarea {
width: 100%;
padding: 12px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 0.625rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.875rem;
color: var(--text-primary);
transition: all var(--transition-fast);
}
.form-group input::placeholder {
color: var(--text-muted);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
background: var(--bg-elevated);
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(45, 90, 39, 0.1);
}
.form-group.checkbox label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
color: var(--text-primary);
}
.form-group.checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent-primary);
gap: 0.5rem;
cursor: pointer;
}
/* Messages */
.form-group.checkbox input {
width: auto;
}
.error-message {
background: var(--error-bg);
border: 1px solid rgba(239, 68, 68, 0.2);
background-color: #fef2f2;
border: 1px solid #fecaca;
color: var(--error);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 16px;
font-size: 0.875rem;
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.success-message {
background: var(--success-bg);
border: 1px solid rgba(34, 197, 94, 0.2);
color: var(--success);
padding: 12px 16px;
border-radius: var(--radius-md);
margin-bottom: 16px;
font-size: 0.875rem;
}
/* Loading */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 64px;
color: var(--text-tertiary);
font-size: 0.875rem;
text-align: center;
padding: 3rem;
color: var(--text-light);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 64px 32px;
color: var(--text-tertiary);
background: var(--bg-secondary);
border: 1px dashed var(--border-secondary);
border-radius: var(--radius-lg);
padding: 3rem;
color: var(--text-light);
background-color: var(--surface);
border: 1px dashed var(--border);
border-radius: 8px;
}
.empty-state p {
font-size: 0.9375rem;
}
/* Project Grid */
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
gap: 1rem;
}
.project-card {
display: block;
color: inherit;
position: relative;
overflow: hidden;
}
.project-card::before {
content: '';
position: absolute;
inset: 0;
background: var(--accent-gradient);
opacity: 0;
transition: opacity var(--transition-normal);
border-radius: var(--radius-lg);
transition: transform 0.2s, box-shadow 0.2s;
}
.project-card:hover {
border-color: var(--border-secondary);
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
color: inherit;
}
.project-card:hover::before {
opacity: 0.03;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
text-decoration: none;
}
.project-card h3 {
color: var(--text-primary);
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
color: var(--primary);
margin-bottom: 0.5rem;
}
.project-card p {
color: var(--text-secondary);
color: var(--text-light);
font-size: 0.875rem;
margin-bottom: 16px;
line-height: 1.5;
margin-bottom: 1rem;
}
.project-meta {
@@ -241,63 +163,24 @@
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
padding-top: 16px;
border-top: 1px solid var(--border-primary);
margin-top: auto;
}
/* Badges */
.badge {
padding: 4px 10px;
border-radius: 100px;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-weight: 500;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.badge-public {
background: var(--success-bg);
color: var(--success);
border: 1px solid rgba(34, 197, 94, 0.2);
background-color: #dcfce7;
color: #166534;
}
.badge-private {
background: var(--warning-bg);
color: var(--warning);
border: 1px solid rgba(245, 158, 11, 0.2);
background-color: #fef3c7;
color: #92400e;
}
.date {
color: var(--text-muted);
}
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 24px;
font-size: 0.875rem;
color: var(--text-tertiary);
}
.breadcrumb a {
color: var(--text-secondary);
transition: color var(--transition-fast);
}
.breadcrumb a:hover {
color: var(--accent-primary);
}
.breadcrumb span {
color: var(--text-primary);
font-weight: 500;
}
.description {
color: var(--text-secondary);
margin-top: 4px;
font-size: 0.9375rem;
color: var(--text-light);
}

View File

@@ -1,32 +1,44 @@
/* Upload Section */
.breadcrumb {
margin-bottom: 1rem;
font-size: 0.875rem;
color: var(--text-light);
}
.breadcrumb a {
color: var(--primary);
}
.breadcrumb span {
color: var(--text);
font-weight: 500;
}
.description {
color: var(--text-light);
margin-top: 0.25rem;
}
.success-message {
background-color: #f0fdf4;
border: 1px solid #bbf7d0;
color: var(--success);
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.upload-section {
margin-bottom: 32px;
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);
margin-bottom: 2rem;
}
.upload-section h3 {
margin-bottom: 20px;
color: var(--text-primary);
font-size: 1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.upload-section h3::before {
content: '';
display: block;
width: 4px;
height: 20px;
background: var(--accent-gradient);
border-radius: 2px;
margin-bottom: 1rem;
color: var(--primary-dark);
}
.upload-form {
display: flex;
gap: 16px;
gap: 1rem;
align-items: flex-end;
flex-wrap: wrap;
}
@@ -37,42 +49,17 @@
min-width: 200px;
}
.upload-form .form-group input[type="file"] {
padding: 10px 16px;
background: var(--bg-tertiary);
cursor: pointer;
}
.upload-form .form-group input[type="file"]::file-selector-button {
background: var(--accent-gradient);
color: white;
border: none;
padding: 6px 12px;
border-radius: var(--radius-sm);
margin-right: 12px;
cursor: pointer;
font-weight: 500;
font-size: 0.8125rem;
}
/* Section Headers */
h2 {
margin-bottom: 16px;
color: var(--text-primary);
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 1rem;
color: var(--primary-dark);
}
/* Tags Table */
.tags-table {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
margin-bottom: 32px;
margin-bottom: 2rem;
}
.tags-table table {
@@ -82,146 +69,63 @@ h2 {
.tags-table th,
.tags-table td {
padding: 14px 20px;
padding: 0.875rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-primary);
border-bottom: 1px solid var(--border);
}
.tags-table th {
background: var(--bg-tertiary);
background-color: #f9f9f9;
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
color: var(--text-light);
}
.tags-table tr:last-child td {
border-bottom: none;
}
.tags-table tbody tr {
transition: background var(--transition-fast);
}
.tags-table tbody tr:hover {
background: var(--bg-tertiary);
}
.tags-table td strong {
color: var(--accent-primary);
font-weight: 600;
.tags-table tr:hover {
background-color: #f9f9f9;
}
.artifact-id {
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);
font-family: monospace;
font-size: 0.875rem;
color: var(--text-light);
}
.btn-small {
padding: 6px 12px;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
/* Usage Section */
.usage-section {
margin-top: 32px;
background: var(--bg-secondary);
margin-top: 2rem;
}
.usage-section h3 {
margin-bottom: 12px;
color: var(--text-primary);
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--primary-dark);
}
.usage-section p {
color: var(--text-secondary);
margin-bottom: 12px;
color: var(--text-light);
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.usage-section pre {
background: #0d0d0f;
border: 1px solid var(--border-primary);
padding: 16px 20px;
border-radius: var(--radius-md);
background-color: #1e1e1e;
color: #d4d4d4;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
margin-bottom: 16px;
margin-bottom: 1rem;
}
.usage-section code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem;
color: #e2e8f0;
}
/* Syntax highlighting for code blocks */
.usage-section pre {
position: relative;
}
.usage-section pre::before {
content: 'bash';
position: absolute;
top: 8px;
right: 12px;
font-size: 0.6875rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Copy button for code blocks (optional enhancement) */
.code-block {
position: relative;
}
.code-block .copy-btn {
position: absolute;
top: 8px;
right: 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
color: var(--text-secondary);
padding: 4px 8px;
border-radius: var(--radius-sm);
font-size: 0.6875rem;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast);
}
.code-block:hover .copy-btn {
opacity: 1;
}
.code-block .copy-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.upload-form {
flex-direction: column;
align-items: stretch;
}
.upload-form .form-group {
min-width: 100%;
}
.tags-table {
overflow-x: auto;
}
.tags-table table {
min-width: 500px;
}
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.875rem;
}

View File

@@ -8,28 +8,13 @@ export interface Project {
created_by: string;
}
export interface TagSummary {
name: string;
artifact_id: string;
created_at: string;
}
export interface Package {
id: string;
project_id: string;
name: string;
description: string | null;
format: string;
platform: string;
created_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 {