Compare commits
4 Commits
ba4f8bc571
...
8f51f42b6c
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f51f42b6c | |||
| 34046ee7a8 | |||
| 1e685baebe | |||
| 3684adfe26 |
@@ -72,11 +72,11 @@ export default function ProjectFormDialog(props: ProjectFormDialogProps) {
|
||||
value={props.homePage}
|
||||
onChange={(event) => props.onHomePageChange(event.target.value as ProjectHomePageValue)}
|
||||
>
|
||||
<MenuItem value="info">Info</MenuItem>
|
||||
<MenuItem value="repos">Repositories</MenuItem>
|
||||
<MenuItem value="issues">Issues</MenuItem>
|
||||
<MenuItem value="wiki">Wiki</MenuItem>
|
||||
<MenuItem value="files">Files</MenuItem>
|
||||
<MenuItem value="info">Settings</MenuItem>
|
||||
</SelectField>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Tabs
|
||||
|
||||
@@ -42,14 +42,6 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
|
||||
return (
|
||||
<Paper sx={paperSx}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/projects/${projectId}/info`}
|
||||
startIcon={<InfoOutlinedIcon />}
|
||||
sx={{ justifyContent: 'flex-start', minWidth: 0, width: 'fit-content' }}
|
||||
>
|
||||
Info
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/projects/${projectId}/repos`}
|
||||
@@ -82,6 +74,14 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
|
||||
>
|
||||
Files
|
||||
</Button>
|
||||
<Button
|
||||
component={Link}
|
||||
to={`/projects/${projectId}/info`}
|
||||
startIcon={<InfoOutlinedIcon />}
|
||||
sx={{ justifyContent: 'flex-start', minWidth: 0, width: 'fit-content' }}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</Box>
|
||||
{right ? <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>{right}</Box> : null}
|
||||
</Paper>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import SectionCard from './SectionCard'
|
||||
import { Project } from '../api'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
type ProjectOverviewCardProps = {
|
||||
project: Project | null
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
export default function ProjectOverviewCard({ project, actions }: ProjectOverviewCardProps) {
|
||||
return (
|
||||
<SectionCard title="Overview" collapsible actions={actions}>
|
||||
{project ? (
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||
{project.created_by_name || project.created_by ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Created by: {project.created_by_name || project.created_by}
|
||||
</Typography>
|
||||
) : null}
|
||||
{project.created_at ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Created: {new Date(project.created_at * 1000).toLocaleString()}
|
||||
</Typography>
|
||||
) : null}
|
||||
{project.updated_by_name || project.updated_by ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Last updated by: {project.updated_by_name || project.updated_by}
|
||||
</Typography>
|
||||
) : null}
|
||||
{project.updated_at ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Last updated: {new Date(project.updated_at * 1000).toLocaleString()}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
{project.description ? (
|
||||
<Box sx={{ '& p': { mt: 0, mb: 0.8 }, '& p:last-child': { mb: 0 } }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{project.description}</ReactMarkdown>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No description.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading project...
|
||||
</Typography>
|
||||
)}
|
||||
</SectionCard>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import IconButton from '@mui/material/IconButton'
|
||||
import Dialog from './ModalDialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import FormDialogContent from './FormDialogContent'
|
||||
import PageAlert from './PageAlert'
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import { RpmMirrorRun } from '../api'
|
||||
|
||||
type RepoRpmDirectoryStatusDialogProps = {
|
||||
@@ -13,6 +15,7 @@ type RepoRpmDirectoryStatusDialogProps = {
|
||||
name: string
|
||||
path: string
|
||||
mode: 'local' | 'mirror'
|
||||
repoUrl: string
|
||||
syncStatus: string
|
||||
syncStep: string
|
||||
syncError: string
|
||||
@@ -42,6 +45,24 @@ export default function RepoRpmDirectoryStatusDialog(props: RepoRpmDirectoryStat
|
||||
<Typography variant="body2">Directory: {props.name}</Typography>
|
||||
<Typography variant="body2">Path: {props.path || '.'}</Typography>
|
||||
<Typography variant="body2">Mode: {props.mode}</Typography>
|
||||
{props.repoUrl ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ flexShrink: 0 }}>RPM Repository URL:</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="a"
|
||||
href={props.repoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
sx={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'primary.main' }}
|
||||
>
|
||||
{props.repoUrl}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => navigator.clipboard?.writeText(props.repoUrl)}>
|
||||
<ContentCopyIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : null}
|
||||
{props.mode === 'mirror' ? (
|
||||
<>
|
||||
<Typography variant="body2">
|
||||
|
||||
@@ -52,7 +52,7 @@ function isSameSessionTarget(left: SSHSession | null, right: SSHSession): boolea
|
||||
export default function SSHSessionFileCopyDialog(props: SSHSessionFileCopyDialogProps) {
|
||||
const [pathsText, setPathsText] = useState('')
|
||||
const [targetDir, setTargetDir] = useState('.')
|
||||
const [overwrite, setOverwrite] = useState(true)
|
||||
const [overwrite, setOverwrite] = useState(false)
|
||||
const [targetSessionID, setTargetSessionID] = useState('')
|
||||
const [targetSessions, setTargetSessions] = useState<SSHSession[]>([])
|
||||
const [sourcePassword, setSourcePassword] = useState('')
|
||||
@@ -75,7 +75,7 @@ export default function SSHSessionFileCopyDialog(props: SSHSessionFileCopyDialog
|
||||
if (!props.open) return
|
||||
setPathsText('')
|
||||
setTargetDir('.')
|
||||
setOverwrite(true)
|
||||
setOverwrite(false)
|
||||
setTargetSessionID('')
|
||||
setTargetSessions([])
|
||||
setSourcePassword('')
|
||||
|
||||
@@ -33,7 +33,7 @@ function formatFileSize(size: number): string {
|
||||
|
||||
export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDialogProps) {
|
||||
const [targetDir, setTargetDir] = useState('.')
|
||||
const [overwrite, setOverwrite] = useState(true)
|
||||
const [overwrite, setOverwrite] = useState(false)
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [password, setPassword] = useState('')
|
||||
const [otpCode, setOTPCode] = useState('')
|
||||
@@ -46,7 +46,7 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
|
||||
useEffect(() => {
|
||||
if (!props.open) return
|
||||
setTargetDir('.')
|
||||
setOverwrite(true)
|
||||
setOverwrite(false)
|
||||
setFiles([])
|
||||
setPassword('')
|
||||
setOTPCode('')
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import Box, { BoxProps } from '@mui/material/Box'
|
||||
import { PointerEvent as ReactPointerEvent, ReactNode, useRef, useState } from 'react'
|
||||
|
||||
type SplitPanelProps = {
|
||||
left: ReactNode
|
||||
right: ReactNode
|
||||
defaultRatio?: number
|
||||
sx?: BoxProps['sx']
|
||||
}
|
||||
|
||||
export default function SplitPanel({ left, right, defaultRatio = 0.3, sx }: SplitPanelProps) {
|
||||
const [splitRatio, setSplitRatio] = useState(defaultRatio)
|
||||
const [splitDragging, setSplitDragging] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const container = containerRef.current
|
||||
if (!splitDragging || !container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
setSplitRatio(Math.max(0.1, Math.min(0.9, (event.clientX - rect.left) / rect.width)))
|
||||
}
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setSplitDragging(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
sx={[
|
||||
{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `minmax(0, ${splitRatio}fr) 12px minmax(0, ${1 - splitRatio}fr)`,
|
||||
userSelect: splitDragging ? 'none' : 'auto',
|
||||
cursor: splitDragging ? 'col-resize' : 'auto',
|
||||
},
|
||||
...(Array.isArray(sx) ? sx : sx ? [sx] : []),
|
||||
]}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
>
|
||||
{left}
|
||||
<Box
|
||||
onPointerDown={() => setSplitDragging(true)}
|
||||
sx={{
|
||||
cursor: 'col-resize',
|
||||
display: 'grid',
|
||||
placeItems: 'stretch',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
width: '2px',
|
||||
justifySelf: 'center',
|
||||
backgroundColor: splitDragging ? 'primary.main' : 'divider',
|
||||
borderRadius: 999,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{right}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,7 @@ import { Box, Button, Chip, DialogContent, MenuItem, Paper, TextField, ToggleBut
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { api, Project, ProjectGroupRole, ProjectMember, ProjectUpdatePayload, User, UserGroup } from '../api'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import ProjectOverviewCard from '../components/ProjectOverviewCard'
|
||||
import ProjectNavBar from '../components/ProjectNavBar'
|
||||
import HeaderActionButton from '../components/HeaderActionButton'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
@@ -340,8 +339,8 @@ export default function ProjectHomePage() {
|
||||
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<SectionCard
|
||||
title="Overview"
|
||||
<ProjectOverviewCard
|
||||
project={project}
|
||||
actions={
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openEdit} disabled={!project}>
|
||||
@@ -352,49 +351,10 @@ export default function ProjectHomePage() {
|
||||
</HeaderActionButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{project ? (
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
|
||||
{project.created_by_name || project.created_by ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Created by: {project.created_by_name || project.created_by}
|
||||
</Typography>
|
||||
) : null}
|
||||
{project.created_at ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Created: {new Date(project.created_at * 1000).toLocaleString()}
|
||||
</Typography>
|
||||
) : null}
|
||||
{project.updated_by_name || project.updated_by ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Last updated by: {project.updated_by_name || project.updated_by}
|
||||
</Typography>
|
||||
) : null}
|
||||
{project.updated_at ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Last updated: {new Date(project.updated_at * 1000).toLocaleString()}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
{project.description ? (
|
||||
<Box sx={{ '& p': { m: 0 } }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{project.description}</ReactMarkdown>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No description.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Loading project...
|
||||
</Typography>
|
||||
)}
|
||||
</SectionCard>
|
||||
/>
|
||||
<SectionCard
|
||||
title="Members"
|
||||
collapsible
|
||||
actions={
|
||||
canManageMembers ? (
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
@@ -486,6 +446,7 @@ export default function ProjectHomePage() {
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
title="Group Roles"
|
||||
collapsible
|
||||
actions={
|
||||
canManageMembers ? (
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Link, useParams } from 'react-router-dom'
|
||||
import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api'
|
||||
import ProjectNavBar from '../components/ProjectNavBar'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
import SplitPanel from '../components/SplitPanel'
|
||||
import TintedPanel from '../components/TintedPanel'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
|
||||
@@ -321,7 +322,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
||||
{loadError}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '280px minmax(0, 1fr)', gap: 1, mb: 1 }}>
|
||||
<SplitPanel defaultRatio={0.25} sx={{ mb: 1 }} left={(
|
||||
<TintedPanel>
|
||||
<PageAlert severity="warning" message={imagesError} onClose={() => setImagesError(null)} sx={{ mb: 1 }} />
|
||||
<List dense>
|
||||
@@ -379,6 +380,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
||||
) : null}
|
||||
</List>
|
||||
</TintedPanel>
|
||||
)} right={(
|
||||
<TintedPanel>
|
||||
<PageAlert severity="warning" message={tagsError} onClose={() => setTagsError(null)} sx={{ mb: 1 }} />
|
||||
{selectedImage !== '' || images.includes('') ? (
|
||||
@@ -467,7 +469,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
|
||||
</Typography>
|
||||
)}
|
||||
</TintedPanel>
|
||||
</Box>
|
||||
)} />
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteTagOpen}
|
||||
title="Delete tag"
|
||||
|
||||
@@ -7,14 +7,13 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
|
||||
import FolderIcon from '@mui/icons-material/Folder'
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload'
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
|
||||
import LazyCodeBlock from '../components/LazyCodeBlock'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import ProjectNavBar from '../components/ProjectNavBar'
|
||||
import RepoSubNav from '../components/RepoSubNav'
|
||||
import SplitPanel from '../components/SplitPanel'
|
||||
import TintedPanel from '../components/TintedPanel'
|
||||
|
||||
const RepoMarkdown = lazy(() => import('../components/RepoMarkdown'))
|
||||
@@ -46,7 +45,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
const [history, setHistory] = useState<RepoCommit[]>([])
|
||||
const [diff, setDiff] = useState<string>('')
|
||||
const [previewTab, setPreviewTab] = useState<'content' | 'history' | 'diff'>('content')
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [selectedCommit, setSelectedCommit] = useState<RepoCommit | null>(null)
|
||||
const [readmeContent, setReadmeContent] = useState<string>('')
|
||||
const [readmeKind, setReadmeKind] = useState<'markdown' | 'text' | ''>('')
|
||||
@@ -539,6 +537,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
if (!match) return 'markup'
|
||||
return match[1]
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
@@ -670,10 +669,8 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
) : null}
|
||||
</Box>
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: `${sidebarOpen ? '280px' : '44px'} minmax(0, 1fr)`, gap: 1 }}>
|
||||
<SplitPanel defaultRatio={0.25} left={(
|
||||
<TintedPanel>
|
||||
{sidebarOpen ? (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0 }}>
|
||||
<IconButton
|
||||
@@ -707,9 +704,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Collapse sidebar">
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<PageAlert severity="warning" message={treeError} onClose={() => setTreeError(null)} sx={{ mb: 1 }} />
|
||||
<List dense>
|
||||
@@ -767,15 +761,8 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Expand sidebar">
|
||||
<ChevronRightIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</TintedPanel>
|
||||
)} right={(
|
||||
<TintedPanel>
|
||||
{selectedFile ? (
|
||||
<Box sx={{ mt: 0 }}>
|
||||
@@ -962,7 +949,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
|
||||
</Box>
|
||||
)}
|
||||
</TintedPanel>
|
||||
</Box>
|
||||
)} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,9 +23,8 @@ import {
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import SplitPanel from '../components/SplitPanel'
|
||||
import { api, Project, Repo, RpmMirrorRun, RpmPackageDetail, RpmPackageSummary, RpmSubdirCreatePayload, RpmSubdirUpdatePayload, RpmTreeEntry } from '../api'
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
|
||||
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
|
||||
import FolderIcon from '@mui/icons-material/Folder'
|
||||
@@ -61,7 +60,6 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
const [rpmMetaError, setRpmMetaError] = useState<string | null>(null)
|
||||
const [rpmMetaLoading, setRpmMetaLoading] = useState(false)
|
||||
const [rpmTab, setRpmTab] = useState<'meta' | 'files' | 'changelog'>('meta')
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [subdirOpen, setSubdirOpen] = useState(false)
|
||||
const [subdirName, setSubdirName] = useState('')
|
||||
const [subdirType, setSubdirType] = useState<'container' | 'repo'>('container')
|
||||
@@ -662,6 +660,16 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
return new Response(stream).text()
|
||||
}
|
||||
|
||||
const statusRepoUrl = (() => {
|
||||
if (!repo?.rpm_url) return ''
|
||||
let base = repo.rpm_url.replace(/\/+$/, '')
|
||||
if (!base.includes('://')) {
|
||||
base = window.location.origin + base
|
||||
}
|
||||
const path = statusPath.replace(/^\/+/, '')
|
||||
return path ? `${base}/${path}` : base
|
||||
})()
|
||||
|
||||
const rpmFileLink = () => {
|
||||
if (!repo || repo.type !== 'rpm' || !repo.rpm_url || !rpmSelectedEntry) return ''
|
||||
const base = repo.rpm_url.replace(/\/+$/, '')
|
||||
@@ -844,12 +852,9 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
{loadError}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: `${sidebarOpen ? '280px' : '44px'} minmax(0, 1fr)`, gap: 1, mb: 1 }}>
|
||||
<SplitPanel defaultRatio={0.3} sx={{ mb: 1 }} left={(
|
||||
<TintedPanel>
|
||||
{sidebarOpen ? (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
|
||||
{canWrite ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -883,10 +888,6 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Collapse sidebar">
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.25, mb: 1, px: 0.5 }}>
|
||||
<IconButton size="small" onClick={() => handleRpmBreadcrumb('')} aria-label="Root">
|
||||
@@ -1058,15 +1059,8 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
</Typography>
|
||||
) : null}
|
||||
</List>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Expand sidebar">
|
||||
<ChevronRightIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</TintedPanel>
|
||||
)} right={(
|
||||
<TintedPanel>
|
||||
{rpmSelected && rpmSelectedEntry && rpmSelectedEntry.name.toLowerCase().endsWith('.rpm') ? (
|
||||
<>
|
||||
@@ -1208,7 +1202,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
</Box>
|
||||
)}
|
||||
</TintedPanel>
|
||||
</Box>
|
||||
)} />
|
||||
<Dialog open={subdirOpen} onClose={() => setSubdirOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>New Subdirectory</DialogTitle>
|
||||
<FormDialogContent>
|
||||
@@ -1362,6 +1356,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
name={statusName}
|
||||
path={statusPath}
|
||||
mode={statusMode}
|
||||
repoUrl={statusRepoUrl}
|
||||
syncStatus={statusSyncStatus}
|
||||
syncStep={statusSyncStep}
|
||||
syncError={statusSyncError}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import PageAlert from '../components/PageAlert'
|
||||
import { api, AvailableRepo, Project, Repo, RepoStats } from '../api'
|
||||
import { api, AvailableRepo, Project, ProjectUpdatePayload, Repo, RepoStats } from '../api'
|
||||
import Autocomplete from '../components/Autocomplete'
|
||||
import ProjectNavBar from '../components/ProjectNavBar'
|
||||
import HeaderActionButton from '../components/HeaderActionButton'
|
||||
@@ -29,6 +29,9 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
|
||||
import RepositoryFormDialog from '../components/RepositoryFormDialog'
|
||||
import SectionCard from '../components/SectionCard'
|
||||
import SelectField from '../components/SelectField'
|
||||
import ProjectFormDialog from '../components/ProjectFormDialog'
|
||||
import { ProjectDescriptionTab, ProjectHomePageValue } from '../components/ProjectFormDialog'
|
||||
import ProjectOverviewCard from '../components/ProjectOverviewCard'
|
||||
|
||||
export default function ReposPage() {
|
||||
const { projectId } = useParams()
|
||||
@@ -73,6 +76,14 @@ export default function ReposPage() {
|
||||
const [page, setPage] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [pageSizeInput, setPageSizeInput] = useState('20')
|
||||
const [projectEditOpen, setProjectEditOpen] = useState(false)
|
||||
const [projectEditSlug, setProjectEditSlug] = useState('')
|
||||
const [projectEditName, setProjectEditName] = useState('')
|
||||
const [projectEditDescription, setProjectEditDescription] = useState('')
|
||||
const [projectEditHomePage, setProjectEditHomePage] = useState<ProjectHomePageValue>('info')
|
||||
const [projectEditDescTab, setProjectEditDescTab] = useState<ProjectDescriptionTab>('write')
|
||||
const [projectEditError, setProjectEditError] = useState<string | null>(null)
|
||||
const [savingProjectEdit, setSavingProjectEdit] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
@@ -336,6 +347,43 @@ export default function ReposPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const openProjectEdit = () => {
|
||||
if (!project) return
|
||||
setProjectEditSlug(project.slug)
|
||||
setProjectEditName(project.name)
|
||||
setProjectEditDescription(project.description || '')
|
||||
setProjectEditHomePage(project.home_page || 'info')
|
||||
setProjectEditDescTab('write')
|
||||
setProjectEditError(null)
|
||||
setProjectEditOpen(true)
|
||||
}
|
||||
|
||||
const handleProjectEditSave = async () => {
|
||||
if (!projectId) return
|
||||
if (/\s/.test(projectEditSlug)) {
|
||||
setProjectEditError('Slug cannot contain whitespace.')
|
||||
return
|
||||
}
|
||||
const payload: ProjectUpdatePayload = {
|
||||
slug: projectEditSlug.trim(),
|
||||
name: projectEditName.trim(),
|
||||
description: projectEditDescription.trim(),
|
||||
home_page: projectEditHomePage
|
||||
}
|
||||
setProjectEditError(null)
|
||||
setSavingProjectEdit(true)
|
||||
try {
|
||||
const updated = await api.updateProject(projectId, payload)
|
||||
setProject(updated)
|
||||
setProjectEditOpen(false)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update project'
|
||||
setProjectEditError(message)
|
||||
} finally {
|
||||
setSavingProjectEdit(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 1 }}>
|
||||
@@ -370,7 +418,17 @@ export default function ReposPage() {
|
||||
</Box>
|
||||
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<ProjectOverviewCard
|
||||
project={project}
|
||||
actions={
|
||||
<HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openProjectEdit} disabled={!project}>
|
||||
Edit Project
|
||||
</HeaderActionButton>
|
||||
}
|
||||
/>
|
||||
<SectionCard
|
||||
collapsible
|
||||
title={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="h6">Repositories</Typography>
|
||||
@@ -556,6 +614,27 @@ export default function ReposPage() {
|
||||
</Button>
|
||||
</Box>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
<ProjectFormDialog
|
||||
open={projectEditOpen}
|
||||
title="Edit Project"
|
||||
error={projectEditError}
|
||||
slug={projectEditSlug}
|
||||
onSlugChange={setProjectEditSlug}
|
||||
name={projectEditName}
|
||||
onNameChange={setProjectEditName}
|
||||
description={projectEditDescription}
|
||||
onDescriptionChange={setProjectEditDescription}
|
||||
homePage={projectEditHomePage}
|
||||
onHomePageChange={setProjectEditHomePage}
|
||||
descriptionTab={projectEditDescTab}
|
||||
onDescriptionTabChange={setProjectEditDescTab}
|
||||
busy={savingProjectEdit}
|
||||
saveText="Save"
|
||||
busyText="Saving..."
|
||||
onSave={handleProjectEditSave}
|
||||
onClose={() => setProjectEditOpen(false)}
|
||||
/>
|
||||
<RepositoryFormDialog
|
||||
open={createOpen}
|
||||
title="New Repository"
|
||||
|
||||
Reference in New Issue
Block a user