Improve PyPI proxy and Package page UX
PyPI proxy improvements: - Set package format to "pypi" instead of "generic" - Extract version from filename and create PackageVersion record - Support .whl, .tar.gz, and .zip filename formats Package page UX overhaul: - Move upload to header button with modal - Simplify table: combine Tag/Version, remove Type and Artifact ID columns - Add row action menu (⋯) with: Copy ID, Ensure File, Create Tag, Dependencies - Remove cluttered "Download by Artifact ID" and "Create/Update Tag" sections - Add modals for upload and create tag actions - Cleaner, more scannable table layout
This commit is contained in:
@@ -17,7 +17,7 @@ from fastapi.responses import StreamingResponse, HTMLResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .database import get_db
|
from .database import get_db
|
||||||
from .models import UpstreamSource, CachedUrl, Artifact, Project, Package, Tag
|
from .models import UpstreamSource, CachedUrl, Artifact, Project, Package, Tag, PackageVersion
|
||||||
from .storage import S3Storage, get_storage
|
from .storage import S3Storage, get_storage
|
||||||
from .config import get_env_upstream_sources
|
from .config import get_env_upstream_sources
|
||||||
|
|
||||||
@@ -30,6 +30,36 @@ PROXY_CONNECT_TIMEOUT = 30.0
|
|||||||
PROXY_READ_TIMEOUT = 60.0
|
PROXY_READ_TIMEOUT = 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_pypi_version(filename: str) -> Optional[str]:
|
||||||
|
"""Extract version from PyPI filename.
|
||||||
|
|
||||||
|
Handles formats like:
|
||||||
|
- cowsay-6.1-py3-none-any.whl
|
||||||
|
- cowsay-1.0.tar.gz
|
||||||
|
- some_package-1.2.3.post1-cp39-cp39-linux_x86_64.whl
|
||||||
|
"""
|
||||||
|
# Remove extension
|
||||||
|
if filename.endswith('.whl'):
|
||||||
|
# Wheel: name-version-pytag-abitag-platform.whl
|
||||||
|
parts = filename[:-4].split('-')
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return parts[1]
|
||||||
|
elif filename.endswith('.tar.gz'):
|
||||||
|
# Source: name-version.tar.gz
|
||||||
|
base = filename[:-7]
|
||||||
|
# Find the last hyphen that precedes a version-like string
|
||||||
|
match = re.match(r'^(.+)-(\d+.*)$', base)
|
||||||
|
if match:
|
||||||
|
return match.group(2)
|
||||||
|
elif filename.endswith('.zip'):
|
||||||
|
# Egg/zip: name-version.zip
|
||||||
|
base = filename[:-4]
|
||||||
|
match = re.match(r'^(.+)-(\d+.*)$', base)
|
||||||
|
if match:
|
||||||
|
return match.group(2)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_pypi_upstream_sources(db: Session) -> list[UpstreamSource]:
|
def _get_pypi_upstream_sources(db: Session) -> list[UpstreamSource]:
|
||||||
"""Get all enabled upstream sources configured for PyPI."""
|
"""Get all enabled upstream sources configured for PyPI."""
|
||||||
# Get database sources
|
# Get database sources
|
||||||
@@ -507,6 +537,7 @@ async def pypi_download_file(
|
|||||||
project_id=system_project.id,
|
project_id=system_project.id,
|
||||||
name=normalized_name,
|
name=normalized_name,
|
||||||
description=f"PyPI package: {normalized_name}",
|
description=f"PyPI package: {normalized_name}",
|
||||||
|
format="pypi",
|
||||||
)
|
)
|
||||||
db.add(package)
|
db.add(package)
|
||||||
db.flush()
|
db.flush()
|
||||||
@@ -525,6 +556,23 @@ async def pypi_download_file(
|
|||||||
)
|
)
|
||||||
db.add(tag)
|
db.add(tag)
|
||||||
|
|
||||||
|
# Extract and create version
|
||||||
|
version = _extract_pypi_version(filename)
|
||||||
|
if version:
|
||||||
|
existing_version = db.query(PackageVersion).filter(
|
||||||
|
PackageVersion.package_id == package.id,
|
||||||
|
PackageVersion.artifact_id == sha256,
|
||||||
|
).first()
|
||||||
|
if not existing_version:
|
||||||
|
pkg_version = PackageVersion(
|
||||||
|
package_id=package.id,
|
||||||
|
artifact_id=sha256,
|
||||||
|
version=version,
|
||||||
|
version_source="filename",
|
||||||
|
created_by="pypi-proxy",
|
||||||
|
)
|
||||||
|
db.add(pkg_version)
|
||||||
|
|
||||||
# Cache the URL mapping
|
# Cache the URL mapping
|
||||||
existing_cached = db.query(CachedUrl).filter(CachedUrl.url_hash == url_hash).first()
|
existing_cached = db.query(CachedUrl).filter(CachedUrl.url_hash == url_hash).first()
|
||||||
if not existing_cached:
|
if not existing_cached:
|
||||||
|
|||||||
@@ -793,4 +793,133 @@ tr:hover .copy-btn {
|
|||||||
.ensure-file-modal {
|
.ensure-file-modal {
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-menu-dropdown {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header upload button */
|
||||||
|
.header-upload-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tag/Version cell */
|
||||||
|
.tag-version-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-version-cell .version-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon buttons */
|
||||||
|
.btn-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action menu */
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 180px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu-dropdown button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-menu-dropdown button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Modal */
|
||||||
|
.upload-modal,
|
||||||
|
.create-tag-modal {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-description {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,12 +63,16 @@ function PackagePage() {
|
|||||||
const [accessDenied, setAccessDenied] = useState(false);
|
const [accessDenied, setAccessDenied] = useState(false);
|
||||||
const [uploadTag, setUploadTag] = useState('');
|
const [uploadTag, setUploadTag] = useState('');
|
||||||
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
|
||||||
const [artifactIdInput, setArtifactIdInput] = useState('');
|
|
||||||
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
|
const [accessLevel, setAccessLevel] = useState<AccessLevel | null>(null);
|
||||||
const [createTagName, setCreateTagName] = useState('');
|
const [createTagName, setCreateTagName] = useState('');
|
||||||
const [createTagArtifactId, setCreateTagArtifactId] = useState('');
|
const [createTagArtifactId, setCreateTagArtifactId] = useState('');
|
||||||
const [createTagLoading, setCreateTagLoading] = useState(false);
|
const [createTagLoading, setCreateTagLoading] = useState(false);
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
|
const [showCreateTagModal, setShowCreateTagModal] = useState(false);
|
||||||
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Dependencies state
|
// Dependencies state
|
||||||
const [selectedTag, setSelectedTag] = useState<TagDetail | null>(null);
|
const [selectedTag, setSelectedTag] = useState<TagDetail | null>(null);
|
||||||
const [dependencies, setDependencies] = useState<Dependency[]>([]);
|
const [dependencies, setDependencies] = useState<Dependency[]>([]);
|
||||||
@@ -326,47 +330,21 @@ function PackagePage() {
|
|||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: 'Tag',
|
header: 'Tag / Version',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (t: TagDetail) => (
|
render: (t: TagDetail) => (
|
||||||
<strong
|
<div className="tag-version-cell">
|
||||||
className={`tag-name-link ${selectedTag?.id === t.id ? 'selected' : ''}`}
|
<strong
|
||||||
onClick={() => handleTagSelect(t)}
|
className={`tag-name-link ${selectedTag?.id === t.id ? 'selected' : ''}`}
|
||||||
style={{ cursor: 'pointer' }}
|
onClick={() => handleTagSelect(t)}
|
||||||
>
|
style={{ cursor: 'pointer' }}
|
||||||
{t.name}
|
>
|
||||||
</strong>
|
{t.name}
|
||||||
),
|
</strong>
|
||||||
},
|
{t.version && <span className="version-badge">{t.version}</span>}
|
||||||
{
|
|
||||||
key: 'version',
|
|
||||||
header: 'Version',
|
|
||||||
render: (t: TagDetail) => (
|
|
||||||
<span className="version-badge">{t.version || '-'}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'artifact_size',
|
|
||||||
header: 'Size',
|
|
||||||
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'artifact_content_type',
|
|
||||||
header: 'Type',
|
|
||||||
render: (t: TagDetail) => (
|
|
||||||
<span className="content-type">{t.artifact_content_type || '-'}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'artifact_original_name',
|
key: 'artifact_original_name',
|
||||||
header: 'Filename',
|
header: 'Filename',
|
||||||
@@ -375,36 +353,70 @@ function PackagePage() {
|
|||||||
<span title={t.artifact_original_name || undefined}>{t.artifact_original_name || '-'}</span>
|
<span title={t.artifact_original_name || undefined}>{t.artifact_original_name || '-'}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'artifact_size',
|
||||||
|
header: 'Size',
|
||||||
|
render: (t: TagDetail) => <span>{formatBytes(t.artifact_size)}</span>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'created_at',
|
key: 'created_at',
|
||||||
header: 'Created',
|
header: 'Created',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
render: (t: TagDetail) => (
|
render: (t: TagDetail) => (
|
||||||
<div className="created-cell">
|
<span title={`by ${t.created_by}`}>{new Date(t.created_at).toLocaleDateString()}</span>
|
||||||
<span>{new Date(t.created_at).toLocaleString()}</span>
|
|
||||||
<span className="created-by">by {t.created_by}</span>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: 'Actions',
|
header: '',
|
||||||
render: (t: TagDetail) => (
|
render: (t: TagDetail) => (
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
<button
|
|
||||||
className="btn btn-secondary btn-small"
|
|
||||||
onClick={() => fetchEnsureFileForTag(t.name)}
|
|
||||||
title="View orchard.ensure file"
|
|
||||||
>
|
|
||||||
Ensure
|
|
||||||
</button>
|
|
||||||
<a
|
<a
|
||||||
href={getDownloadUrl(projectName!, packageName!, t.name)}
|
href={getDownloadUrl(projectName!, packageName!, t.name)}
|
||||||
className="btn btn-secondary btn-small"
|
className="btn btn-icon"
|
||||||
download
|
download
|
||||||
|
title="Download"
|
||||||
>
|
>
|
||||||
Download
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
<div className="action-menu">
|
||||||
|
<button
|
||||||
|
className="btn btn-icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpenMenuId(openMenuId === t.id ? null : t.id);
|
||||||
|
}}
|
||||||
|
title="More actions"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="1" />
|
||||||
|
<circle cx="12" cy="5" r="1" />
|
||||||
|
<circle cx="12" cy="19" r="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{openMenuId === t.id && (
|
||||||
|
<div className="action-menu-dropdown">
|
||||||
|
<button onClick={() => { navigator.clipboard.writeText(t.artifact_id); setOpenMenuId(null); }}>
|
||||||
|
Copy Artifact ID
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { fetchEnsureFileForTag(t.name); setOpenMenuId(null); }}>
|
||||||
|
View Ensure File
|
||||||
|
</button>
|
||||||
|
{canWrite && (
|
||||||
|
<button onClick={() => { setCreateTagArtifactId(t.artifact_id); setShowCreateTagModal(true); setOpenMenuId(null); }}>
|
||||||
|
Create/Update Tag
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => { handleTagSelect(t); setOpenMenuId(null); }}>
|
||||||
|
View Dependencies
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -451,6 +463,19 @@ function PackagePage() {
|
|||||||
<div className="page-header__title-row">
|
<div className="page-header__title-row">
|
||||||
<h1>{packageName}</h1>
|
<h1>{packageName}</h1>
|
||||||
{pkg && <Badge variant="default">{pkg.format}</Badge>}
|
{pkg && <Badge variant="default">{pkg.format}</Badge>}
|
||||||
|
{user && canWrite && (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-small header-upload-btn"
|
||||||
|
onClick={() => setShowUploadModal(true)}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '6px' }}>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{pkg?.description && <p className="description">{pkg.description}</p>}
|
{pkg?.description && <p className="description">{pkg.description}</p>}
|
||||||
<div className="page-header__meta">
|
<div className="page-header__meta">
|
||||||
@@ -496,41 +521,6 @@ function PackagePage() {
|
|||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
{uploadSuccess && <div className="success-message">{uploadSuccess}</div>}
|
{uploadSuccess && <div className="success-message">{uploadSuccess}</div>}
|
||||||
|
|
||||||
{user && (
|
|
||||||
<div className="upload-section card">
|
|
||||||
<h3>Upload Artifact</h3>
|
|
||||||
{canWrite ? (
|
|
||||||
<div className="upload-form">
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="upload-tag">Tag (optional)</label>
|
|
||||||
<input
|
|
||||||
id="upload-tag"
|
|
||||||
type="text"
|
|
||||||
value={uploadTag}
|
|
||||||
onChange={(e) => setUploadTag(e.target.value)}
|
|
||||||
placeholder="v1.0.0, latest, stable..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DragDropUpload
|
|
||||||
projectName={projectName!}
|
|
||||||
packageName={packageName!}
|
|
||||||
tag={uploadTag || undefined}
|
|
||||||
onUploadComplete={handleUploadComplete}
|
|
||||||
onUploadError={handleUploadError}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DragDropUpload
|
|
||||||
projectName={projectName!}
|
|
||||||
packageName={packageName!}
|
|
||||||
disabled={true}
|
|
||||||
disabledReason="You have read-only access to this project and cannot upload artifacts."
|
|
||||||
onUploadComplete={handleUploadComplete}
|
|
||||||
onUploadError={handleUploadError}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Tags / Versions</h2>
|
<h2>Tags / Versions</h2>
|
||||||
@@ -737,78 +727,6 @@ function PackagePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="download-by-id-section card">
|
|
||||||
<h3>Download by Artifact ID</h3>
|
|
||||||
<div className="download-by-id-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={artifactIdInput}
|
|
||||||
onChange={(e) => setArtifactIdInput(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))}
|
|
||||||
placeholder="Enter SHA256 artifact ID (64 hex characters)"
|
|
||||||
className="artifact-id-input"
|
|
||||||
/>
|
|
||||||
<a
|
|
||||||
href={artifactIdInput.length === 64 ? getDownloadUrl(projectName!, packageName!, `artifact:${artifactIdInput}`) : '#'}
|
|
||||||
className={`btn btn-primary ${artifactIdInput.length !== 64 ? 'btn-disabled' : ''}`}
|
|
||||||
download
|
|
||||||
onClick={(e) => {
|
|
||||||
if (artifactIdInput.length !== 64) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{artifactIdInput.length > 0 && artifactIdInput.length !== 64 && (
|
|
||||||
<p className="validation-hint">Artifact ID must be exactly 64 hex characters ({artifactIdInput.length}/64)</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{user && canWrite && (
|
|
||||||
<div className="create-tag-section card">
|
|
||||||
<h3>Create / Update Tag</h3>
|
|
||||||
<p className="section-description">Point a tag at any existing artifact by its ID</p>
|
|
||||||
<form onSubmit={handleCreateTag} className="create-tag-form">
|
|
||||||
<div className="form-row">
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="create-tag-name">Tag Name</label>
|
|
||||||
<input
|
|
||||||
id="create-tag-name"
|
|
||||||
type="text"
|
|
||||||
value={createTagName}
|
|
||||||
onChange={(e) => setCreateTagName(e.target.value)}
|
|
||||||
placeholder="latest, stable, v1.0.0..."
|
|
||||||
disabled={createTagLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group form-group--wide">
|
|
||||||
<label htmlFor="create-tag-artifact">Artifact ID</label>
|
|
||||||
<input
|
|
||||||
id="create-tag-artifact"
|
|
||||||
type="text"
|
|
||||||
value={createTagArtifactId}
|
|
||||||
onChange={(e) => setCreateTagArtifactId(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))}
|
|
||||||
placeholder="SHA256 hash (64 hex characters)"
|
|
||||||
className="artifact-id-input"
|
|
||||||
disabled={createTagLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={createTagLoading || !createTagName.trim() || createTagArtifactId.length !== 64}
|
|
||||||
>
|
|
||||||
{createTagLoading ? 'Creating...' : 'Create Tag'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{createTagArtifactId.length > 0 && createTagArtifactId.length !== 64 && (
|
|
||||||
<p className="validation-hint">Artifact ID must be exactly 64 hex characters ({createTagArtifactId.length}/64)</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="usage-section card">
|
<div className="usage-section card">
|
||||||
<h3>Usage</h3>
|
<h3>Usage</h3>
|
||||||
<p>Download artifacts using:</p>
|
<p>Download artifacts using:</p>
|
||||||
@@ -831,6 +749,118 @@ function PackagePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Upload Modal */}
|
||||||
|
{showUploadModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowUploadModal(false)}>
|
||||||
|
<div className="upload-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>Upload Artifact</h3>
|
||||||
|
<button
|
||||||
|
className="modal-close"
|
||||||
|
onClick={() => setShowUploadModal(false)}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="upload-tag">Tag (optional)</label>
|
||||||
|
<input
|
||||||
|
id="upload-tag"
|
||||||
|
type="text"
|
||||||
|
value={uploadTag}
|
||||||
|
onChange={(e) => setUploadTag(e.target.value)}
|
||||||
|
placeholder="v1.0.0, latest, stable..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DragDropUpload
|
||||||
|
projectName={projectName!}
|
||||||
|
packageName={packageName!}
|
||||||
|
tag={uploadTag || undefined}
|
||||||
|
onUploadComplete={(result) => {
|
||||||
|
handleUploadComplete(result);
|
||||||
|
setShowUploadModal(false);
|
||||||
|
setUploadTag('');
|
||||||
|
}}
|
||||||
|
onUploadError={handleUploadError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Update Tag Modal */}
|
||||||
|
{showCreateTagModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowCreateTagModal(false)}>
|
||||||
|
<div className="create-tag-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>Create / Update Tag</h3>
|
||||||
|
<button
|
||||||
|
className="modal-close"
|
||||||
|
onClick={() => { setShowCreateTagModal(false); setCreateTagName(''); setCreateTagArtifactId(''); }}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p className="modal-description">Point a tag at an artifact by its ID</p>
|
||||||
|
<form onSubmit={(e) => { handleCreateTag(e); setShowCreateTagModal(false); }}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="modal-tag-name">Tag Name</label>
|
||||||
|
<input
|
||||||
|
id="modal-tag-name"
|
||||||
|
type="text"
|
||||||
|
value={createTagName}
|
||||||
|
onChange={(e) => setCreateTagName(e.target.value)}
|
||||||
|
placeholder="latest, stable, v1.0.0..."
|
||||||
|
disabled={createTagLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="modal-artifact-id">Artifact ID</label>
|
||||||
|
<input
|
||||||
|
id="modal-artifact-id"
|
||||||
|
type="text"
|
||||||
|
value={createTagArtifactId}
|
||||||
|
onChange={(e) => setCreateTagArtifactId(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))}
|
||||||
|
placeholder="SHA256 hash (64 hex characters)"
|
||||||
|
className="artifact-id-input"
|
||||||
|
disabled={createTagLoading}
|
||||||
|
/>
|
||||||
|
{createTagArtifactId.length > 0 && createTagArtifactId.length !== 64 && (
|
||||||
|
<p className="validation-hint">{createTagArtifactId.length}/64 characters</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => { setShowCreateTagModal(false); setCreateTagName(''); setCreateTagArtifactId(''); }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={createTagLoading || !createTagName.trim() || createTagArtifactId.length !== 64}
|
||||||
|
>
|
||||||
|
{createTagLoading ? 'Creating...' : 'Create Tag'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ensure File Modal */}
|
{/* Ensure File Modal */}
|
||||||
{showEnsureFile && (
|
{showEnsureFile && (
|
||||||
<div className="modal-overlay" onClick={() => setShowEnsureFile(false)}>
|
<div className="modal-overlay" onClick={() => setShowEnsureFile(false)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user