From e89947f3d384854aa83f38fe06a2e46a15a3bbf1 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Fri, 12 Dec 2025 10:23:44 -0600 Subject: [PATCH] Develop Frontend Components for Project, Package, and Instance Views --- README.md | 25 +- frontend/src/api.ts | 91 ++++-- frontend/src/components/Badge.css | 43 +++ frontend/src/components/Badge.tsx | 17 ++ frontend/src/components/Breadcrumb.css | 38 +++ frontend/src/components/Breadcrumb.tsx | 38 +++ frontend/src/components/Card.css | 78 +++++ frontend/src/components/Card.tsx | 59 ++++ frontend/src/components/DataTable.css | 100 +++++++ frontend/src/components/DataTable.tsx | 86 ++++++ frontend/src/components/FilterChip.css | 63 ++++ frontend/src/components/FilterChip.tsx | 47 +++ frontend/src/components/Pagination.css | 64 +++++ frontend/src/components/Pagination.tsx | 98 +++++++ frontend/src/components/SearchInput.css | 57 ++++ frontend/src/components/SearchInput.tsx | 74 +++++ frontend/src/components/SortDropdown.css | 95 ++++++ frontend/src/components/SortDropdown.tsx | 108 +++++++ frontend/src/components/index.ts | 9 + frontend/src/pages/Home.css | 173 +++++++++++ frontend/src/pages/Home.tsx | 150 ++++++++-- frontend/src/pages/PackagePage.css | 95 +++++- frontend/src/pages/PackagePage.tsx | 351 +++++++++++++++++++---- frontend/src/pages/ProjectPage.tsx | 283 +++++++++++++++--- frontend/src/types.ts | 51 ++++ 25 files changed, 2123 insertions(+), 170 deletions(-) create mode 100644 frontend/src/components/Badge.css create mode 100644 frontend/src/components/Badge.tsx create mode 100644 frontend/src/components/Breadcrumb.css create mode 100644 frontend/src/components/Breadcrumb.tsx create mode 100644 frontend/src/components/Card.css create mode 100644 frontend/src/components/Card.tsx create mode 100644 frontend/src/components/DataTable.css create mode 100644 frontend/src/components/DataTable.tsx create mode 100644 frontend/src/components/FilterChip.css create mode 100644 frontend/src/components/FilterChip.tsx create mode 100644 frontend/src/components/Pagination.css create mode 100644 frontend/src/components/Pagination.tsx create mode 100644 frontend/src/components/SearchInput.css create mode 100644 frontend/src/components/SearchInput.tsx create mode 100644 frontend/src/components/SortDropdown.css create mode 100644 frontend/src/components/SortDropdown.tsx create mode 100644 frontend/src/components/index.ts diff --git a/README.md b/README.md index 8abe1db..265da86 100644 --- a/README.md +++ b/README.md @@ -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 - **PostgreSQL Metadata** - Relational database for metadata, access control, and audit trails - **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 - **Helm Chart** - Kubernetes deployment with PostgreSQL, MinIO, and Redis subcharts - **Multipart Upload** - Automatic multipart upload for files larger than 100MB @@ -314,10 +320,21 @@ orchard/ │ └── requirements.txt ├── frontend/ │ ├── 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 -│ │ ├── api.ts # API client -│ │ ├── types.ts # TypeScript types +│ │ │ ├── Home.tsx # Project list +│ │ │ ├── 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 │ │ └── main.tsx │ ├── index.html diff --git a/frontend/src/api.ts b/frontend/src/api.ts index bd876ce..e4ad39e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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'; @@ -10,21 +23,26 @@ async function handleResponse(response: Response): Promise { return response.json(); } -// Paginated response type -interface PaginatedResponse { - items: T[]; - pagination: { - page: number; - limit: number; - total: number; - total_pages: number; - }; +function buildQueryString(params: Record): string { + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + searchParams.append(key, String(value)); + } + }); + const query = searchParams.toString(); + return query ? `?${query}` : ''; } // Project API -export async function listProjects(): Promise { - const response = await fetch(`${API_BASE}/projects`); - const data = await handleResponse>(response); +export async function listProjects(params: ListParams = {}): Promise> { + const query = buildQueryString(params as Record); + const response = await fetch(`${API_BASE}/projects${query}`); + return handleResponse>(response); +} + +export async function listProjectsSimple(params: ListParams = {}): Promise { + const data = await listProjects(params); return data.items; } @@ -43,12 +61,22 @@ export async function getProject(name: string): Promise { } // Package API -export async function listPackages(projectName: string): Promise { - const response = await fetch(`${API_BASE}/project/${projectName}/packages`); - const data = await handleResponse>(response); +export async function listPackages(projectName: string, params: PackageListParams = {}): Promise> { + const query = buildQueryString(params as Record); + const response = await fetch(`${API_BASE}/project/${projectName}/packages${query}`); + return handleResponse>(response); +} + +export async function listPackagesSimple(projectName: string, params: PackageListParams = {}): Promise { + const data = await listPackages(projectName, params); return data.items; } +export async function getPackage(projectName: string, packageName: string): Promise { + const response = await fetch(`${API_BASE}/project/${projectName}/packages/${packageName}`); + return handleResponse(response); +} + export async function createPackage(projectName: string, data: { name: string; description?: string }): Promise { const response = await fetch(`${API_BASE}/project/${projectName}/packages`, { method: 'POST', @@ -59,9 +87,20 @@ export async function createPackage(projectName: string, data: { name: string; d } // Tag API -export async function listTags(projectName: string, packageName: string): Promise { - const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/tags`); - return handleResponse(response); +export async function listTags(projectName: string, packageName: string, params: TagListParams = {}): Promise> { + const query = buildQueryString(params as Record); + const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/tags${query}`); + return handleResponse>(response); +} + +export async function listTagsSimple(projectName: string, packageName: string, params: TagListParams = {}): Promise { + const data = await listTags(projectName, packageName, params); + return data.items; +} + +export async function getTag(projectName: string, packageName: string, tagName: string): Promise { + const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/tags/${tagName}`); + return handleResponse(response); } export async function createTag(projectName: string, packageName: string, data: { name: string; artifact_id: string }): Promise { @@ -74,9 +113,19 @@ export async function createTag(projectName: string, packageName: string, data: } // Artifact API -export async function getArtifact(artifactId: string): Promise { +export async function getArtifact(artifactId: string): Promise { const response = await fetch(`${API_BASE}/artifact/${artifactId}`); - return handleResponse(response); + return handleResponse(response); +} + +export async function listPackageArtifacts( + projectName: string, + packageName: string, + params: ArtifactListParams = {} +): Promise> { + const query = buildQueryString(params as Record); + const response = await fetch(`${API_BASE}/project/${projectName}/${packageName}/artifacts${query}`); + return handleResponse>(response); } // Upload diff --git a/frontend/src/components/Badge.css b/frontend/src/components/Badge.css new file mode 100644 index 0000000..e75f030 --- /dev/null +++ b/frontend/src/components/Badge.css @@ -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); +} diff --git a/frontend/src/components/Badge.tsx b/frontend/src/components/Badge.tsx new file mode 100644 index 0000000..6a007ed --- /dev/null +++ b/frontend/src/components/Badge.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/src/components/Breadcrumb.css b/frontend/src/components/Breadcrumb.css new file mode 100644 index 0000000..854a4bc --- /dev/null +++ b/frontend/src/components/Breadcrumb.css @@ -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; +} diff --git a/frontend/src/components/Breadcrumb.tsx b/frontend/src/components/Breadcrumb.tsx new file mode 100644 index 0000000..4952d8d --- /dev/null +++ b/frontend/src/components/Breadcrumb.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/Card.css b/frontend/src/components/Card.css new file mode 100644 index 0000000..a3ed709 --- /dev/null +++ b/frontend/src/components/Card.css @@ -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; +} diff --git a/frontend/src/components/Card.tsx b/frontend/src/components/Card.tsx new file mode 100644 index 0000000..cc28630 --- /dev/null +++ b/frontend/src/components/Card.tsx @@ -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 ( + + {children} + + ); + } + + if (onClick) { + return ( +
+ {children} +
+ ); + } + + return
{children}
; +} + +interface CardHeaderProps { + children: ReactNode; + className?: string; +} + +export function CardHeader({ children, className = '' }: CardHeaderProps) { + return
{children}
; +} + +interface CardBodyProps { + children: ReactNode; + className?: string; +} + +export function CardBody({ children, className = '' }: CardBodyProps) { + return
{children}
; +} + +interface CardFooterProps { + children: ReactNode; + className?: string; +} + +export function CardFooter({ children, className = '' }: CardFooterProps) { + return
{children}
; +} diff --git a/frontend/src/components/DataTable.css b/frontend/src/components/DataTable.css new file mode 100644 index 0000000..1716990 --- /dev/null +++ b/frontend/src/components/DataTable.css @@ -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; +} diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx new file mode 100644 index 0000000..dc8e048 --- /dev/null +++ b/frontend/src/components/DataTable.tsx @@ -0,0 +1,86 @@ +import { ReactNode } from 'react'; +import './DataTable.css'; + +interface Column { + key: string; + header: string; + render: (item: T) => ReactNode; + className?: string; + sortable?: boolean; +} + +interface DataTableProps { + data: T[]; + columns: Column[]; + keyExtractor: (item: T) => string; + emptyMessage?: string; + className?: string; + onSort?: (key: string) => void; + sortKey?: string; + sortOrder?: 'asc' | 'desc'; +} + +export function DataTable({ + data, + columns, + keyExtractor, + emptyMessage = 'No data available', + className = '', + onSort, + sortKey, + sortOrder, +}: DataTableProps) { + if (data.length === 0) { + return ( +
+

{emptyMessage}

+
+ ); + } + + return ( +
+ + + + {columns.map((column) => ( + + ))} + + + + {data.map((item) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
column.sortable && onSort?.(column.key)} + > + + {column.header} + {column.sortable && sortKey === column.key && ( + + + + )} + +
+ {column.render(item)} +
+
+ ); +} diff --git a/frontend/src/components/FilterChip.css b/frontend/src/components/FilterChip.css new file mode 100644 index 0000000..1d02126 --- /dev/null +++ b/frontend/src/components/FilterChip.css @@ -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); +} diff --git a/frontend/src/components/FilterChip.tsx b/frontend/src/components/FilterChip.tsx new file mode 100644 index 0000000..ccf4551 --- /dev/null +++ b/frontend/src/components/FilterChip.tsx @@ -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 ( + + {label}: + {value} + + + ); +} + +interface FilterChipGroupProps { + children: React.ReactNode; + onClearAll?: () => void; + className?: string; +} + +export function FilterChipGroup({ children, onClearAll, className = '' }: FilterChipGroupProps) { + return ( +
+ {children} + {onClearAll && ( + + )} +
+ ); +} diff --git a/frontend/src/components/Pagination.css b/frontend/src/components/Pagination.css new file mode 100644 index 0000000..93e5df1 --- /dev/null +++ b/frontend/src/components/Pagination.css @@ -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; +} diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx new file mode 100644 index 0000000..fc7274e --- /dev/null +++ b/frontend/src/components/Pagination.tsx @@ -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 ( +
+ + Showing {start}-{end} of {total} + + +
+ + + {getPageNumbers().map((pageNum, index) => + pageNum === 'ellipsis' ? ( + + ... + + ) : ( + + ) + )} + + +
+
+ ); +} diff --git a/frontend/src/components/SearchInput.css b/frontend/src/components/SearchInput.css new file mode 100644 index 0000000..d80e9b3 --- /dev/null +++ b/frontend/src/components/SearchInput.css @@ -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); +} diff --git a/frontend/src/components/SearchInput.tsx b/frontend/src/components/SearchInput.tsx new file mode 100644 index 0000000..48463c4 --- /dev/null +++ b/frontend/src/components/SearchInput.tsx @@ -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 ( +
+ + + + + setLocalValue(e.target.value)} + placeholder={placeholder} + className="search-input__field" + /> + {localValue && ( + + )} +
+ ); +} diff --git a/frontend/src/components/SortDropdown.css b/frontend/src/components/SortDropdown.css new file mode 100644 index 0000000..dfe8def --- /dev/null +++ b/frontend/src/components/SortDropdown.css @@ -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; +} diff --git a/frontend/src/components/SortDropdown.tsx b/frontend/src/components/SortDropdown.tsx new file mode 100644 index 0000000..16533f4 --- /dev/null +++ b/frontend/src/components/SortDropdown.tsx @@ -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(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 ( +
+ + + + + {isOpen && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts new file mode 100644 index 0000000..4b5575f --- /dev/null +++ b/frontend/src/components/index.ts @@ -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'; diff --git a/frontend/src/pages/Home.css b/frontend/src/pages/Home.css index 7d84cf8..3bde2bb 100644 --- a/frontend/src/pages/Home.css +++ b/frontend/src/pages/Home.css @@ -272,6 +272,179 @@ 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 { display: flex; diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 67980f0..121f0c6 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,33 +1,67 @@ -import { useState, useEffect } from 'react'; -import { Link } from 'react-router-dom'; -import { Project } from '../types'; +import { useState, useEffect, useCallback } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { Project, PaginatedResponse } from '../types'; 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'; +const SORT_OPTIONS: SortOption[] = [ + { value: 'name', label: 'Name' }, + { value: 'created_at', label: 'Created' }, + { value: 'updated_at', label: 'Updated' }, +]; + function Home() { - const [projects, setProjects] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); + + const [projectsData, setProjectsData] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [showForm, setShowForm] = useState(false); const [newProject, setNewProject] = useState({ name: '', description: '', is_public: true }); const [creating, setCreating] = useState(false); - useEffect(() => { - loadProjects(); - }, []); + // Get params from URL + 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) => { + 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 { setLoading(true); - const data = await listProjects(); - setProjects(data); + const data = await listProjects({ page, search, sort, order }); + setProjectsData(data); setError(null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load projects'); } finally { setLoading(false); } - } + }, [page, search, sort, order]); + + useEffect(() => { + loadProjects(); + }, [loadProjects]); async function handleCreateProject(e: React.FormEvent) { 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
Loading projects...
; } @@ -99,27 +153,65 @@ function Home() { )} +
+ + +
+ + {hasActiveFilters && ( + + {search && handleSearchChange('')} />} + + )} + {projects.length === 0 ? (
-

No projects yet. Create your first project to get started!

+ {hasActiveFilters ? ( +

No projects match your filters. Try adjusting your search.

+ ) : ( +

No projects yet. Create your first project to get started!

+ )}
) : ( -
- {projects.map((project) => ( - -

{project.name}

- {project.description &&

{project.description}

} -
- - {project.is_public ? 'Public' : 'Private'} - - - Created {new Date(project.created_at).toLocaleDateString()} - -
- - ))} -
+ <> +
+ {projects.map((project) => ( + +

{project.name}

+ {project.description &&

{project.description}

} +
+ + {project.is_public ? 'Public' : 'Private'} + +
+ Created {new Date(project.created_at).toLocaleDateString()} + {project.updated_at !== project.created_at && ( + Updated {new Date(project.updated_at).toLocaleDateString()} + )} +
+
+
+ by {project.created_by} +
+ + ))} +
+ + {pagination && pagination.total_pages > 1 && ( + + )} + )} ); diff --git a/frontend/src/pages/PackagePage.css b/frontend/src/pages/PackagePage.css index 0a85418..b7e4782 100644 --- a/frontend/src/pages/PackagePage.css +++ b/frontend/src/pages/PackagePage.css @@ -206,6 +206,92 @@ h2 { 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 */ @media (max-width: 768px) { .upload-form { @@ -217,11 +303,8 @@ h2 { min-width: 100%; } - .tags-table { - overflow-x: auto; - } - - .tags-table table { - min-width: 500px; + .package-header-stats { + flex-wrap: wrap; + gap: 12px; } } diff --git a/frontend/src/pages/PackagePage.tsx b/frontend/src/pages/PackagePage.tsx index cf598cf..cc4acb0 100644 --- a/frontend/src/pages/PackagePage.tsx +++ b/frontend/src/pages/PackagePage.tsx @@ -1,13 +1,64 @@ -import { useState, useEffect, useRef } from 'react'; -import { useParams, Link } from 'react-router-dom'; -import { Tag } from '../types'; -import { listTags, uploadArtifact, getDownloadUrl } from '../api'; +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; +import { TagDetail, Package, PaginatedResponse } from '../types'; +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 './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 ( + + ); +} + function PackagePage() { const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>(); - const [tags, setTags] = useState([]); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + + const [pkg, setPkg] = useState(null); + const [tagsData, setTagsData] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [uploading, setUploading] = useState(false); @@ -15,24 +66,61 @@ function PackagePage() { const [tag, setTag] = useState(''); const fileInputRef = useRef(null); - useEffect(() => { - if (projectName && packageName) { - loadTags(); - } - }, [projectName, packageName]); + // Get params from URL + 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'; + + const updateParams = useCallback( + (updates: Record) => { + 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 { setLoading(true); - const data = await listTags(projectName!, packageName!); - setTags(data); + const [pkgData, tagsResult] = await Promise.all([ + getPackage(projectName, packageName), + listTags(projectName, packageName, { page, search, sort, order }), + ]); + setPkg(pkgData); + setTagsData(tagsResult); setError(null); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load tags'); + setError(err instanceof Error ? err.message : 'Failed to load data'); } finally { 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) { e.preventDefault(); @@ -51,7 +139,7 @@ function PackagePage() { if (fileInputRef.current) { fileInputRef.current.value = ''; } - loadTags(); + loadData(); } catch (err) { setError(err instanceof Error ? err.message : 'Upload failed'); } 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) => {t.name}, + }, + { + key: 'artifact_id', + header: 'Artifact ID', + render: (t: TagDetail) => ( +
+ {t.artifact_id.substring(0, 12)}... + +
+ ), + }, + { + key: 'size', + header: 'Size', + render: (t: TagDetail) => {formatBytes(t.artifact_size)}, + }, + { + key: 'content_type', + header: 'Type', + render: (t: TagDetail) => ( + {t.artifact_content_type || '-'} + ), + }, + { + key: 'original_name', + header: 'Filename', + className: 'cell-truncate', + render: (t: TagDetail) => ( + {t.artifact_original_name || '-'} + ), + }, + { + key: 'created_at', + header: 'Created', + sortable: true, + render: (t: TagDetail) => ( +
+ {new Date(t.created_at).toLocaleString()} + by {t.created_by} +
+ ), + }, + { + key: 'actions', + header: 'Actions', + render: (t: TagDetail) => ( + + Download + + ), + }, + ]; + + if (loading && !tagsData) { return
Loading...
; } return (
- +
-

{packageName}

+
+
+

{packageName}

+ {pkg && {pkg.format}} +
+ {pkg?.description &&

{pkg.description}

} +
+ + in {projectName} + + {pkg && ( + <> + Created {new Date(pkg.created_at).toLocaleDateString()} + {pkg.updated_at !== pkg.created_at && ( + Updated {new Date(pkg.updated_at).toLocaleDateString()} + )} + + )} +
+ {pkg && (pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && ( +
+ {pkg.tag_count !== undefined && ( + + {pkg.tag_count} tags + + )} + {pkg.artifact_count !== undefined && ( + + {pkg.artifact_count} artifacts + + )} + {pkg.total_size !== undefined && pkg.total_size > 0 && ( + + {formatBytes(pkg.total_size)} total + + )} + {pkg.latest_tag && ( + + Latest: {pkg.latest_tag} + + )} +
+ )} +
{error &&
{error}
} @@ -81,12 +299,7 @@ function PackagePage() {
- +
@@ -104,42 +317,54 @@ function PackagePage() {
-

Tags / Versions

- {tags.length === 0 ? ( -
-

No tags yet. Upload an artifact with a tag to create one!

-
- ) : ( -
- - - - - - - - - - - {tags.map((t) => ( - - - - - - - ))} - -
TagArtifact IDCreatedActions
{t.name}{t.artifact_id.substring(0, 12)}...{new Date(t.created_at).toLocaleString()} - - Download - -
-
+
+

Tags / Versions

+
+ +
+ + +
+ + {hasActiveFilters && ( + + {search && handleSearchChange('')} />} + + )} + + t.id} + emptyMessage={ + hasActiveFilters + ? 'No tags match your filters. Try adjusting your search.' + : 'No tags yet. Upload an artifact with a tag to create one!' + } + onSort={(key) => { + if (key === sort) { + handleSortChange(key, order === 'asc' ? 'desc' : 'asc'); + } else { + handleSortChange(key, 'asc'); + } + }} + sortKey={sort} + sortOrder={order} + /> + + {pagination && pagination.total_pages > 1 && ( + )}
diff --git a/frontend/src/pages/ProjectPage.tsx b/frontend/src/pages/ProjectPage.tsx index 2b666c8..43e2d6f 100644 --- a/frontend/src/pages/ProjectPage.tsx +++ b/frontend/src/pages/ProjectPage.tsx @@ -1,48 +1,107 @@ -import { useState, useEffect } from 'react'; -import { useParams, Link } from 'react-router-dom'; -import { Project, Package } from '../types'; +import { useState, useEffect, useCallback } from 'react'; +import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom'; +import { Project, Package, PaginatedResponse } from '../types'; 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'; +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() { const { projectName } = useParams<{ projectName: string }>(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const [project, setProject] = useState(null); - const [packages, setPackages] = useState([]); + const [packagesData, setPackagesData] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); 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); - useEffect(() => { - if (projectName) { - loadData(); - } - }, [projectName]); + // Get params from URL + 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'; + const format = searchParams.get('format') || ''; + + const updateParams = useCallback( + (updates: Record) => { + 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 { setLoading(true); - const [projectData, packagesData] = await Promise.all([ - getProject(projectName!), - listPackages(projectName!), + const [projectData, packagesResult] = await Promise.all([ + getProject(projectName), + listPackages(projectName, { page, search, sort, order, format: format || undefined }), ]); setProject(projectData); - setPackages(packagesData); + setPackagesData(packagesResult); setError(null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load data'); } finally { 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) { e.preventDefault(); try { setCreating(true); await createPackage(projectName!, newPackage); - setNewPackage({ name: '', description: '' }); + setNewPackage({ name: '', description: '', format: 'generic', platform: 'any' }); setShowForm(false); loadData(); } 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
Loading...
; } @@ -62,14 +145,29 @@ function ProjectPage() { return (
- +
-
-

{project.name}

+
+
+

{project.name}

+ + {project.is_public ? 'Public' : 'Private'} + +
{project.description &&

{project.description}

} +
+ Created {new Date(project.created_at).toLocaleDateString()} + {project.updated_at !== project.created_at && ( + Updated {new Date(project.updated_at).toLocaleDateString()} + )} + by {project.created_by} +