- Remove SearchInput from Home page (use GlobalSearch in header instead) - Rename "Search packages..." to "Filter packages..." on ProjectPage - Rename "Search tags..." to "Filter tags..." on PackagePage - Update FilterChip labels from "Search" to "Filter" This differentiates the global search (header) from page-level filtering.
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
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 (
|
|
<button className="copy-btn" onClick={handleCopy} title="Copy to clipboard">
|
|
{copied ? (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<polyline points="20 6 9 17 4 12" />
|
|
</svg>
|
|
) : (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function PackagePage() {
|
|
const { projectName, packageName } = useParams<{ projectName: string; packageName: string }>();
|
|
const navigate = useNavigate();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
const [pkg, setPkg] = useState<Package | null>(null);
|
|
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [uploadResult, setUploadResult] = useState<string | null>(null);
|
|
const [tag, setTag] = useState('');
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// 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<string, string | undefined>) => {
|
|
const newParams = new URLSearchParams(searchParams);
|
|
Object.entries(updates).forEach(([key, value]) => {
|
|
if (value === undefined || value === '' || (key === 'page' && value === '1')) {
|
|
newParams.delete(key);
|
|
} else {
|
|
newParams.set(key, value);
|
|
}
|
|
});
|
|
setSearchParams(newParams);
|
|
},
|
|
[searchParams, setSearchParams]
|
|
);
|
|
|
|
const loadData = useCallback(async () => {
|
|
if (!projectName || !packageName) return;
|
|
|
|
try {
|
|
setLoading(true);
|
|
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 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();
|
|
const file = fileInputRef.current?.files?.[0];
|
|
if (!file) {
|
|
setError('Please select a file');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setUploading(true);
|
|
setError(null);
|
|
const result = await uploadArtifact(projectName!, packageName!, file, tag || undefined);
|
|
setUploadResult(`Uploaded successfully! Artifact ID: ${result.artifact_id}`);
|
|
setTag('');
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
loadData();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Upload failed');
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}
|
|
|
|
const handleSearchChange = (value: string) => {
|
|
updateParams({ search: value, page: '1' });
|
|
};
|
|
|
|
const handleSortChange = (newSort: string, newOrder: 'asc' | 'desc') => {
|
|
updateParams({ sort: newSort, order: newOrder, page: '1' });
|
|
};
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
updateParams({ page: String(newPage) });
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setSearchParams({});
|
|
};
|
|
|
|
const hasActiveFilters = search !== '';
|
|
const tags = tagsData?.items || [];
|
|
const pagination = tagsData?.pagination;
|
|
|
|
const columns = [
|
|
{
|
|
key: 'name',
|
|
header: 'Tag',
|
|
sortable: true,
|
|
render: (t: TagDetail) => <strong>{t.name}</strong>,
|
|
},
|
|
{
|
|
key: 'artifact_id',
|
|
header: 'Artifact ID',
|
|
render: (t: TagDetail) => (
|
|
<div className="artifact-id-cell">
|
|
<code className="artifact-id">{t.artifact_id.substring(0, 12)}...</code>
|
|
<CopyButton text={t.artifact_id} />
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'size',
|
|
header: 'Size',
|
|
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
|
|
},
|
|
{
|
|
key: 'content_type',
|
|
header: 'Type',
|
|
render: (t: TagDetail) => (
|
|
<span className="content-type">{t.artifact_content_type || '-'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'original_name',
|
|
header: 'Filename',
|
|
className: 'cell-truncate',
|
|
render: (t: TagDetail) => (
|
|
<span title={t.artifact_original_name || undefined}>{t.artifact_original_name || '-'}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
header: 'Created',
|
|
sortable: true,
|
|
render: (t: TagDetail) => (
|
|
<div className="created-cell">
|
|
<span>{new Date(t.created_at).toLocaleString()}</span>
|
|
<span className="created-by">by {t.created_by}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: 'actions',
|
|
header: 'Actions',
|
|
render: (t: TagDetail) => (
|
|
<a
|
|
href={getDownloadUrl(projectName!, packageName!, t.name)}
|
|
className="btn btn-secondary btn-small"
|
|
download
|
|
>
|
|
Download
|
|
</a>
|
|
),
|
|
},
|
|
];
|
|
|
|
if (loading && !tagsData) {
|
|
return <div className="loading">Loading...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="home">
|
|
<Breadcrumb
|
|
items={[
|
|
{ label: 'Projects', href: '/' },
|
|
{ label: projectName!, href: `/project/${projectName}` },
|
|
{ label: packageName! },
|
|
]}
|
|
/>
|
|
|
|
<div className="page-header">
|
|
<div className="page-header__info">
|
|
<div className="page-header__title-row">
|
|
<h1>{packageName}</h1>
|
|
{pkg && <Badge variant="default">{pkg.format}</Badge>}
|
|
</div>
|
|
{pkg?.description && <p className="description">{pkg.description}</p>}
|
|
<div className="page-header__meta">
|
|
<span className="meta-item">
|
|
in <a href={`/project/${projectName}`}>{projectName}</a>
|
|
</span>
|
|
{pkg && (
|
|
<>
|
|
<span className="meta-item">Created {new Date(pkg.created_at).toLocaleDateString()}</span>
|
|
{pkg.updated_at !== pkg.created_at && (
|
|
<span className="meta-item">Updated {new Date(pkg.updated_at).toLocaleDateString()}</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
{pkg && (pkg.tag_count !== undefined || pkg.artifact_count !== undefined) && (
|
|
<div className="package-header-stats">
|
|
{pkg.tag_count !== undefined && (
|
|
<span className="stat-item">
|
|
<strong>{pkg.tag_count}</strong> tags
|
|
</span>
|
|
)}
|
|
{pkg.artifact_count !== undefined && (
|
|
<span className="stat-item">
|
|
<strong>{pkg.artifact_count}</strong> artifacts
|
|
</span>
|
|
)}
|
|
{pkg.total_size !== undefined && pkg.total_size > 0 && (
|
|
<span className="stat-item">
|
|
<strong>{formatBytes(pkg.total_size)}</strong> total
|
|
</span>
|
|
)}
|
|
{pkg.latest_tag && (
|
|
<span className="stat-item">
|
|
Latest: <strong className="accent">{pkg.latest_tag}</strong>
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{error && <div className="error-message">{error}</div>}
|
|
{uploadResult && <div className="success-message">{uploadResult}</div>}
|
|
|
|
<div className="upload-section card">
|
|
<h3>Upload Artifact</h3>
|
|
<form onSubmit={handleUpload} className="upload-form">
|
|
<div className="form-group">
|
|
<label htmlFor="file">File</label>
|
|
<input id="file" type="file" ref={fileInputRef} required />
|
|
</div>
|
|
<div className="form-group">
|
|
<label htmlFor="tag">Tag (optional)</label>
|
|
<input
|
|
id="tag"
|
|
type="text"
|
|
value={tag}
|
|
onChange={(e) => setTag(e.target.value)}
|
|
placeholder="v1.0.0, latest, stable..."
|
|
/>
|
|
</div>
|
|
<button type="submit" className="btn btn-primary" disabled={uploading}>
|
|
{uploading ? 'Uploading...' : 'Upload'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="section-header">
|
|
<h2>Tags / Versions</h2>
|
|
</div>
|
|
|
|
<div className="list-controls">
|
|
<SearchInput
|
|
value={search}
|
|
onChange={handleSearchChange}
|
|
placeholder="Filter tags..."
|
|
className="list-controls__search"
|
|
/>
|
|
<SortDropdown options={SORT_OPTIONS} value={sort} order={order} onChange={handleSortChange} />
|
|
</div>
|
|
|
|
{hasActiveFilters && (
|
|
<FilterChipGroup onClearAll={clearFilters}>
|
|
{search && <FilterChip label="Filter" value={search} onRemove={() => handleSearchChange('')} />}
|
|
</FilterChipGroup>
|
|
)}
|
|
|
|
<DataTable
|
|
data={tags}
|
|
columns={columns}
|
|
keyExtractor={(t) => 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 && (
|
|
<Pagination
|
|
page={pagination.page}
|
|
totalPages={pagination.total_pages}
|
|
total={pagination.total}
|
|
limit={pagination.limit}
|
|
onPageChange={handlePageChange}
|
|
/>
|
|
)}
|
|
|
|
<div className="usage-section card">
|
|
<h3>Usage</h3>
|
|
<p>Download artifacts using:</p>
|
|
<pre>
|
|
<code>curl -O {window.location.origin}/api/v1/project/{projectName}/{packageName}/+/latest</code>
|
|
</pre>
|
|
<p>Or with a specific tag:</p>
|
|
<pre>
|
|
<code>curl -O {window.location.origin}/api/v1/project/{projectName}/{packageName}/+/v1.0.0</code>
|
|
</pre>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default PackagePage;
|