repo details page mostly working
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
Binary file not shown.
|
After Width: | Height: | Size: 940 KiB |
@@ -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 ?? ''
|
||||||
|
|||||||
@@ -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] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 className="text-[11px] font-semibold uppercase tracking-wider text-white/40 truncate">
|
||||||
|
{currentRepo}
|
||||||
</p>
|
</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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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
|
||||||
|
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)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 3000)
|
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>
|
||||||
|
: 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>
|
||||||
|
: <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] ?? (
|
|
||||||
<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" />
|
<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>
|
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,8 +382,14 @@ 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 {
|
||||||
|
Name *string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
IsPrivate *bool `json:"isPrivate"`
|
IsPrivate *bool `json:"isPrivate"`
|
||||||
DefaultBranch *string `json:"defaultBranch"`
|
DefaultBranch *string `json:"defaultBranch"`
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user