repo details page mostly working

This commit is contained in:
2026-05-07 13:04:13 +02:00
parent 00aede9c91
commit 12bcf59bc9
12 changed files with 407 additions and 214 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ export async function bootstrapCSRF(): Promise<void> {
}
}
async function getCSRFToken(): Promise<string> {
export async function getCSRFToken(): Promise<string> {
if (csrfToken) return csrfToken
await bootstrapCSRF()
return csrfToken ?? ''
+29 -3
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import { api, getCSRFToken } from '../client'
import type { Repository, TreeEntry } from '../../types/api'
const fileDiffSchema = z.object({
@@ -19,6 +19,7 @@ const repositorySchema = z.object({
ownerName: z.string(),
isEmpty: z.boolean(),
size: z.number().default(0),
avatarUrl: z.string().default(''),
name: z.string(),
description: z.string(),
isPrivate: z.boolean(),
@@ -91,7 +92,7 @@ export function useRepoDiff(owner: string, name: string, base: string, head: str
export function useUpdateRepo(owner: string, name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: { description?: string; isPrivate?: boolean; defaultBranch?: string }) =>
mutationFn: (data: { name?: string; description?: string; isPrivate?: boolean; defaultBranch?: string }) =>
api.patch<Repository>(
`/api/v1/repos/${owner}/${name}`,
repositorySchema,
@@ -99,7 +100,32 @@ export function useUpdateRepo(owner: string, name: string) {
),
onSuccess: (updated) => {
queryClient.invalidateQueries({ queryKey: ['repos'] })
queryClient.setQueryData(['repos', owner, name], updated)
queryClient.setQueryData(['repos', updated.ownerName, updated.name], updated)
},
})
}
export function useUploadRepoAvatar(owner: string, name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (file: File) => {
const token = await getCSRFToken()
const formData = new FormData()
formData.append('avatar', file)
const res = await fetch(`/api/v1/repos/${owner}/${name}/avatar`, {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': token },
body: formData,
})
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'Upload failed' }))
throw new Error(body.error ?? 'Upload failed')
}
return res.json() as Promise<{ avatarUrl: string }>
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repos', owner, name] })
},
})
}
+8 -8
View File
@@ -4,6 +4,7 @@ import { cn } from '../../lib/utils'
import { useAuth } from '../../contexts/AuthContext'
import { useRecentRepos } from '../../hooks/useRecentRepos'
import { useStarredRepos } from '../../hooks/useStarredRepos'
import { RepoAvatar } from '../../ui/RepoAvatar'
interface SidebarProps {
className?: string
@@ -71,9 +72,12 @@ export function Sidebar({ className }: SidebarProps) {
{/* ── Repo context sub-nav ────────────────────────────────────── */}
{currentOwner && currentRepo && (
<div className="mt-3 border-t border-white/10 pt-3">
<p className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40 truncate">
{currentOwner}/{currentRepo}
</p>
<div className="flex items-center gap-2 px-3 py-1 mb-1">
<RepoAvatar ownerName={currentOwner} name={currentRepo} size={18} className="rounded-sm opacity-90" />
<p className="text-[11px] font-semibold uppercase tracking-wider text-white/40 truncate">
{currentRepo}
</p>
</div>
<RepoSubNav owner={currentOwner} repo={currentRepo} />
</div>
)}
@@ -124,11 +128,7 @@ function RecentRepoItem({ ownerName, name, isActive, isStarred, onStar }: {
)}>
<Link to={`/repos/${ownerName}/${name}`}
className="flex items-center gap-2 flex-1 min-w-0 px-3 py-1.5">
<div className="w-4 h-4 rounded-sm bg-[#0052CC]/70 flex items-center justify-center shrink-0">
<svg width="9" height="9" fill="white" viewBox="0 0 24 24">
<path d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776"/>
</svg>
</div>
<RepoAvatar ownerName={ownerName} name={name} size={16} className="rounded-sm opacity-90" />
<span className="text-xs text-white/75 truncate">{name}</span>
</Link>
<button onClick={onStar}
+3 -8
View File
@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom'
import type { Repository } from '../../types/api'
import { RepoAvatar } from '../../ui/RepoAvatar'
interface RepoCardProps {
repo: Repository
@@ -11,16 +12,10 @@ export function RepoCard({ repo }: RepoCardProps) {
return (
<Link
to={`/repos/${repo.ownerName}/${repo.name}`}
className="flex items-start gap-4 p-4 border border-[#DFE1E6] rounded hover:border-[#4C9AFF] hover:bg-[#FAFBFC] transition-colors group"
className="flex items-start gap-3 p-4 border border-[#DFE1E6] rounded hover:border-[#4C9AFF] hover:bg-[#FAFBFC] transition-colors group"
>
{/* Icon */}
<div className="flex items-center justify-center w-9 h-9 rounded bg-[#0052CC]/10 text-[#0052CC] shrink-0">
<svg width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
</svg>
</div>
<RepoAvatar ownerName={repo.ownerName} name={repo.name} avatarUrl={repo.avatarUrl} size={36} />
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-[#0052CC] group-hover:underline truncate">
+2
View File
@@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm'
import { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
import { TreeBrowser } from '../components/repos/TreeBrowser'
import { RepoListSkeleton } from '../ui/Skeleton'
import { RepoAvatar } from '../ui/RepoAvatar'
import { useRecentRepos } from '../hooks/useRecentRepos'
export default function RepoPage() {
@@ -53,6 +54,7 @@ export default function RepoPage() {
<div className="flex items-center gap-2 text-sm text-[#5E6C84] mb-1">
<Link to="/repos" className="hover:text-[#0052CC]">Repositories</Link>
<span>/</span>
<RepoAvatar ownerName={owner} name={repo.name} avatarUrl={repo.avatarUrl} size={20} />
<span className="font-semibold text-[#172B4D]">{repo.name}</span>
{repo.isPrivate && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-[#DFE1E6] text-[#5E6C84]">
+179 -192
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useRepo, useUpdateRepo, useDeleteRepo } from '../api/queries/repos'
import { useRepo, useUpdateRepo, useDeleteRepo, useUploadRepoAvatar } from '../api/queries/repos'
import { Skeleton } from '../ui/Skeleton'
import { RepoAvatar } from '../ui/RepoAvatar'
// ─── Sidebar definition ───────────────────────────────────────────────────────
// ─── Sidebar config ───────────────────────────────────────────────────────────
type SectionId =
| 'repository-details'
@@ -19,7 +20,7 @@ type SectionId =
| 'excluded-files'
| 'git-lfs'
const SIDEBAR: { group: string; items: { id: SectionId; label: string; badge?: string }[] }[] = [
const SIDEBAR: { group: string; items: { id: SectionId; label: string }[] }[] = [
{
group: 'General',
items: [
@@ -61,53 +62,20 @@ const SIDEBAR: { group: string; items: { id: SectionId; label: string; badge?: s
const SECTION_META: Record<SectionId, { title: string; description: string }> = {
'repository-details': { title: 'Repository details', description: '' },
'repository-permissions': {
title: 'Repository permissions',
description: 'Control who can read, write, or administer this repository. Invite collaborators and manage team access.',
},
'access-keys': {
title: 'Access keys',
description: 'SSH deploy keys give read or write access to this repository without requiring an interactive user account. Useful for CI/CD systems.',
},
'access-tokens': {
title: 'Access tokens',
description: 'Repository access tokens give third-party tools secure, scoped access to this repository via the API.',
},
'branch-restrictions': {
title: 'Branch restrictions',
description: 'Protect important branches by restricting who can push, delete, or force-push to them. Define which users or groups can merge pull requests.',
},
'branching-model': {
title: 'Branching model',
description: 'Define a branching strategy (e.g. Gitflow) to enforce consistent branch naming across your team. Automatically categorise branches.',
},
'merge-strategies': {
title: 'Merge strategies',
description: 'Configure which merge strategies (merge commit, squash, rebase) are allowed when closing pull requests.',
},
'webhooks': {
title: 'Webhooks',
description: 'Send real-time HTTP POST notifications to external services when events occur in this repository — push, PR creation, comments, and more.',
},
'default-reviewers': {
title: 'Default reviewers',
description: 'Automatically add reviewers to pull requests based on the files changed or the target branch.',
},
'default-description': {
title: 'Default description',
description: 'Set a default description template that pre-fills the body field when a pull request is created.',
},
'excluded-files': {
title: 'Excluded files',
description: 'Specify files that should be excluded from pull request diff views and review requirements.',
},
'git-lfs': {
title: 'Git LFS',
description: 'Git Large File Storage replaces large files with text pointers inside git, while storing the actual files on a remote server.',
},
'repository-permissions': { title: 'Repository permissions', description: 'Control who can read, write, or administer this repository. Invite collaborators and manage team access.' },
'access-keys': { title: 'Access keys', description: 'SSH deploy keys give read or write access to this repository without requiring an interactive user account. Useful for CI/CD.' },
'access-tokens': { title: 'Access tokens', description: 'Repository access tokens give third-party tools secure, scoped access to this repository via the API.' },
'branch-restrictions': { title: 'Branch restrictions', description: 'Protect important branches by restricting who can push, delete, or force-push to them.' },
'branching-model': { title: 'Branching model', description: 'Define a branching strategy (e.g. Gitflow) to enforce consistent branch naming across your team.' },
'merge-strategies': { title: 'Merge strategies', description: 'Configure which merge strategies (merge commit, squash, rebase) are allowed for pull requests.' },
'webhooks': { title: 'Webhooks', description: 'Send real-time HTTP POST notifications to external services when events occur in this repository.' },
'default-reviewers': { title: 'Default reviewers', description: 'Automatically add reviewers to pull requests based on changed files or the target branch.' },
'default-description': { title: 'Default description', description: 'Set a template that pre-fills the pull request body when a new PR is created.' },
'excluded-files': { title: 'Excluded files', description: 'Specify files that should be excluded from pull request diff views and review requirements.' },
'git-lfs': { title: 'Git LFS', description: 'Git Large File Storage replaces large files with text pointers inside git, while storing the actual files on a remote server.' },
}
// ─── Main page ────────────────────────────────────────────────────────────────
// ─── Page shell ───────────────────────────────────────────────────────────────
export default function RepoSettingsPage() {
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
@@ -115,69 +83,36 @@ export default function RepoSettingsPage() {
const [sidebarSearch, setSidebarSearch] = useState('')
const section = (searchParams.get('section') ?? 'repository-details') as SectionId
function goTo(id: SectionId) {
setSearchParams({ section: id })
}
const filtered = sidebarSearch.trim()
? SIDEBAR.map(g => ({
...g,
items: g.items.filter(i => i.label.toLowerCase().includes(sidebarSearch.toLowerCase())),
})).filter(g => g.items.length > 0)
? SIDEBAR.map(g => ({ ...g, items: g.items.filter(i => i.label.toLowerCase().includes(sidebarSearch.toLowerCase())) })).filter(g => g.items.length > 0)
: SIDEBAR
return (
<div className="flex h-full overflow-hidden">
{/* ── Left sidebar ── */}
<aside className="w-60 shrink-0 border-r border-[#DFE1E6] bg-white hidden md:flex flex-col overflow-hidden">
{/* Top: back + search */}
<div className="p-3 border-b border-[#DFE1E6] shrink-0 space-y-2.5">
<Link
to={`/repos/${owner}/${repoName}`}
className="flex items-center gap-1.5 text-sm text-[#172B4D] hover:text-[#0052CC] font-medium"
>
{/* Sidebar */}
<aside className="w-60 shrink-0 border-r border-[#DFE1E6] bg-white hidden md:flex flex-col">
<div className="p-3 border-b border-[#DFE1E6] space-y-2.5 shrink-0">
<Link to={`/repos/${owner}/${repoName}`} className="flex items-center gap-1.5 text-sm text-[#172B4D] hover:text-[#0052CC] font-medium">
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
Repository settings
</Link>
<div className="relative">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#5E6C84] pointer-events-none"
width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#5E6C84] pointer-events-none" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
<input
value={sidebarSearch}
onChange={e => setSidebarSearch(e.target.value)}
placeholder="Jump to settings…"
className="w-full text-xs bg-[#F4F5F7] border border-[#DFE1E6] rounded pl-7 pr-3 py-1.5 focus:outline-none focus:border-[#4C9AFF]"
/>
<input value={sidebarSearch} onChange={e => setSidebarSearch(e.target.value)} placeholder="Jump to settings…" className="w-full text-xs bg-[#F4F5F7] border border-[#DFE1E6] rounded pl-7 pr-3 py-1.5 focus:outline-none focus:border-[#4C9AFF]" />
</div>
</div>
{/* Nav items */}
<nav className="flex-1 overflow-y-auto py-2">
{filtered.map(group => (
<div key={group.group} className="mb-1">
<p className="text-[10px] font-semibold text-[#5E6C84] uppercase tracking-wider px-4 py-1.5">
{group.group}
</p>
<p className="text-[10px] font-semibold text-[#5E6C84] uppercase tracking-wider px-4 py-1.5">{group.group}</p>
{group.items.map(item => (
<button
key={item.id}
onClick={() => goTo(item.id)}
className={`w-full text-left px-4 py-2 text-sm transition-colors flex items-center justify-between ${
section === item.id
? 'bg-[#DEEBFF] text-[#0052CC] font-medium border-l-[3px] border-[#0052CC]'
: 'text-[#172B4D] hover:bg-[#F4F5F7] border-l-[3px] border-transparent'
}`}
>
<button key={item.id} onClick={() => setSearchParams({ section: item.id })}
className={`w-full text-left px-4 py-2 text-sm transition-colors border-l-[3px] ${section === item.id ? 'bg-[#DEEBFF] text-[#0052CC] font-medium border-[#0052CC]' : 'text-[#172B4D] hover:bg-[#F4F5F7] border-transparent'}`}>
{item.label}
{item.badge && (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-[#DFE1E6] text-[#5E6C84]">
{item.badge}
</span>
)}
</button>
))}
</div>
@@ -185,23 +120,13 @@ export default function RepoSettingsPage() {
</nav>
</aside>
{/* ── Main content ── */}
{/* Content */}
<main className="flex-1 overflow-y-auto min-w-0">
{/* Mobile section selector */}
<div className="md:hidden p-3 border-b border-[#DFE1E6] bg-white">
<select
value={section}
onChange={e => goTo(e.target.value as SectionId)}
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none"
>
{SIDEBAR.map(g =>
g.items.map(i => (
<option key={i.id} value={i.id}>{g.group} {i.label}</option>
))
)}
<select value={section} onChange={e => setSearchParams({ section: e.target.value })} className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none">
{SIDEBAR.map(g => g.items.map(i => <option key={i.id} value={i.id}>{g.group} {i.label}</option>))}
</select>
</div>
{section === 'repository-details'
? <RepositoryDetailsSection owner={owner} repo={repoName} />
: <ComingSoon sectionId={section} />
@@ -211,38 +136,60 @@ export default function RepoSettingsPage() {
)
}
// ─── Repository details section ───────────────────────────────────────────────
// ─── Repository details ───────────────────────────────────────────────────────
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
return `${(bytes / 1024 ** 3).toFixed(2)} GB`
}
function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string }) {
const navigate = useNavigate()
const { data: repoData, isLoading } = useRepo(owner, repo)
const updateRepo = useUpdateRepo(owner, repo)
const deleteRepo = useDeleteRepo(owner, repo)
const navigate = useNavigate()
const uploadAvatar = useUploadRepoAvatar(owner, repo)
// Form state
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [isPrivate, setIsPrivate] = useState(false)
const [defaultBranch, setDefaultBranch] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [showManage, setShowManage] = useState(false)
const [confirmDelete, setConfirmDelete] = useState('')
const [saved, setSaved] = useState(false)
const [nameError, setNameError] = useState('')
// Avatar state
const fileInputRef = useRef<HTMLInputElement>(null)
const [avatarPreview, setAvatarPreview] = useState<string | null>(null)
const manageRef = useRef<HTMLDivElement>(null)
// Initialize form from API data
useEffect(() => {
if (repoData) {
setName(repoData.name)
setDescription(repoData.description ?? '')
setIsPrivate(repoData.isPrivate)
setDefaultBranch(repoData.defaultBranch)
}
}, [repoData])
// Close manage dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (manageRef.current && !manageRef.current.contains(e.target as Node)) setShowManage(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const isDirty = repoData != null && (
name !== repoData.name ||
description !== (repoData.description ?? '') ||
isPrivate !== repoData.isPrivate ||
defaultBranch !== repoData.defaultBranch
@@ -250,24 +197,50 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
function handleDiscard() {
if (!repoData) return
setName(repoData.name)
setDescription(repoData.description ?? '')
setIsPrivate(repoData.isPrivate)
setDefaultBranch(repoData.defaultBranch)
setNameError('')
}
function handleNameChange(val: string) {
setName(val)
if (val && !/^[a-zA-Z0-9._-]+$/.test(val)) {
setNameError('Only letters, numbers, hyphens, underscores, and dots are allowed.')
} else {
setNameError('')
}
}
async function handleSave(e: React.FormEvent) {
e.preventDefault()
await updateRepo.mutateAsync({ description, isPrivate, defaultBranch })
setSaved(true)
setTimeout(() => setSaved(false), 3000)
if (nameError) return
const payload: Record<string, unknown> = { description, isPrivate, defaultBranch }
if (name !== repoData?.name) payload.name = name
const updated = await updateRepo.mutateAsync(payload)
if (updated.name !== repo) {
// Renamed — navigate to new URL
navigate(`/repos/${owner}/${updated.name}/settings?section=repository-details`, { replace: true })
} else {
setSaved(true)
setTimeout(() => setSaved(false), 3000)
}
}
async function handleDelete() {
if (confirmDelete !== repo || !repoData) return
if (confirmDelete !== repo) return
await deleteRepo.mutateAsync()
navigate('/repos')
}
const handleAvatarFile = useCallback((file: File) => {
const reader = new FileReader()
reader.onload = () => setAvatarPreview(reader.result as string)
reader.readAsDataURL(file)
uploadAvatar.mutate(file)
}, [uploadAvatar])
if (isLoading || !repoData) {
return (
<div className="max-w-2xl px-6 py-6 space-y-4">
@@ -278,14 +251,11 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
)
}
// Initials avatar colour based on repo name
const avatarColor = '#0052CC'
return (
<div className="max-w-2xl px-6 py-6 space-y-6">
{/* Page header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-1 text-xs text-[#5E6C84] mb-1.5">
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[#0052CC]">{owner}</Link>
@@ -295,13 +265,30 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
<h1 className="text-xl font-semibold text-[#172B4D]">Repository details</h1>
</div>
<div className="relative shrink-0">
<button className="flex items-center gap-1.5 px-3 py-1.5 border border-[#DFE1E6] rounded text-sm text-[#172B4D] hover:bg-[#F4F5F7] font-medium">
{/* Manage repository dropdown */}
<div className="relative shrink-0" ref={manageRef}>
<button
onClick={() => setShowManage(s => !s)}
className="flex items-center gap-1.5 px-3 py-1.5 border border-[#DFE1E6] rounded text-sm text-[#172B4D] hover:bg-[#F4F5F7] font-medium"
>
Manage repository
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
{showManage && (
<div className="absolute right-0 top-full mt-1 w-52 bg-white border border-[#DFE1E6] rounded-lg shadow-xl z-20 overflow-hidden">
<button
onClick={() => { setShowManage(false); setShowAdvanced(true); setTimeout(() => document.getElementById('danger-zone')?.scrollIntoView({ behavior: 'smooth' }), 50) }}
className="w-full flex items-center gap-2 px-4 py-3 text-sm text-[#DE350B] hover:bg-[#FFEBE6] text-left"
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
Delete repository
</button>
</div>
)}
</div>
</div>
@@ -313,34 +300,70 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
<div>
<label className="block text-sm font-medium text-[#172B4D] mb-3">Avatar</label>
<div className="flex items-center gap-4">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center text-white text-xl font-bold"
style={{ backgroundColor: avatarColor }}
>
{repoData.name[0]?.toUpperCase()}
</div>
{/* Clickable avatar */}
<button
type="button"
className="px-3 py-1.5 text-sm border border-[#DFE1E6] rounded text-[#172B4D] hover:bg-[#F4F5F7]"
onClick={() => fileInputRef.current?.click()}
className="rounded-lg overflow-hidden ring-2 ring-[#DFE1E6] hover:ring-[#4C9AFF] transition-all focus:outline-none"
title="Click to change avatar"
>
Change avatar
{avatarPreview ? (
<img src={avatarPreview} alt="Preview" className="w-14 h-14 object-cover" />
) : (
<RepoAvatar
ownerName={owner}
name={repoData.name}
avatarUrl={repoData.avatarUrl}
size={56}
/>
)}
</button>
<div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadAvatar.isPending}
className="px-3 py-1.5 text-sm border border-[#DFE1E6] rounded text-[#172B4D] hover:bg-[#F4F5F7] disabled:opacity-50"
>
{uploadAvatar.isPending ? 'Uploading…' : 'Change avatar'}
</button>
<p className="text-xs text-[#5E6C84] mt-1">JPEG, PNG, GIF or WebP · max 5 MB</p>
{uploadAvatar.isError && (
<p className="text-xs text-[#DE350B] mt-1">{(uploadAvatar.error as Error).message}</p>
)}
{uploadAvatar.isSuccess && !avatarPreview && (
<p className="text-xs text-[#00875A] mt-1">Avatar updated.</p>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) handleAvatarFile(f) }}
/>
</div>
</div>
{/* Repository name (read-only) */}
{/* Repository name */}
<div>
<label className="block text-sm font-medium text-[#172B4D] mb-1">
Repository name <span className="text-[#DE350B]">*</span>
</label>
<input
value={repoData.name}
disabled
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm bg-[#F4F5F7] text-[#5E6C84] cursor-not-allowed"
value={name}
onChange={e => handleNameChange(e.target.value)}
className={`w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 ${nameError ? 'border-[#DE350B] focus:border-[#DE350B] focus:ring-[#DE350B]' : 'border-[#DFE1E6] focus:border-[#4C9AFF] focus:ring-[#4C9AFF]'}`}
/>
<p className="text-xs text-[#5E6C84] mt-1">
Renaming would require all collaborators to update their git remotes. Coming soon.
</p>
{nameError
? <p className="text-xs text-[#DE350B] mt-1">{nameError}</p>
: name !== repoData.name
? <p className="text-xs text-[#FF8B00] mt-1 flex items-center gap-1">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /></svg>
Renaming will change the clone URL all existing git remotes will need to be updated.
</p>
: <p className="text-xs text-[#5E6C84] mt-1">Letters, numbers, hyphens, underscores, and dots only.</p>
}
</div>
{/* Size (read-only) */}
@@ -357,7 +380,7 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
onChange={e => setDescription(e.target.value)}
rows={4}
placeholder="Describe this repository…"
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] resize-y"
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF] resize-y"
/>
</div>
@@ -372,8 +395,8 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
className="mt-0.5 w-4 h-4 accent-[#0052CC]"
/>
<div>
<span className="text-sm text-[#172B4D]">
{isPrivate ? 'This is a private repository' : 'Make this repository private'}
<span className="text-sm font-medium text-[#172B4D]">
{isPrivate ? 'This is a private repository' : 'This is a public repository'}
</span>
<p className="text-xs text-[#5E6C84] mt-0.5">
{isPrivate
@@ -391,10 +414,8 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
onClick={() => setShowAdvanced(s => !s)}
className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-[#172B4D] hover:bg-[#F4F5F7] text-left"
>
<svg
width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24"
className={`transition-transform shrink-0 ${showAdvanced ? 'rotate-90' : ''}`}
>
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24"
className={`transition-transform shrink-0 ${showAdvanced ? 'rotate-90' : ''}`}>
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
Advanced
@@ -402,30 +423,29 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
{showAdvanced && (
<div className="border-t border-[#DFE1E6] px-4 py-5 space-y-6 bg-[#FAFBFC]">
{/* Default branch */}
<div>
<label className="block text-sm font-medium text-[#172B4D] mb-1">Default branch</label>
<input
value={defaultBranch}
onChange={e => setDefaultBranch(e.target.value)}
className="w-full max-w-xs border border-[#DFE1E6] bg-white rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]"
placeholder="main"
className="w-full max-w-xs bg-white border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]"
/>
<p className="text-xs text-[#5E6C84] mt-1">Used as the base branch for new pull requests.</p>
<p className="text-xs text-[#5E6C84] mt-1">The default branch used as the base for new pull requests.</p>
</div>
{/* Danger zone */}
<div className="border border-[#FFEBE6] rounded-lg overflow-hidden">
<div className="px-4 py-3 bg-[#FFEBE6]/50 border-b border-[#FFEBE6]">
<div id="danger-zone" className="border border-[#FFEBE6] rounded-lg overflow-hidden">
<div className="px-4 py-3 bg-[#FFEBE6]/60 border-b border-[#FFEBE6]">
<h3 className="text-sm font-semibold text-[#BF2600]">Delete repository</h3>
</div>
<div className="px-4 py-4 space-y-3 bg-white">
<p className="text-sm text-[#172B4D]">
This is permanent and cannot be undone. All commits, branches, pull requests, issues, and settings will be lost.
This is permanent and cannot be undone. All commits, branches, pull requests, issues, and settings will be permanently deleted.
</p>
<p className="text-xs text-[#5E6C84]">
Type <code className="font-mono bg-[#F4F5F7] border border-[#DFE1E6] px-1.5 py-0.5 rounded">{repo}</code> to confirm.
Type <code className="font-mono bg-[#F4F5F7] border border-[#DFE1E6] px-1.5 py-0.5 rounded text-[#172B4D]">{repo}</code> to confirm.
</p>
<input
value={confirmDelete}
@@ -450,12 +470,12 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
)}
</div>
{/* Footer */}
{/* Footer: error / saved / Discard / Save */}
<div className="flex items-center justify-end gap-3 pt-2 border-t border-[#DFE1E6]">
{updateRepo.isError && (
<p className="text-xs text-[#DE350B] mr-auto">{(updateRepo.error as Error).message}</p>
)}
{saved && (
{saved && !updateRepo.isError && (
<span className="text-xs text-[#00875A] font-medium mr-auto flex items-center gap-1">
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
@@ -463,19 +483,12 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
Changes saved
</span>
)}
<button
type="button"
onClick={handleDiscard}
disabled={!isDirty}
className="px-4 py-2 rounded border border-[#DFE1E6] text-sm text-[#172B4D] hover:bg-[#F4F5F7] disabled:opacity-40 min-h-[36px]"
>
<button type="button" onClick={handleDiscard} disabled={!isDirty}
className="px-4 py-2 rounded border border-[#DFE1E6] text-sm text-[#172B4D] hover:bg-[#F4F5F7] disabled:opacity-40 min-h-[36px]">
Discard
</button>
<button
type="submit"
disabled={updateRepo.isPending || !isDirty}
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] disabled:opacity-50 min-h-[36px]"
>
<button type="submit" disabled={updateRepo.isPending || !isDirty || !!nameError}
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] disabled:opacity-50 min-h-[36px]">
{updateRepo.isPending ? 'Saving…' : 'Save changes'}
</button>
</div>
@@ -484,46 +497,20 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
)
}
// ─── Coming soon section ──────────────────────────────────────────────────────
const COMING_SOON_ICONS: Partial<Record<SectionId, React.ReactNode>> = {
'access-keys': (
<svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1.2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 0 1 21.75 8.25Z" />
</svg>
),
'webhooks': (
<svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1.2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
</svg>
),
'branch-restrictions': (
<svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1.2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>
),
}
// ─── Coming soon ──────────────────────────────────────────────────────────────
function ComingSoon({ sectionId }: { sectionId: SectionId }) {
const meta = SECTION_META[sectionId]
return (
<div className="max-w-2xl px-6 py-6">
<h1 className="text-xl font-semibold text-[#172B4D] mb-1">{meta.title}</h1>
<div className="mt-8 flex flex-col items-center justify-center py-16 border border-dashed border-[#DFE1E6] rounded-lg text-center bg-[#FAFBFC]">
<div className="mb-4 text-[#97A0AF]">
{COMING_SOON_ICONS[sectionId] ?? (
<svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1.2" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />
</svg>
)}
</div>
<svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1.2" viewBox="0 0 24 24" className="mb-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />
</svg>
<h2 className="text-base font-semibold text-[#172B4D] mb-2">{meta.title}</h2>
<p className="text-sm text-[#5E6C84] max-w-sm leading-relaxed">{meta.description}</p>
<span className="mt-5 inline-block text-[10px] font-semibold uppercase tracking-wider text-white bg-[#97A0AF] px-2.5 py-1 rounded-full">
Coming soon
</span>
<span className="mt-5 text-[10px] font-semibold uppercase tracking-wider text-white bg-[#97A0AF] px-2.5 py-1 rounded-full">Coming soon</span>
</div>
</div>
)
+1
View File
@@ -21,6 +21,7 @@ export interface Repository {
defaultBranch: string
isEmpty: boolean
size: number
avatarUrl: string
createdAt: string
updatedAt: string
}
+53
View File
@@ -0,0 +1,53 @@
import { useState } from 'react'
interface RepoAvatarProps {
ownerName: string
name: string
avatarUrl?: string
size?: number
className?: string
}
// Consistent color per repo name (not random on each render)
function hashColor(name: string): string {
const palette = [
'#0052CC', '#00875A', '#FF5630', '#FF8B00',
'#6554C0', '#00B8D9', '#36B37E', '#253858',
]
let hash = 0
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0
return palette[Math.abs(hash) % palette.length]
}
export function RepoAvatar({ ownerName, name, avatarUrl, size = 32, className = '' }: RepoAvatarProps) {
const [imgError, setImgError] = useState(false)
// Use provided avatarUrl; fall back to the API endpoint (which 404s if not set → onError fires)
const src = avatarUrl || `/api/v1/repos/${ownerName}/${name}/avatar`
const letter = (name[0] ?? '?').toUpperCase()
const bg = hashColor(name)
const fontSize = Math.round(size * 0.42)
const style = { width: size, height: size, minWidth: size, minHeight: size, fontSize }
if (!imgError) {
return (
<img
src={src}
alt={name}
style={style}
className={`rounded object-cover shrink-0 ${className}`}
onError={() => setImgError(true)}
/>
)
}
return (
<div
style={{ ...style, backgroundColor: bg }}
className={`rounded flex items-center justify-center text-white font-bold select-none shrink-0 ${className}`}
>
{letter}
</div>
)
}