Compare commits

..

4 Commits

12 changed files with 469 additions and 301 deletions
@@ -72,11 +72,11 @@ export default function ProjectFormDialog(props: ProjectFormDialogProps) {
value={props.homePage} value={props.homePage}
onChange={(event) => props.onHomePageChange(event.target.value as ProjectHomePageValue)} onChange={(event) => props.onHomePageChange(event.target.value as ProjectHomePageValue)}
> >
<MenuItem value="info">Info</MenuItem>
<MenuItem value="repos">Repositories</MenuItem> <MenuItem value="repos">Repositories</MenuItem>
<MenuItem value="issues">Issues</MenuItem> <MenuItem value="issues">Issues</MenuItem>
<MenuItem value="wiki">Wiki</MenuItem> <MenuItem value="wiki">Wiki</MenuItem>
<MenuItem value="files">Files</MenuItem> <MenuItem value="files">Files</MenuItem>
<MenuItem value="info">Settings</MenuItem>
</SelectField> </SelectField>
<Box sx={{ mt: 1 }}> <Box sx={{ mt: 1 }}>
<Tabs <Tabs
+8 -8
View File
@@ -42,14 +42,6 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
return ( return (
<Paper sx={paperSx}> <Paper sx={paperSx}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}> <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 <Button
component={Link} component={Link}
to={`/projects/${projectId}/repos`} to={`/projects/${projectId}/repos`}
@@ -82,6 +74,14 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
> >
Files Files
</Button> </Button>
<Button
component={Link}
to={`/projects/${projectId}/info`}
startIcon={<InfoOutlinedIcon />}
sx={{ justifyContent: 'flex-start', minWidth: 0, width: 'fit-content' }}
>
Settings
</Button>
</Box> </Box>
{right ? <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>{right}</Box> : null} {right ? <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>{right}</Box> : null}
</Paper> </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 Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import IconButton from '@mui/material/IconButton'
import Dialog from './ModalDialog' import Dialog from './ModalDialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import FormDialogContent from './FormDialogContent' import FormDialogContent from './FormDialogContent'
import PageAlert from './PageAlert' import PageAlert from './PageAlert'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import { RpmMirrorRun } from '../api' import { RpmMirrorRun } from '../api'
type RepoRpmDirectoryStatusDialogProps = { type RepoRpmDirectoryStatusDialogProps = {
@@ -13,6 +15,7 @@ type RepoRpmDirectoryStatusDialogProps = {
name: string name: string
path: string path: string
mode: 'local' | 'mirror' mode: 'local' | 'mirror'
repoUrl: string
syncStatus: string syncStatus: string
syncStep: string syncStep: string
syncError: string syncError: string
@@ -42,6 +45,24 @@ export default function RepoRpmDirectoryStatusDialog(props: RepoRpmDirectoryStat
<Typography variant="body2">Directory: {props.name}</Typography> <Typography variant="body2">Directory: {props.name}</Typography>
<Typography variant="body2">Path: {props.path || '.'}</Typography> <Typography variant="body2">Path: {props.path || '.'}</Typography>
<Typography variant="body2">Mode: {props.mode}</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' ? ( {props.mode === 'mirror' ? (
<> <>
<Typography variant="body2"> <Typography variant="body2">
@@ -52,7 +52,7 @@ function isSameSessionTarget(left: SSHSession | null, right: SSHSession): boolea
export default function SSHSessionFileCopyDialog(props: SSHSessionFileCopyDialogProps) { export default function SSHSessionFileCopyDialog(props: SSHSessionFileCopyDialogProps) {
const [pathsText, setPathsText] = useState('') const [pathsText, setPathsText] = useState('')
const [targetDir, setTargetDir] = useState('.') const [targetDir, setTargetDir] = useState('.')
const [overwrite, setOverwrite] = useState(true) const [overwrite, setOverwrite] = useState(false)
const [targetSessionID, setTargetSessionID] = useState('') const [targetSessionID, setTargetSessionID] = useState('')
const [targetSessions, setTargetSessions] = useState<SSHSession[]>([]) const [targetSessions, setTargetSessions] = useState<SSHSession[]>([])
const [sourcePassword, setSourcePassword] = useState('') const [sourcePassword, setSourcePassword] = useState('')
@@ -75,7 +75,7 @@ export default function SSHSessionFileCopyDialog(props: SSHSessionFileCopyDialog
if (!props.open) return if (!props.open) return
setPathsText('') setPathsText('')
setTargetDir('.') setTargetDir('.')
setOverwrite(true) setOverwrite(false)
setTargetSessionID('') setTargetSessionID('')
setTargetSessions([]) setTargetSessions([])
setSourcePassword('') setSourcePassword('')
@@ -33,7 +33,7 @@ function formatFileSize(size: number): string {
export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDialogProps) { export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDialogProps) {
const [targetDir, setTargetDir] = useState('.') const [targetDir, setTargetDir] = useState('.')
const [overwrite, setOverwrite] = useState(true) const [overwrite, setOverwrite] = useState(false)
const [files, setFiles] = useState<File[]>([]) const [files, setFiles] = useState<File[]>([])
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [otpCode, setOTPCode] = useState('') const [otpCode, setOTPCode] = useState('')
@@ -46,7 +46,7 @@ export default function SSHSessionFileUploadDialog(props: SSHSessionFileUploadDi
useEffect(() => { useEffect(() => {
if (!props.open) return if (!props.open) return
setTargetDir('.') setTargetDir('.')
setOverwrite(true) setOverwrite(false)
setFiles([]) setFiles([])
setPassword('') setPassword('')
setOTPCode('') setOTPCode('')
+65
View File
@@ -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>
)
}
+6 -45
View File
@@ -3,8 +3,7 @@ import { Box, Button, Chip, DialogContent, MenuItem, Paper, TextField, ToggleBut
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, Project, ProjectGroupRole, ProjectMember, ProjectUpdatePayload, User, UserGroup } from '../api' import { api, Project, ProjectGroupRole, ProjectMember, ProjectUpdatePayload, User, UserGroup } from '../api'
import ReactMarkdown from 'react-markdown' import ProjectOverviewCard from '../components/ProjectOverviewCard'
import remarkGfm from 'remark-gfm'
import ProjectNavBar from '../components/ProjectNavBar' import ProjectNavBar from '../components/ProjectNavBar'
import HeaderActionButton from '../components/HeaderActionButton' import HeaderActionButton from '../components/HeaderActionButton'
import AddIcon from '@mui/icons-material/Add' 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} {projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
</Box> </Box>
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<SectionCard <ProjectOverviewCard
title="Overview" project={project}
actions={ actions={
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}> <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openEdit} disabled={!project}> <HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openEdit} disabled={!project}>
@@ -352,49 +351,10 @@ export default function ProjectHomePage() {
</HeaderActionButton> </HeaderActionButton>
</Box> </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 <SectionCard
title="Members" title="Members"
collapsible
actions={ actions={
canManageMembers ? ( canManageMembers ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
@@ -486,6 +446,7 @@ export default function ProjectHomePage() {
</SectionCard> </SectionCard>
<SectionCard <SectionCard
title="Group Roles" title="Group Roles"
collapsible
actions={ actions={
canManageMembers ? ( canManageMembers ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}> <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
+4 -2
View File
@@ -15,6 +15,7 @@ import { Link, useParams } from 'react-router-dom'
import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api' import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api'
import ProjectNavBar from '../components/ProjectNavBar' import ProjectNavBar from '../components/ProjectNavBar'
import RepoSubNav from '../components/RepoSubNav' import RepoSubNav from '../components/RepoSubNav'
import SplitPanel from '../components/SplitPanel'
import TintedPanel from '../components/TintedPanel' import TintedPanel from '../components/TintedPanel'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline' import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
@@ -321,7 +322,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
{loadError} {loadError}
</Typography> </Typography>
) : null} ) : null}
<Box sx={{ display: 'grid', gridTemplateColumns: '280px minmax(0, 1fr)', gap: 1, mb: 1 }}> <SplitPanel defaultRatio={0.25} sx={{ mb: 1 }} left={(
<TintedPanel> <TintedPanel>
<PageAlert severity="warning" message={imagesError} onClose={() => setImagesError(null)} sx={{ mb: 1 }} /> <PageAlert severity="warning" message={imagesError} onClose={() => setImagesError(null)} sx={{ mb: 1 }} />
<List dense> <List dense>
@@ -379,6 +380,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
) : null} ) : null}
</List> </List>
</TintedPanel> </TintedPanel>
)} right={(
<TintedPanel> <TintedPanel>
<PageAlert severity="warning" message={tagsError} onClose={() => setTagsError(null)} sx={{ mb: 1 }} /> <PageAlert severity="warning" message={tagsError} onClose={() => setTagsError(null)} sx={{ mb: 1 }} />
{selectedImage !== '' || images.includes('') ? ( {selectedImage !== '' || images.includes('') ? (
@@ -467,7 +469,7 @@ export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
</Typography> </Typography>
)} )}
</TintedPanel> </TintedPanel>
</Box> )} />
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={deleteTagOpen} open={deleteTagOpen}
title="Delete tag" title="Delete tag"
+5 -18
View File
@@ -7,14 +7,13 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined' import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
import FolderIcon from '@mui/icons-material/Folder' import FolderIcon from '@mui/icons-material/Folder'
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile' 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 FileDownloadIcon from '@mui/icons-material/FileDownload'
import OpenInNewIcon from '@mui/icons-material/OpenInNew' import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import LazyCodeBlock from '../components/LazyCodeBlock' import LazyCodeBlock from '../components/LazyCodeBlock'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import ProjectNavBar from '../components/ProjectNavBar' import ProjectNavBar from '../components/ProjectNavBar'
import RepoSubNav from '../components/RepoSubNav' import RepoSubNav from '../components/RepoSubNav'
import SplitPanel from '../components/SplitPanel'
import TintedPanel from '../components/TintedPanel' import TintedPanel from '../components/TintedPanel'
const RepoMarkdown = lazy(() => import('../components/RepoMarkdown')) const RepoMarkdown = lazy(() => import('../components/RepoMarkdown'))
@@ -46,7 +45,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
const [history, setHistory] = useState<RepoCommit[]>([]) const [history, setHistory] = useState<RepoCommit[]>([])
const [diff, setDiff] = useState<string>('') const [diff, setDiff] = useState<string>('')
const [previewTab, setPreviewTab] = useState<'content' | 'history' | 'diff'>('content') const [previewTab, setPreviewTab] = useState<'content' | 'history' | 'diff'>('content')
const [sidebarOpen, setSidebarOpen] = useState(true)
const [selectedCommit, setSelectedCommit] = useState<RepoCommit | null>(null) const [selectedCommit, setSelectedCommit] = useState<RepoCommit | null>(null)
const [readmeContent, setReadmeContent] = useState<string>('') const [readmeContent, setReadmeContent] = useState<string>('')
const [readmeKind, setReadmeKind] = useState<'markdown' | 'text' | ''>('') const [readmeKind, setReadmeKind] = useState<'markdown' | 'text' | ''>('')
@@ -539,6 +537,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
if (!match) return 'markup' if (!match) return 'markup'
return match[1] return match[1]
} }
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
@@ -670,10 +669,8 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
) : null} ) : null}
</Box> </Box>
<Divider sx={{ mb: 1 }} /> <Divider sx={{ mb: 1 }} />
<Box sx={{ display: 'grid', gridTemplateColumns: `${sidebarOpen ? '280px' : '44px'} minmax(0, 1fr)`, gap: 1 }}> <SplitPanel defaultRatio={0.25} left={(
<TintedPanel> <TintedPanel>
{sidebarOpen ? (
<>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0 }}>
<IconButton <IconButton
@@ -707,9 +704,6 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
) )
})} })}
</Box> </Box>
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Collapse sidebar">
<ChevronLeftIcon fontSize="small" />
</IconButton>
</Box> </Box>
<PageAlert severity="warning" message={treeError} onClose={() => setTreeError(null)} sx={{ mb: 1 }} /> <PageAlert severity="warning" message={treeError} onClose={() => setTreeError(null)} sx={{ mb: 1 }} />
<List dense> <List dense>
@@ -767,15 +761,8 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</ListItem> </ListItem>
))} ))}
</List> </List>
</>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Expand sidebar">
<ChevronRightIcon fontSize="small" />
</IconButton>
</Box>
)}
</TintedPanel> </TintedPanel>
)} right={(
<TintedPanel> <TintedPanel>
{selectedFile ? ( {selectedFile ? (
<Box sx={{ mt: 0 }}> <Box sx={{ mt: 0 }}>
@@ -962,7 +949,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
</Box> </Box>
)} )}
</TintedPanel> </TintedPanel>
</Box> )} />
</Box> </Box>
) )
} }
+16 -21
View File
@@ -23,9 +23,8 @@ import {
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import PageAlert from '../components/PageAlert' import PageAlert from '../components/PageAlert'
import SplitPanel from '../components/SplitPanel'
import { api, Project, Repo, RpmMirrorRun, RpmPackageDetail, RpmPackageSummary, RpmSubdirCreatePayload, RpmSubdirUpdatePayload, RpmTreeEntry } from '../api' 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 DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline' import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
import FolderIcon from '@mui/icons-material/Folder' 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 [rpmMetaError, setRpmMetaError] = useState<string | null>(null)
const [rpmMetaLoading, setRpmMetaLoading] = useState(false) const [rpmMetaLoading, setRpmMetaLoading] = useState(false)
const [rpmTab, setRpmTab] = useState<'meta' | 'files' | 'changelog'>('meta') const [rpmTab, setRpmTab] = useState<'meta' | 'files' | 'changelog'>('meta')
const [sidebarOpen, setSidebarOpen] = useState(true)
const [subdirOpen, setSubdirOpen] = useState(false) const [subdirOpen, setSubdirOpen] = useState(false)
const [subdirName, setSubdirName] = useState('') const [subdirName, setSubdirName] = useState('')
const [subdirType, setSubdirType] = useState<'container' | 'repo'>('container') const [subdirType, setSubdirType] = useState<'container' | 'repo'>('container')
@@ -662,6 +660,16 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
return new Response(stream).text() 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 = () => { const rpmFileLink = () => {
if (!repo || repo.type !== 'rpm' || !repo.rpm_url || !rpmSelectedEntry) return '' if (!repo || repo.type !== 'rpm' || !repo.rpm_url || !rpmSelectedEntry) return ''
const base = repo.rpm_url.replace(/\/+$/, '') const base = repo.rpm_url.replace(/\/+$/, '')
@@ -844,12 +852,9 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
{loadError} {loadError}
</Typography> </Typography>
) : null} ) : 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> <TintedPanel>
{sidebarOpen ? ( <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
<>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{canWrite ? ( {canWrite ? (
<> <>
<Button <Button
@@ -883,10 +888,6 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
</Button> </Button>
</> </>
) : null} ) : null}
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Collapse sidebar">
<ChevronLeftIcon fontSize="small" />
</IconButton>
</Box>
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.25, mb: 1, px: 0.5 }}> <Box sx={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 0.25, mb: 1, px: 0.5 }}>
<IconButton size="small" onClick={() => handleRpmBreadcrumb('')} aria-label="Root"> <IconButton size="small" onClick={() => handleRpmBreadcrumb('')} aria-label="Root">
@@ -1058,15 +1059,8 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
</Typography> </Typography>
) : null} ) : null}
</List> </List>
</>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<IconButton size="small" onClick={() => setSidebarOpen((prev) => !prev)} aria-label="Expand sidebar">
<ChevronRightIcon fontSize="small" />
</IconButton>
</Box>
)}
</TintedPanel> </TintedPanel>
)} right={(
<TintedPanel> <TintedPanel>
{rpmSelected && rpmSelectedEntry && rpmSelectedEntry.name.toLowerCase().endsWith('.rpm') ? ( {rpmSelected && rpmSelectedEntry && rpmSelectedEntry.name.toLowerCase().endsWith('.rpm') ? (
<> <>
@@ -1208,7 +1202,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
</Box> </Box>
)} )}
</TintedPanel> </TintedPanel>
</Box> )} />
<Dialog open={subdirOpen} onClose={() => setSubdirOpen(false)} maxWidth="sm" fullWidth> <Dialog open={subdirOpen} onClose={() => setSubdirOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>New Subdirectory</DialogTitle> <DialogTitle>New Subdirectory</DialogTitle>
<FormDialogContent> <FormDialogContent>
@@ -1362,6 +1356,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
name={statusName} name={statusName}
path={statusPath} path={statusPath}
mode={statusMode} mode={statusMode}
repoUrl={statusRepoUrl}
syncStatus={statusSyncStatus} syncStatus={statusSyncStatus}
syncStep={statusSyncStep} syncStep={statusSyncStep}
syncError={statusSyncError} syncError={statusSyncError}
+80 -1
View File
@@ -21,7 +21,7 @@ import {
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import PageAlert from '../components/PageAlert' 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 Autocomplete from '../components/Autocomplete'
import ProjectNavBar from '../components/ProjectNavBar' import ProjectNavBar from '../components/ProjectNavBar'
import HeaderActionButton from '../components/HeaderActionButton' import HeaderActionButton from '../components/HeaderActionButton'
@@ -29,6 +29,9 @@ import { ListRowActionButton, ListRowActions } from '../components/ListRowAction
import RepositoryFormDialog from '../components/RepositoryFormDialog' import RepositoryFormDialog from '../components/RepositoryFormDialog'
import SectionCard from '../components/SectionCard' import SectionCard from '../components/SectionCard'
import SelectField from '../components/SelectField' 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() { export default function ReposPage() {
const { projectId } = useParams() const { projectId } = useParams()
@@ -73,6 +76,14 @@ export default function ReposPage() {
const [page, setPage] = useState(0) const [page, setPage] = useState(0)
const [pageSize, setPageSize] = useState(20) const [pageSize, setPageSize] = useState(20)
const [pageSizeInput, setPageSizeInput] = 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(() => { useEffect(() => {
if (!projectId) return 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 ( return (
<Box> <Box>
<Typography variant="h5" sx={{ mb: 1 }}> <Typography variant="h5" sx={{ mb: 1 }}>
@@ -370,7 +418,17 @@ export default function ReposPage() {
</Box> </Box>
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null} {projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
</Box> </Box>
<Box sx={{ display: 'grid', gap: 1 }}>
<ProjectOverviewCard
project={project}
actions={
<HeaderActionButton variant="outlined" startIcon={<EditIcon />} onClick={openProjectEdit} disabled={!project}>
Edit Project
</HeaderActionButton>
}
/>
<SectionCard <SectionCard
collapsible
title={ title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', flex: 1, minWidth: 0 }}>
<Typography variant="h6">Repositories</Typography> <Typography variant="h6">Repositories</Typography>
@@ -556,6 +614,27 @@ export default function ReposPage() {
</Button> </Button>
</Box> </Box>
</SectionCard> </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 <RepositoryFormDialog
open={createOpen} open={createOpen}
title="New Repository" title="New Repository"