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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 940 KiB

+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 if (csrfToken) return csrfToken
await bootstrapCSRF() await bootstrapCSRF()
return csrfToken ?? '' return csrfToken ?? ''
+29 -3
View File
@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod' import { z } from 'zod'
import { api } from '../client' import { api, getCSRFToken } from '../client'
import type { Repository, TreeEntry } from '../../types/api' import type { Repository, TreeEntry } from '../../types/api'
const fileDiffSchema = z.object({ const fileDiffSchema = z.object({
@@ -19,6 +19,7 @@ const repositorySchema = z.object({
ownerName: z.string(), ownerName: z.string(),
isEmpty: z.boolean(), isEmpty: z.boolean(),
size: z.number().default(0), size: z.number().default(0),
avatarUrl: z.string().default(''),
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
isPrivate: z.boolean(), 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) { export function useUpdateRepo(owner: string, name: string) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (data: { description?: string; isPrivate?: boolean; defaultBranch?: string }) => mutationFn: (data: { name?: string; description?: string; isPrivate?: boolean; defaultBranch?: string }) =>
api.patch<Repository>( api.patch<Repository>(
`/api/v1/repos/${owner}/${name}`, `/api/v1/repos/${owner}/${name}`,
repositorySchema, repositorySchema,
@@ -99,7 +100,32 @@ export function useUpdateRepo(owner: string, name: string) {
), ),
onSuccess: (updated) => { onSuccess: (updated) => {
queryClient.invalidateQueries({ queryKey: ['repos'] }) 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 { useAuth } from '../../contexts/AuthContext'
import { useRecentRepos } from '../../hooks/useRecentRepos' import { useRecentRepos } from '../../hooks/useRecentRepos'
import { useStarredRepos } from '../../hooks/useStarredRepos' import { useStarredRepos } from '../../hooks/useStarredRepos'
import { RepoAvatar } from '../../ui/RepoAvatar'
interface SidebarProps { interface SidebarProps {
className?: string className?: string
@@ -71,9 +72,12 @@ export function Sidebar({ className }: SidebarProps) {
{/* ── Repo context sub-nav ────────────────────────────────────── */} {/* ── Repo context sub-nav ────────────────────────────────────── */}
{currentOwner && currentRepo && ( {currentOwner && currentRepo && (
<div className="mt-3 border-t border-white/10 pt-3"> <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"> <div className="flex items-center gap-2 px-3 py-1 mb-1">
{currentOwner}/{currentRepo} <RepoAvatar ownerName={currentOwner} name={currentRepo} size={18} className="rounded-sm opacity-90" />
</p> <p className="text-[11px] font-semibold uppercase tracking-wider text-white/40 truncate">
{currentRepo}
</p>
</div>
<RepoSubNav owner={currentOwner} repo={currentRepo} /> <RepoSubNav owner={currentOwner} repo={currentRepo} />
</div> </div>
)} )}
@@ -124,11 +128,7 @@ function RecentRepoItem({ ownerName, name, isActive, isStarred, onStar }: {
)}> )}>
<Link to={`/repos/${ownerName}/${name}`} <Link to={`/repos/${ownerName}/${name}`}
className="flex items-center gap-2 flex-1 min-w-0 px-3 py-1.5"> 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"> <RepoAvatar ownerName={ownerName} name={name} size={16} className="rounded-sm opacity-90" />
<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>
<span className="text-xs text-white/75 truncate">{name}</span> <span className="text-xs text-white/75 truncate">{name}</span>
</Link> </Link>
<button onClick={onStar} <button onClick={onStar}
+3 -8
View File
@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import type { Repository } from '../../types/api' import type { Repository } from '../../types/api'
import { RepoAvatar } from '../../ui/RepoAvatar'
interface RepoCardProps { interface RepoCardProps {
repo: Repository repo: Repository
@@ -11,16 +12,10 @@ export function RepoCard({ repo }: RepoCardProps) {
return ( return (
<Link <Link
to={`/repos/${repo.ownerName}/${repo.name}`} 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 */} <RepoAvatar ownerName={repo.ownerName} name={repo.name} avatarUrl={repo.avatarUrl} size={36} />
<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>
{/* Info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-[#0052CC] group-hover:underline truncate"> <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 { useRepo, useRepoTree, useRepoBlob, useRepoBranches } from '../api/queries/repos'
import { TreeBrowser } from '../components/repos/TreeBrowser' import { TreeBrowser } from '../components/repos/TreeBrowser'
import { RepoListSkeleton } from '../ui/Skeleton' import { RepoListSkeleton } from '../ui/Skeleton'
import { RepoAvatar } from '../ui/RepoAvatar'
import { useRecentRepos } from '../hooks/useRecentRepos' import { useRecentRepos } from '../hooks/useRecentRepos'
export default function RepoPage() { 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"> <div className="flex items-center gap-2 text-sm text-[#5E6C84] mb-1">
<Link to="/repos" className="hover:text-[#0052CC]">Repositories</Link> <Link to="/repos" className="hover:text-[#0052CC]">Repositories</Link>
<span>/</span> <span>/</span>
<RepoAvatar ownerName={owner} name={repo.name} avatarUrl={repo.avatarUrl} size={20} />
<span className="font-semibold text-[#172B4D]">{repo.name}</span> <span className="font-semibold text-[#172B4D]">{repo.name}</span>
{repo.isPrivate && ( {repo.isPrivate && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-[#DFE1E6] text-[#5E6C84]"> <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 { 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 { Skeleton } from '../ui/Skeleton'
import { RepoAvatar } from '../ui/RepoAvatar'
// ─── Sidebar definition ─────────────────────────────────────────────────────── // ─── Sidebar config ───────────────────────────────────────────────────────────
type SectionId = type SectionId =
| 'repository-details' | 'repository-details'
@@ -19,7 +20,7 @@ type SectionId =
| 'excluded-files' | 'excluded-files'
| 'git-lfs' | 'git-lfs'
const SIDEBAR: { group: string; items: { id: SectionId; label: string; badge?: string }[] }[] = [ const SIDEBAR: { group: string; items: { id: SectionId; label: string }[] }[] = [
{ {
group: 'General', group: 'General',
items: [ 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 }> = { const SECTION_META: Record<SectionId, { title: string; description: string }> = {
'repository-details': { title: 'Repository details', description: '' }, 'repository-details': { title: 'Repository details', description: '' },
'repository-permissions': { 'repository-permissions': { title: 'Repository permissions', description: 'Control who can read, write, or administer this repository. Invite collaborators and manage team access.' },
title: 'Repository permissions', '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.' },
description: 'Control who can read, write, or administer this repository. Invite collaborators and manage team access.', '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.' },
'access-keys': { 'branching-model': { title: 'Branching model', description: 'Define a branching strategy (e.g. Gitflow) to enforce consistent branch naming across your team.' },
title: 'Access keys', 'merge-strategies': { title: 'Merge strategies', description: 'Configure which merge strategies (merge commit, squash, rebase) are allowed for pull requests.' },
description: 'SSH deploy keys give read or write access to this repository without requiring an interactive user account. Useful for CI/CD systems.', '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.' },
'access-tokens': { 'default-description': { title: 'Default description', description: 'Set a template that pre-fills the pull request body when a new PR is created.' },
title: 'Access tokens', 'excluded-files': { title: 'Excluded files', description: 'Specify files that should be excluded from pull request diff views and review requirements.' },
description: 'Repository access tokens give third-party tools secure, scoped access to this repository via the API.', '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.' },
},
'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.',
},
} }
// ─── Main page ──────────────────────────────────────────────────────────────── // ─── Page shell ───────────────────────────────────────────────────────────────
export default function RepoSettingsPage() { export default function RepoSettingsPage() {
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>() const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
@@ -115,69 +83,36 @@ export default function RepoSettingsPage() {
const [sidebarSearch, setSidebarSearch] = useState('') const [sidebarSearch, setSidebarSearch] = useState('')
const section = (searchParams.get('section') ?? 'repository-details') as SectionId const section = (searchParams.get('section') ?? 'repository-details') as SectionId
function goTo(id: SectionId) {
setSearchParams({ section: id })
}
const filtered = sidebarSearch.trim() const filtered = sidebarSearch.trim()
? SIDEBAR.map(g => ({ ? SIDEBAR.map(g => ({ ...g, items: g.items.filter(i => i.label.toLowerCase().includes(sidebarSearch.toLowerCase())) })).filter(g => g.items.length > 0)
...g,
items: g.items.filter(i => i.label.toLowerCase().includes(sidebarSearch.toLowerCase())),
})).filter(g => g.items.length > 0)
: SIDEBAR : SIDEBAR
return ( return (
<div className="flex h-full overflow-hidden"> <div className="flex h-full overflow-hidden">
{/* ── Left sidebar ── */} {/* Sidebar */}
<aside className="w-60 shrink-0 border-r border-[#DFE1E6] bg-white hidden md:flex flex-col overflow-hidden"> <aside className="w-60 shrink-0 border-r border-[#DFE1E6] bg-white hidden md:flex flex-col">
{/* Top: back + search */} <div className="p-3 border-b border-[#DFE1E6] space-y-2.5 shrink-0">
<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">
<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"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg> </svg>
Repository settings Repository settings
</Link> </Link>
<div className="relative"> <div className="relative">
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[#5E6C84] pointer-events-none" <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">
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" /> <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> </svg>
<input <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]" />
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>
</div> </div>
{/* Nav items */}
<nav className="flex-1 overflow-y-auto py-2"> <nav className="flex-1 overflow-y-auto py-2">
{filtered.map(group => ( {filtered.map(group => (
<div key={group.group} className="mb-1"> <div key={group.group} className="mb-1">
<p className="text-[10px] font-semibold text-[#5E6C84] uppercase tracking-wider px-4 py-1.5"> <p className="text-[10px] font-semibold text-[#5E6C84] uppercase tracking-wider px-4 py-1.5">{group.group}</p>
{group.group}
</p>
{group.items.map(item => ( {group.items.map(item => (
<button <button key={item.id} onClick={() => setSearchParams({ section: item.id })}
key={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'}`}>
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'
}`}
>
{item.label} {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> </button>
))} ))}
</div> </div>
@@ -185,23 +120,13 @@ export default function RepoSettingsPage() {
</nav> </nav>
</aside> </aside>
{/* ── Main content ── */} {/* Content */}
<main className="flex-1 overflow-y-auto min-w-0"> <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"> <div className="md:hidden p-3 border-b border-[#DFE1E6] bg-white">
<select <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">
value={section} {SIDEBAR.map(g => g.items.map(i => <option key={i.id} value={i.id}>{g.group} {i.label}</option>))}
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> </select>
</div> </div>
{section === 'repository-details' {section === 'repository-details'
? <RepositoryDetailsSection owner={owner} repo={repoName} /> ? <RepositoryDetailsSection owner={owner} repo={repoName} />
: <ComingSoon sectionId={section} /> : <ComingSoon sectionId={section} />
@@ -211,38 +136,60 @@ export default function RepoSettingsPage() {
) )
} }
// ─── Repository details section ─────────────────────────────────────────────── // ─── Repository details ───────────────────────────────────────────────────────
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes === 0) return '0 B' if (!bytes) return ''
if (bytes < 1024) return `${bytes} B` 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` if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
return `${(bytes / 1024 ** 3).toFixed(2)} GB` return `${(bytes / 1024 ** 3).toFixed(2)} GB`
} }
function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string }) { function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string }) {
const navigate = useNavigate()
const { data: repoData, isLoading } = useRepo(owner, repo) const { data: repoData, isLoading } = useRepo(owner, repo)
const updateRepo = useUpdateRepo(owner, repo) const updateRepo = useUpdateRepo(owner, repo)
const deleteRepo = useDeleteRepo(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 [description, setDescription] = useState('')
const [isPrivate, setIsPrivate] = useState(false) const [isPrivate, setIsPrivate] = useState(false)
const [defaultBranch, setDefaultBranch] = useState('') const [defaultBranch, setDefaultBranch] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false)
const [showManage, setShowManage] = useState(false)
const [confirmDelete, setConfirmDelete] = useState('') const [confirmDelete, setConfirmDelete] = useState('')
const [saved, setSaved] = useState(false) 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(() => { useEffect(() => {
if (repoData) { if (repoData) {
setName(repoData.name)
setDescription(repoData.description ?? '') setDescription(repoData.description ?? '')
setIsPrivate(repoData.isPrivate) setIsPrivate(repoData.isPrivate)
setDefaultBranch(repoData.defaultBranch) setDefaultBranch(repoData.defaultBranch)
} }
}, [repoData]) }, [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 && ( const isDirty = repoData != null && (
name !== repoData.name ||
description !== (repoData.description ?? '') || description !== (repoData.description ?? '') ||
isPrivate !== repoData.isPrivate || isPrivate !== repoData.isPrivate ||
defaultBranch !== repoData.defaultBranch defaultBranch !== repoData.defaultBranch
@@ -250,24 +197,50 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
function handleDiscard() { function handleDiscard() {
if (!repoData) return if (!repoData) return
setName(repoData.name)
setDescription(repoData.description ?? '') setDescription(repoData.description ?? '')
setIsPrivate(repoData.isPrivate) setIsPrivate(repoData.isPrivate)
setDefaultBranch(repoData.defaultBranch) 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) { async function handleSave(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
await updateRepo.mutateAsync({ description, isPrivate, defaultBranch }) if (nameError) return
setSaved(true) const payload: Record<string, unknown> = { description, isPrivate, defaultBranch }
setTimeout(() => setSaved(false), 3000) 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() { async function handleDelete() {
if (confirmDelete !== repo || !repoData) return if (confirmDelete !== repo) return
await deleteRepo.mutateAsync() await deleteRepo.mutateAsync()
navigate('/repos') 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) { if (isLoading || !repoData) {
return ( return (
<div className="max-w-2xl px-6 py-6 space-y-4"> <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 ( return (
<div className="max-w-2xl px-6 py-6 space-y-6"> <div className="max-w-2xl px-6 py-6 space-y-6">
{/* Page header */} {/* Page header */}
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4 flex-wrap">
<div> <div>
<div className="flex items-center gap-1 text-xs text-[#5E6C84] mb-1.5"> <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> <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> <h1 className="text-xl font-semibold text-[#172B4D]">Repository details</h1>
</div> </div>
<div className="relative shrink-0"> {/* Manage repository dropdown */}
<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"> <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 Manage repository
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24"> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg> </svg>
</button> </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>
</div> </div>
@@ -313,34 +300,70 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
<div> <div>
<label className="block text-sm font-medium text-[#172B4D] mb-3">Avatar</label> <label className="block text-sm font-medium text-[#172B4D] mb-3">Avatar</label>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div {/* Clickable avatar */}
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>
<button <button
type="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> </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>
</div> </div>
{/* Repository name (read-only) */} {/* Repository name */}
<div> <div>
<label className="block text-sm font-medium text-[#172B4D] mb-1"> <label className="block text-sm font-medium text-[#172B4D] mb-1">
Repository name <span className="text-[#DE350B]">*</span> Repository name <span className="text-[#DE350B]">*</span>
</label> </label>
<input <input
value={repoData.name} value={name}
disabled onChange={e => handleNameChange(e.target.value)}
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm bg-[#F4F5F7] text-[#5E6C84] cursor-not-allowed" 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"> {nameError
Renaming would require all collaborators to update their git remotes. Coming soon. ? <p className="text-xs text-[#DE350B] mt-1">{nameError}</p>
</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> </div>
{/* Size (read-only) */} {/* Size (read-only) */}
@@ -357,7 +380,7 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
onChange={e => setDescription(e.target.value)} onChange={e => setDescription(e.target.value)}
rows={4} rows={4}
placeholder="Describe this repository…" 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> </div>
@@ -372,8 +395,8 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
className="mt-0.5 w-4 h-4 accent-[#0052CC]" className="mt-0.5 w-4 h-4 accent-[#0052CC]"
/> />
<div> <div>
<span className="text-sm text-[#172B4D]"> <span className="text-sm font-medium text-[#172B4D]">
{isPrivate ? 'This is a private repository' : 'Make this repository private'} {isPrivate ? 'This is a private repository' : 'This is a public repository'}
</span> </span>
<p className="text-xs text-[#5E6C84] mt-0.5"> <p className="text-xs text-[#5E6C84] mt-0.5">
{isPrivate {isPrivate
@@ -391,10 +414,8 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
onClick={() => setShowAdvanced(s => !s)} 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" className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-[#172B4D] hover:bg-[#F4F5F7] text-left"
> >
<svg <svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24"
width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24" className={`transition-transform shrink-0 ${showAdvanced ? 'rotate-90' : ''}`}>
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" /> <path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg> </svg>
Advanced Advanced
@@ -402,30 +423,29 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
{showAdvanced && ( {showAdvanced && (
<div className="border-t border-[#DFE1E6] px-4 py-5 space-y-6 bg-[#FAFBFC]"> <div className="border-t border-[#DFE1E6] px-4 py-5 space-y-6 bg-[#FAFBFC]">
{/* Default branch */} {/* Default branch */}
<div> <div>
<label className="block text-sm font-medium text-[#172B4D] mb-1">Default branch</label> <label className="block text-sm font-medium text-[#172B4D] mb-1">Default branch</label>
<input <input
value={defaultBranch} value={defaultBranch}
onChange={e => setDefaultBranch(e.target.value)} 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" 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> </div>
{/* Danger zone */} {/* Danger zone */}
<div className="border border-[#FFEBE6] rounded-lg overflow-hidden"> <div id="danger-zone" className="border border-[#FFEBE6] rounded-lg overflow-hidden">
<div className="px-4 py-3 bg-[#FFEBE6]/50 border-b border-[#FFEBE6]"> <div className="px-4 py-3 bg-[#FFEBE6]/60 border-b border-[#FFEBE6]">
<h3 className="text-sm font-semibold text-[#BF2600]">Delete repository</h3> <h3 className="text-sm font-semibold text-[#BF2600]">Delete repository</h3>
</div> </div>
<div className="px-4 py-4 space-y-3 bg-white"> <div className="px-4 py-4 space-y-3 bg-white">
<p className="text-sm text-[#172B4D]"> <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>
<p className="text-xs text-[#5E6C84]"> <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> </p>
<input <input
value={confirmDelete} value={confirmDelete}
@@ -450,12 +470,12 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
)} )}
</div> </div>
{/* Footer */} {/* Footer: error / saved / Discard / Save */}
<div className="flex items-center justify-end gap-3 pt-2 border-t border-[#DFE1E6]"> <div className="flex items-center justify-end gap-3 pt-2 border-t border-[#DFE1E6]">
{updateRepo.isError && ( {updateRepo.isError && (
<p className="text-xs text-[#DE350B] mr-auto">{(updateRepo.error as Error).message}</p> <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"> <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"> <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" /> <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 Changes saved
</span> </span>
)} )}
<button <button type="button" onClick={handleDiscard} disabled={!isDirty}
type="button" className="px-4 py-2 rounded border border-[#DFE1E6] text-sm text-[#172B4D] hover:bg-[#F4F5F7] disabled:opacity-40 min-h-[36px]">
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 Discard
</button> </button>
<button <button type="submit" disabled={updateRepo.isPending || !isDirty || !!nameError}
type="submit" className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] disabled:opacity-50 min-h-[36px]">
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]"
>
{updateRepo.isPending ? 'Saving…' : 'Save changes'} {updateRepo.isPending ? 'Saving…' : 'Save changes'}
</button> </button>
</div> </div>
@@ -484,46 +497,20 @@ function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string
) )
} }
// ─── Coming soon section ────────────────────────────────────────────────────── // ─── Coming soon ──────────────────────────────────────────────────────────────
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>
),
}
function ComingSoon({ sectionId }: { sectionId: SectionId }) { function ComingSoon({ sectionId }: { sectionId: SectionId }) {
const meta = SECTION_META[sectionId] const meta = SECTION_META[sectionId]
return ( return (
<div className="max-w-2xl px-6 py-6"> <div className="max-w-2xl px-6 py-6">
<h1 className="text-xl font-semibold text-[#172B4D] mb-1">{meta.title}</h1> <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="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]"> <svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1.2" viewBox="0 0 24 24" className="mb-4">
{COMING_SOON_ICONS[sectionId] ?? ( <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 width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1.2" viewBox="0 0 24 24"> </svg>
<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>
<h2 className="text-base font-semibold text-[#172B4D] mb-2">{meta.title}</h2> <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> <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"> <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>
Coming soon
</span>
</div> </div>
</div> </div>
) )
+1
View File
@@ -21,6 +21,7 @@ export interface Repository {
defaultBranch: string defaultBranch: string
isEmpty: boolean isEmpty: boolean
size: number size: number
avatarUrl: string
createdAt: string createdAt: string
updatedAt: 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>
)
}
+129 -2
View File
@@ -2,10 +2,13 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"xorm.io/xorm" "xorm.io/xorm"
@@ -21,15 +24,40 @@ type repoResponse struct {
models.Repository models.Repository
OwnerName string `json:"ownerName"` OwnerName string `json:"ownerName"`
IsEmpty bool `json:"isEmpty"` IsEmpty bool `json:"isEmpty"`
AvatarURL string `json:"avatarUrl"`
Size int64 `json:"size"` Size int64 `json:"size"`
} }
func avatarPath(repoRoot string, repoID int64) string {
return filepath.Join(repoRoot, ".avatars", strconv.FormatInt(repoID, 10))
}
func isValidRepoName(name string) bool {
if len(name) == 0 || len(name) > 100 {
return false
}
for _, c := range name {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-') {
return false
}
}
return true
}
func (h *RepoHandler) withOwnerName(repo *models.Repository) repoResponse { func (h *RepoHandler) withOwnerName(repo *models.Repository) repoResponse {
var owner models.User var owner models.User
h.db.ID(repo.OwnerID).Get(&owner) h.db.ID(repo.OwnerID).Get(&owner)
gitdomain.SetRepoRoot(h.cfg.RepoRoot) gitdomain.SetRepoRoot(h.cfg.RepoRoot)
avURL := ""
if _, err := os.Stat(avatarPath(h.cfg.RepoRoot, repo.ID)); err == nil {
avURL = "/api/v1/repos/" + owner.Username + "/" + repo.Name + "/avatar"
}
return repoResponse{ return repoResponse{
Repository: *repo, Repository: *repo,
AvatarURL: avURL,
OwnerName: owner.Username, OwnerName: owner.Username,
IsEmpty: gitdomain.IsEmpty(repo.DiskPath), IsEmpty: gitdomain.IsEmpty(repo.DiskPath),
} }
@@ -354,10 +382,16 @@ func (h *RepoHandler) Update(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
callerID, _ := middleware.UserIDFromContext(r.Context())
if callerID != repo.OwnerID {
jsonError(w, "only the owner can update a repository", http.StatusForbidden)
return
}
var body struct { var body struct {
Description *string `json:"description"` Name *string `json:"name"`
IsPrivate *bool `json:"isPrivate"` Description *string `json:"description"`
IsPrivate *bool `json:"isPrivate"`
DefaultBranch *string `json:"defaultBranch"` DefaultBranch *string `json:"defaultBranch"`
} }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
@@ -366,6 +400,27 @@ func (h *RepoHandler) Update(w http.ResponseWriter, r *http.Request) {
} }
cols := []string{} cols := []string{}
// Rename: update disk path and DB name atomically.
if body.Name != nil && *body.Name != "" && *body.Name != repo.Name {
newName := strings.TrimSpace(*body.Name)
if !isValidRepoName(newName) {
jsonError(w, "invalid repository name: use letters, numbers, hyphens, underscores, and dots only", http.StatusBadRequest)
return
}
newDiskPath := filepath.Join(filepath.Dir(repo.DiskPath), newName+".git")
if _, err := os.Stat(newDiskPath); !os.IsNotExist(err) {
jsonError(w, "a repository with that name already exists", http.StatusConflict)
return
}
if err := os.Rename(repo.DiskPath, newDiskPath); err != nil {
jsonError(w, "could not rename repository on disk", http.StatusInternalServerError)
return
}
repo.DiskPath = newDiskPath
repo.Name = newName
cols = append(cols, "name", "disk_path")
}
if body.Description != nil { if body.Description != nil {
repo.Description = *body.Description repo.Description = *body.Description
cols = append(cols, "description") cols = append(cols, "description")
@@ -388,6 +443,78 @@ func (h *RepoHandler) Update(w http.ResponseWriter, r *http.Request) {
jsonOK(w, h.withOwnerName(repo)) jsonOK(w, h.withOwnerName(repo))
} }
// GetAvatar serves the repository avatar image stored on disk.
func (h *RepoHandler) GetAvatar(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
data, err := os.ReadFile(avatarPath(h.cfg.RepoRoot, repo.ID))
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", http.DetectContentType(data))
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(data)
}
// UploadAvatar accepts a multipart image upload and stores it as the repo avatar.
func (h *RepoHandler) UploadAvatar(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r)
if !ok {
return
}
callerID, _ := middleware.UserIDFromContext(r.Context())
if callerID != repo.OwnerID {
jsonError(w, "only the owner can change the avatar", http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 5<<20)
if err := r.ParseMultipartForm(5 << 20); err != nil {
jsonError(w, "file too large (max 5 MB)", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("avatar")
if err != nil {
jsonError(w, "avatar file is required", http.StatusBadRequest)
return
}
defer file.Close()
// Sniff content type from first 512 bytes, then read the rest.
sniff := make([]byte, 512)
n, _ := file.Read(sniff)
ct := http.DetectContentType(sniff[:n])
if ct != "image/jpeg" && ct != "image/png" && ct != "image/gif" && ct != "image/webp" {
jsonError(w, "unsupported image type; use JPEG, PNG, GIF, or WebP", http.StatusBadRequest)
return
}
rest, err := io.ReadAll(file)
if err != nil {
jsonError(w, "could not read file", http.StatusInternalServerError)
return
}
data := append(sniff[:n], rest...)
avatarDir := filepath.Join(h.cfg.RepoRoot, ".avatars")
if err := os.MkdirAll(avatarDir, 0755); err != nil {
jsonError(w, "could not create avatar directory", http.StatusInternalServerError)
return
}
if err := os.WriteFile(avatarPath(h.cfg.RepoRoot, repo.ID), data, 0644); err != nil {
jsonError(w, "could not save avatar", http.StatusInternalServerError)
return
}
var ownerUser models.User
h.db.ID(repo.OwnerID).Get(&ownerUser)
jsonOK(w, map[string]string{
"avatarUrl": "/api/v1/repos/" + ownerUser.Username + "/" + repo.Name + "/avatar",
})
}
func (h *RepoHandler) Delete(w http.ResponseWriter, r *http.Request) { func (h *RepoHandler) Delete(w http.ResponseWriter, r *http.Request) {
repo, ok := h.lookupRepo(w, r) repo, ok := h.lookupRepo(w, r)
if !ok { if !ok {
+2
View File
@@ -109,6 +109,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, staticFi
r.With(csrf).Patch("/", repoH.Update) r.With(csrf).Patch("/", repoH.Update)
r.With(csrf).Delete("/", repoH.Delete) r.With(csrf).Delete("/", repoH.Delete)
r.Get("/tree", repoH.Tree) r.Get("/tree", repoH.Tree)
r.Get("/avatar", repoH.GetAvatar)
r.With(csrf).Post("/avatar", repoH.UploadAvatar)
r.Get("/blob", repoH.Blob) r.Get("/blob", repoH.Blob)
r.With(csrf).Put("/blob", repoH.UpdateBlob) r.With(csrf).Put("/blob", repoH.UpdateBlob)
r.Get("/commits", repoH.Commits) r.Get("/commits", repoH.Commits)