803672a610
Enable LFS toggle — turns LFS on/off for the repo; all other controls dim when disabled File locking toggle — enables the LFS locking protocol for binary assets Maximum file size — optional per-file size cap in MB (blank = unlimited) Info callout linking to the git-lfs client install page and noting the .gitattributes requirement
1839 lines
94 KiB
TypeScript
1839 lines
94 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { useRepo, useUpdateRepo, useDeleteRepo, useUploadRepoAvatar } from '../api/queries/repos'
|
|
import { useRepoMembers, useAddMember, useUpdateMember, useRemoveMember } from '../api/queries/members'
|
|
import { useDeployKeys, useCreateDeployKey, useDeleteDeployKey, useAccessTokens, useCreateAccessToken, useDeleteAccessToken } from '../api/queries/keys'
|
|
import {
|
|
useBranchProtections, useCreateBranchProtection, useUpdateBranchProtection, useDeleteBranchProtection,
|
|
useBranchingModel, useUpdateBranchingModel,
|
|
useMergeStrategies, useUpdateMergeStrategies,
|
|
useWebhooks, useCreateWebhook, useUpdateWebhook, useDeleteWebhook, useTestWebhook,
|
|
} from '../api/queries/workflow'
|
|
import {
|
|
useDefaultReviewers, useAddDefaultReviewer, useRemoveDefaultReviewer,
|
|
useDefaultDescription, useUpdateDefaultDescription,
|
|
useExcludedFiles, useUpdateExcludedFiles,
|
|
} from '../api/queries/prs'
|
|
import { useLFSSettings, useUpdateLFSSettings } from '../api/queries/lfs'
|
|
import { useRecentRepos } from '../hooks/useRecentRepos'
|
|
import { useAuth } from '../contexts/AuthContext'
|
|
import { Skeleton } from '../ui/Skeleton'
|
|
import { RepoAvatar } from '../ui/RepoAvatar'
|
|
|
|
// ─── Sidebar config ───────────────────────────────────────────────────────────
|
|
|
|
type SectionId =
|
|
| 'repository-details'
|
|
| 'repository-permissions'
|
|
| 'access-keys'
|
|
| 'access-tokens'
|
|
| 'branch-restrictions'
|
|
| 'branching-model'
|
|
| 'merge-strategies'
|
|
| 'webhooks'
|
|
| 'default-reviewers'
|
|
| 'default-description'
|
|
| 'excluded-files'
|
|
| 'git-lfs'
|
|
|
|
const SIDEBAR: { group: string; items: { id: SectionId; label: string }[] }[] = [
|
|
{
|
|
group: 'General',
|
|
items: [
|
|
{ id: 'repository-details', label: 'Repository details' },
|
|
{ id: 'repository-permissions', label: 'Repository permissions' },
|
|
],
|
|
},
|
|
{
|
|
group: 'Security',
|
|
items: [
|
|
{ id: 'access-keys', label: 'Access keys' },
|
|
{ id: 'access-tokens', label: 'Access tokens' },
|
|
],
|
|
},
|
|
{
|
|
group: 'Workflow',
|
|
items: [
|
|
{ id: 'branch-restrictions', label: 'Branch restrictions' },
|
|
{ id: 'branching-model', label: 'Branching model' },
|
|
{ id: 'merge-strategies', label: 'Merge strategies' },
|
|
{ id: 'webhooks', label: 'Webhooks' },
|
|
],
|
|
},
|
|
{
|
|
group: 'Pull Requests',
|
|
items: [
|
|
{ id: 'default-reviewers', label: 'Default reviewers' },
|
|
{ id: 'default-description', label: 'Default description' },
|
|
{ id: 'excluded-files', label: 'Excluded files' },
|
|
],
|
|
},
|
|
{
|
|
group: 'Features',
|
|
items: [
|
|
{ id: 'git-lfs', label: 'Git LFS' },
|
|
],
|
|
},
|
|
]
|
|
|
|
const SECTION_META: Record<SectionId, { title: string; description: string }> = {
|
|
'repository-details': { title: 'Repository details', description: '' },
|
|
'repository-permissions': { title: 'Repository permissions', description: 'Control who can read, write, or administer this repository. Invite collaborators and manage team access.' },
|
|
'access-keys': { title: 'Access keys', description: 'SSH deploy keys give read or write access to this repository without requiring an interactive user account. Useful for CI/CD.' },
|
|
'access-tokens': { title: 'Access tokens', description: 'Repository access tokens give third-party tools secure, scoped access to this repository via the API.' },
|
|
'branch-restrictions': { title: 'Branch restrictions', description: 'Protect important branches by restricting who can push, delete, or force-push to them.' },
|
|
'branching-model': { title: 'Branching model', description: 'Define a branching strategy (e.g. Gitflow) to enforce consistent branch naming across your team.' },
|
|
'merge-strategies': { title: 'Merge strategies', description: 'Configure which merge strategies (merge commit, squash, rebase) are allowed for pull requests.' },
|
|
'webhooks': { title: 'Webhooks', description: 'Send real-time HTTP POST notifications to external services when events occur in this repository.' },
|
|
'default-reviewers': { title: 'Default reviewers', description: 'Automatically add reviewers to pull requests based on changed files or the target branch.' },
|
|
'default-description': { title: 'Default description', description: 'Set a template that pre-fills the pull request body when a new PR is created.' },
|
|
'excluded-files': { title: 'Excluded files', description: 'Specify files that should be excluded from pull request diff views and review requirements.' },
|
|
'git-lfs': { title: 'Git LFS', description: 'Git Large File Storage replaces large files with text pointers inside git, while storing the actual files on a remote server.' },
|
|
}
|
|
|
|
// ─── Page shell ───────────────────────────────────────────────────────────────
|
|
|
|
export default function RepoSettingsPage() {
|
|
const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const [sidebarSearch, setSidebarSearch] = useState('')
|
|
const section = (searchParams.get('section') ?? 'repository-details') as SectionId
|
|
|
|
const filtered = sidebarSearch.trim()
|
|
? SIDEBAR.map(g => ({ ...g, items: g.items.filter(i => i.label.toLowerCase().includes(sidebarSearch.toLowerCase())) })).filter(g => g.items.length > 0)
|
|
: SIDEBAR
|
|
|
|
return (
|
|
<div className="flex h-full overflow-hidden">
|
|
{/* Sidebar */}
|
|
<aside className="w-60 shrink-0 border-r border-[var(--c-border)] bg-[var(--c-surface)] hidden md:flex flex-col">
|
|
<div className="p-3 border-b border-[var(--c-border)] space-y-2.5 shrink-0">
|
|
<Link to={`/repos/${owner}/${repoName}`} className="flex items-center gap-1.5 text-sm text-[var(--c-text)] hover:text-[var(--c-brand)] font-medium">
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
|
</svg>
|
|
Repository settings
|
|
</Link>
|
|
<div className="relative">
|
|
<svg className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--c-muted)] pointer-events-none" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
|
</svg>
|
|
<input value={sidebarSearch} onChange={e => setSidebarSearch(e.target.value)} placeholder="Jump to settings…" className="w-full text-xs bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded pl-7 pr-3 py-1.5 focus:outline-none focus:border-[var(--c-brand-focus)]" />
|
|
</div>
|
|
</div>
|
|
<nav className="flex-1 overflow-y-auto py-2">
|
|
{filtered.map(group => (
|
|
<div key={group.group} className="mb-1">
|
|
<p className="text-[10px] font-semibold text-[var(--c-muted)] uppercase tracking-wider px-4 py-1.5">{group.group}</p>
|
|
{group.items.map(item => (
|
|
<button key={item.id} onClick={() => setSearchParams({ section: item.id })}
|
|
className={`w-full text-left px-4 py-2 text-sm transition-colors border-l-[3px] ${section === item.id ? 'bg-[var(--c-brand-tint)] text-[var(--c-brand)] font-medium border-[var(--c-brand)]' : 'text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] border-transparent'}`}>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
</nav>
|
|
</aside>
|
|
|
|
{/* Content */}
|
|
<main className="flex-1 overflow-y-auto min-w-0">
|
|
<div className="md:hidden p-3 border-b border-[var(--c-border)] bg-[var(--c-surface)]">
|
|
<select value={section} onChange={e => setSearchParams({ section: e.target.value })} className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm focus:outline-none">
|
|
{SIDEBAR.map(g => g.items.map(i => <option key={i.id} value={i.id}>{g.group} → {i.label}</option>))}
|
|
</select>
|
|
</div>
|
|
{section === 'repository-details' && <RepositoryDetailsSection owner={owner} repo={repoName} />}
|
|
{section === 'repository-permissions' && <RepositoryPermissionsSection owner={owner} repo={repoName} />}
|
|
{section === 'access-keys' && <AccessKeysSection owner={owner} repo={repoName} />}
|
|
{section === 'access-tokens' && <AccessTokensSection owner={owner} repo={repoName} />}
|
|
{section === 'branch-restrictions' && <BranchRestrictionsSection owner={owner} repo={repoName} />}
|
|
{section === 'branching-model' && <BranchingModelSection owner={owner} repo={repoName} />}
|
|
{section === 'merge-strategies' && <MergeStrategiesSection owner={owner} repo={repoName} />}
|
|
{section === 'webhooks' && <WebhooksSection owner={owner} repo={repoName} />}
|
|
{section === 'default-reviewers' && <DefaultReviewersSection owner={owner} repo={repoName} />}
|
|
{section === 'default-description' && <DefaultDescriptionSection owner={owner} repo={repoName} />}
|
|
{section === 'excluded-files' && <ExcludedFilesSection owner={owner} repo={repoName} />}
|
|
{section === 'git-lfs' && <GitLFSSection owner={owner} repo={repoName} />}
|
|
{!['repository-details','repository-permissions','access-keys','access-tokens',
|
|
'branch-restrictions','branching-model','merge-strategies','webhooks',
|
|
'default-reviewers','default-description','excluded-files','git-lfs'].includes(section) && <ComingSoon sectionId={section} />}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Repository details ───────────────────────────────────────────────────────
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (!bytes) return '—'
|
|
if (bytes < 1024) return `${bytes} B`
|
|
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`
|
|
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`
|
|
return `${(bytes / 1024 ** 3).toFixed(2)} GB`
|
|
}
|
|
|
|
function RepositoryDetailsSection({ owner, repo }: { owner: string; repo: string }) {
|
|
const navigate = useNavigate()
|
|
const { data: repoData, isLoading } = useRepo(owner, repo)
|
|
const updateRepo = useUpdateRepo(owner, repo)
|
|
const deleteRepo = useDeleteRepo(owner, repo)
|
|
const uploadAvatar = useUploadRepoAvatar(owner, repo)
|
|
const { remove: removeRecent } = useRecentRepos()
|
|
|
|
// Form state
|
|
const [name, setName] = useState('')
|
|
const [description, setDescription] = useState('')
|
|
const [isPrivate, setIsPrivate] = useState(false)
|
|
const [defaultBranch, setDefaultBranch] = useState('')
|
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
|
const [showManage, setShowManage] = useState(false)
|
|
const [confirmDelete, setConfirmDelete] = useState('')
|
|
const [saved, setSaved] = useState(false)
|
|
const [nameError, setNameError] = useState('')
|
|
|
|
// Avatar state
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(null)
|
|
const manageRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Initialize form from API data
|
|
useEffect(() => {
|
|
if (repoData) {
|
|
setName(repoData.name)
|
|
setDescription(repoData.description ?? '')
|
|
setIsPrivate(repoData.isPrivate)
|
|
setDefaultBranch(repoData.defaultBranch)
|
|
}
|
|
}, [repoData])
|
|
|
|
// Close manage dropdown on outside click
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (manageRef.current && !manageRef.current.contains(e.target as Node)) setShowManage(false)
|
|
}
|
|
document.addEventListener('mousedown', handler)
|
|
return () => document.removeEventListener('mousedown', handler)
|
|
}, [])
|
|
|
|
const isDirty = repoData != null && (
|
|
name !== repoData.name ||
|
|
description !== (repoData.description ?? '') ||
|
|
isPrivate !== repoData.isPrivate ||
|
|
defaultBranch !== repoData.defaultBranch
|
|
)
|
|
|
|
function handleDiscard() {
|
|
if (!repoData) return
|
|
setName(repoData.name)
|
|
setDescription(repoData.description ?? '')
|
|
setIsPrivate(repoData.isPrivate)
|
|
setDefaultBranch(repoData.defaultBranch)
|
|
setNameError('')
|
|
}
|
|
|
|
function handleNameChange(val: string) {
|
|
setName(val)
|
|
if (val && !/^[a-zA-Z0-9._-]+$/.test(val)) {
|
|
setNameError('Only letters, numbers, hyphens, underscores, and dots are allowed.')
|
|
} else {
|
|
setNameError('')
|
|
}
|
|
}
|
|
|
|
async function handleSave(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (nameError) return
|
|
const payload: Record<string, unknown> = { description, isPrivate, defaultBranch }
|
|
if (name !== repoData?.name) payload.name = name
|
|
const updated = await updateRepo.mutateAsync(payload)
|
|
if (updated.name !== repo) {
|
|
// Renamed — navigate to new URL
|
|
navigate(`/repos/${owner}/${updated.name}/settings?section=repository-details`, { replace: true })
|
|
} else {
|
|
setSaved(true)
|
|
setTimeout(() => setSaved(false), 3000)
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (confirmDelete !== repo) return
|
|
await deleteRepo.mutateAsync()
|
|
removeRecent(owner, repo)
|
|
navigate('/repos')
|
|
}
|
|
|
|
const handleAvatarFile = useCallback((file: File) => {
|
|
const reader = new FileReader()
|
|
reader.onload = () => setAvatarPreview(reader.result as string)
|
|
reader.readAsDataURL(file)
|
|
uploadAvatar.mutate(file)
|
|
}, [uploadAvatar])
|
|
|
|
if (isLoading || !repoData) {
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-4">
|
|
<Skeleton className="h-8 w-48" />
|
|
<Skeleton className="h-5 w-full" />
|
|
<Skeleton className="h-24 w-full" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-6">
|
|
|
|
{/* Page header */}
|
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
<div>
|
|
<div className="flex items-center gap-1 text-xs text-[var(--c-muted)] mb-1.5">
|
|
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)]">{owner}</Link>
|
|
<span>/</span>
|
|
<Link to={`/repos/${owner}/${repo}`} className="hover:text-[var(--c-brand)]">{repo}</Link>
|
|
</div>
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Repository details</h1>
|
|
</div>
|
|
|
|
{/* Manage repository dropdown */}
|
|
<div className="relative shrink-0" ref={manageRef}>
|
|
<button
|
|
onClick={() => setShowManage(s => !s)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 border border-[var(--c-border)] rounded text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] font-medium"
|
|
>
|
|
Manage repository
|
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
</button>
|
|
{showManage && (
|
|
<div className="absolute right-0 top-full mt-1 w-52 bg-[var(--c-surface)] border border-[var(--c-border)] 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-[var(--c-danger)] hover:bg-[var(--c-danger-tint)] 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 className="border-t border-[var(--c-border)]" />
|
|
|
|
<form onSubmit={handleSave} className="space-y-6">
|
|
|
|
{/* Avatar */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--c-text)] mb-3">Avatar</label>
|
|
<div className="flex items-center gap-4">
|
|
{/* Clickable avatar */}
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="rounded-lg overflow-hidden ring-2 ring-[var(--c-border)] hover:ring-[var(--c-brand-focus)] transition-all focus:outline-none"
|
|
title="Click to change avatar"
|
|
>
|
|
{avatarPreview ? (
|
|
<img src={avatarPreview} alt="Preview" className="w-14 h-14 object-cover" />
|
|
) : (
|
|
<RepoAvatar
|
|
ownerName={owner}
|
|
name={repoData.name}
|
|
avatarUrl={repoData.avatarUrl}
|
|
size={56}
|
|
/>
|
|
)}
|
|
</button>
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploadAvatar.isPending}
|
|
className="px-3 py-1.5 text-sm border border-[var(--c-border)] rounded text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] disabled:opacity-50"
|
|
>
|
|
{uploadAvatar.isPending ? 'Uploading…' : 'Change avatar'}
|
|
</button>
|
|
<p className="text-xs text-[var(--c-muted)] mt-1">JPEG, PNG, GIF or WebP · max 5 MB</p>
|
|
{uploadAvatar.isError && (
|
|
<p className="text-xs text-[var(--c-danger)] mt-1">{(uploadAvatar.error as Error).message}</p>
|
|
)}
|
|
{uploadAvatar.isSuccess && !avatarPreview && (
|
|
<p className="text-xs text-[var(--c-success)] mt-1">Avatar updated.</p>
|
|
)}
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
|
className="hidden"
|
|
onChange={e => { const f = e.target.files?.[0]; if (f) handleAvatarFile(f) }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Repository name */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--c-text)] mb-1">
|
|
Repository name <span className="text-[var(--c-danger)]">*</span>
|
|
</label>
|
|
<input
|
|
value={name}
|
|
onChange={e => handleNameChange(e.target.value)}
|
|
className={`w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-1 ${nameError ? 'border-[var(--c-danger)] focus:border-[var(--c-danger)] focus:ring-[var(--c-danger)]' : 'border-[var(--c-border)] focus:border-[var(--c-brand-focus)] focus:ring-[var(--c-brand-focus)]'}`}
|
|
/>
|
|
{nameError
|
|
? <p className="text-xs text-[var(--c-danger)] mt-1">{nameError}</p>
|
|
: name !== repoData.name
|
|
? <p className="text-xs text-[var(--c-warning)] 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-[var(--c-muted)] mt-1">Letters, numbers, hyphens, underscores, and dots only.</p>
|
|
}
|
|
</div>
|
|
|
|
{/* Size (read-only) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--c-text)] mb-1">Size</label>
|
|
<p className="text-sm text-[var(--c-text)]">{formatSize(repoData.size)}</p>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--c-text)] mb-1">Description</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={e => setDescription(e.target.value)}
|
|
rows={4}
|
|
placeholder="Describe this repository…"
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)] resize-y"
|
|
/>
|
|
</div>
|
|
|
|
{/* Access level */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--c-text)] mb-2">Access level</label>
|
|
<label className="flex items-start gap-2.5 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={isPrivate}
|
|
onChange={e => setIsPrivate(e.target.checked)}
|
|
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]"
|
|
/>
|
|
<div>
|
|
<span className="text-sm font-medium text-[var(--c-text)]">
|
|
{isPrivate ? 'This is a private repository' : 'This is a public repository'}
|
|
</span>
|
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
|
{isPrivate
|
|
? 'Only collaborators you invite can see and push to this repository.'
|
|
: 'Anyone can view this repository. Make it private to restrict access.'}
|
|
</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
{/* Advanced (collapsible) */}
|
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowAdvanced(s => !s)}
|
|
className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] text-left"
|
|
>
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24"
|
|
className={`transition-transform shrink-0 ${showAdvanced ? 'rotate-90' : ''}`}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
|
</svg>
|
|
Advanced
|
|
</button>
|
|
|
|
{showAdvanced && (
|
|
<div className="border-t border-[var(--c-border)] px-4 py-5 space-y-6 bg-[var(--c-surface-raised)]">
|
|
{/* Default branch */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--c-text)] mb-1">Default branch</label>
|
|
<input
|
|
value={defaultBranch}
|
|
onChange={e => setDefaultBranch(e.target.value)}
|
|
placeholder="main"
|
|
className="w-full max-w-xs bg-[var(--c-surface)] border border-[var(--c-border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--c-brand-focus)]"
|
|
/>
|
|
<p className="text-xs text-[var(--c-muted)] mt-1">The default branch used as the base for new pull requests.</p>
|
|
</div>
|
|
|
|
{/* Danger zone */}
|
|
<div id="danger-zone" className="border border-[var(--c-danger-tint)] rounded-lg overflow-hidden">
|
|
<div className="px-4 py-3 bg-[var(--c-danger-tint)]/60 border-b border-[var(--c-danger-tint)]">
|
|
<h3 className="text-sm font-semibold text-[var(--c-danger-dark)]">Delete repository</h3>
|
|
</div>
|
|
<div className="px-4 py-4 space-y-3 bg-[var(--c-surface)]">
|
|
<p className="text-sm text-[var(--c-text)]">
|
|
This is permanent and cannot be undone. All commits, branches, pull requests, issues, and settings will be permanently deleted.
|
|
</p>
|
|
<p className="text-xs text-[var(--c-muted)]">
|
|
Type <code className="font-mono bg-[var(--c-surface-muted)] border border-[var(--c-border)] px-1.5 py-0.5 rounded text-[var(--c-text)]">{repo}</code> to confirm.
|
|
</p>
|
|
<input
|
|
value={confirmDelete}
|
|
onChange={e => setConfirmDelete(e.target.value)}
|
|
placeholder={repo}
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--c-danger)]"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleDelete}
|
|
disabled={confirmDelete !== repo || deleteRepo.isPending}
|
|
className="px-4 py-2 rounded bg-[var(--c-danger)] text-white text-sm font-medium hover:bg-[var(--c-danger-dark)] disabled:opacity-40"
|
|
>
|
|
{deleteRepo.isPending ? 'Deleting…' : 'Delete repository'}
|
|
</button>
|
|
{deleteRepo.isError && (
|
|
<p className="text-xs text-[var(--c-danger)]">{(deleteRepo.error as Error).message}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer: error / saved / Discard / Save */}
|
|
<div className="flex items-center justify-end gap-3 pt-2 border-t border-[var(--c-border)]">
|
|
{updateRepo.isError && (
|
|
<p className="text-xs text-[var(--c-danger)] mr-auto">{(updateRepo.error as Error).message}</p>
|
|
)}
|
|
{saved && !updateRepo.isError && (
|
|
<span className="text-xs text-[var(--c-success)] font-medium mr-auto flex items-center gap-1">
|
|
<svg width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
|
</svg>
|
|
Changes saved
|
|
</span>
|
|
)}
|
|
<button type="button" onClick={handleDiscard} disabled={!isDirty}
|
|
className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] disabled:opacity-40 min-h-[36px]">
|
|
Discard
|
|
</button>
|
|
<button type="submit" disabled={updateRepo.isPending || !isDirty || !!nameError}
|
|
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50 min-h-[36px]">
|
|
{updateRepo.isPending ? 'Saving…' : 'Save changes'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Repository permissions section ──────────────────────────────────────────
|
|
|
|
const PERMISSION_LABELS: Record<string, { label: string; description: string; color: string }> = {
|
|
read: { label: 'Read', description: 'Can clone and pull', color: 'bg-[var(--c-surface-muted)] text-[var(--c-muted)]' },
|
|
write: { label: 'Write', description: 'Can push branches and commits', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300' },
|
|
admin: { label: 'Admin', description: 'Can manage settings and members', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300' },
|
|
}
|
|
|
|
function PermissionBadge({ permission }: { permission: string }) {
|
|
const p = PERMISSION_LABELS[permission] ?? PERMISSION_LABELS.read
|
|
return (
|
|
<span className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full ${p.color}`}>
|
|
{p.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function RepositoryPermissionsSection({ owner, repo }: { owner: string; repo: string }) {
|
|
const { user } = useAuth()
|
|
const { data: members, isLoading } = useRepoMembers(owner, repo)
|
|
const addMember = useAddMember(owner, repo)
|
|
const updateMember = useUpdateMember(owner, repo)
|
|
const removeMember = useRemoveMember(owner, repo)
|
|
|
|
const [username, setUsername] = useState('')
|
|
const [permission, setPermission] = useState('write')
|
|
const [addError, setAddError] = useState('')
|
|
|
|
const isOwner = members?.find(m => m.isOwner)?.username === user?.username
|
|
|
|
async function handleAdd(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setAddError('')
|
|
if (!username.trim()) return
|
|
try {
|
|
await addMember.mutateAsync({ username: username.trim(), permission })
|
|
setUsername('')
|
|
} catch (err) {
|
|
setAddError((err as Error).message)
|
|
}
|
|
}
|
|
|
|
async function handlePermissionChange(memberUsername: string, newPermission: string) {
|
|
await updateMember.mutateAsync({ username: memberUsername, permission: newPermission })
|
|
}
|
|
|
|
async function handleRemove(memberUsername: string) {
|
|
await removeMember.mutateAsync(memberUsername)
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Repository permissions</h1>
|
|
<p className="text-sm text-[var(--c-muted)] mt-1">
|
|
Manage who has access to this repository and what they can do.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="border-t border-[var(--c-border)]" />
|
|
|
|
{/* Permission level reference */}
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{Object.entries(PERMISSION_LABELS).map(([key, val]) => (
|
|
<div key={key} className="border border-[var(--c-border)] rounded-lg p-3 bg-[var(--c-surface)]">
|
|
<PermissionBadge permission={key} />
|
|
<p className="text-xs text-[var(--c-muted)] mt-2">{val.description}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Member list */}
|
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
|
|
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
|
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">Members</h2>
|
|
<span className="text-xs text-[var(--c-muted)]">{members?.length ?? 0} {members?.length === 1 ? 'person' : 'people'}</span>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="p-4 space-y-3">
|
|
{[1,2].map(i => (
|
|
<div key={i} className="flex items-center gap-3">
|
|
<Skeleton className="w-8 h-8 rounded-full" />
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-4 w-16 ml-auto" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<ul className="divide-y divide-[var(--c-border)]">
|
|
{members?.map(member => (
|
|
<li key={member.userId} className="flex items-center gap-3 px-4 py-3">
|
|
{/* Avatar */}
|
|
<div className="w-8 h-8 rounded-full overflow-hidden shrink-0 bg-[var(--c-brand)] flex items-center justify-center text-white text-sm font-bold">
|
|
{member.avatarUrl
|
|
? <img src={member.avatarUrl} alt={member.username} className="w-full h-full object-cover" />
|
|
: member.username[0]?.toUpperCase()
|
|
}
|
|
</div>
|
|
|
|
{/* Name */}
|
|
<div className="flex-1 min-w-0">
|
|
<span className="text-sm font-medium text-[var(--c-text)]">{member.username}</span>
|
|
{member.isOwner && (
|
|
<span className="ml-2 text-[10px] text-[var(--c-muted)]">owner</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Permission selector or badge */}
|
|
{member.isOwner ? (
|
|
<PermissionBadge permission="admin" />
|
|
) : isOwner ? (
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
value={member.permission}
|
|
onChange={e => handlePermissionChange(member.username, e.target.value)}
|
|
disabled={updateMember.isPending}
|
|
className="text-xs border border-[var(--c-border)] rounded px-2 py-1 bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]"
|
|
>
|
|
<option value="read">Read</option>
|
|
<option value="write">Write</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
<button
|
|
onClick={() => handleRemove(member.username)}
|
|
disabled={removeMember.isPending}
|
|
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] disabled:opacity-40 p-1 rounded hover:bg-[var(--c-danger-tint)] transition-colors"
|
|
title="Remove member"
|
|
>
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<PermissionBadge permission={member.permission} />
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add member form — only for owner/admin */}
|
|
{isOwner && (
|
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
|
|
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
|
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">Add a member</h2>
|
|
</div>
|
|
<form onSubmit={handleAdd} className="p-4 space-y-4">
|
|
<div className="flex gap-3 flex-wrap">
|
|
<div className="flex-1 min-w-[180px]">
|
|
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Username</label>
|
|
<input
|
|
value={username}
|
|
onChange={e => { setUsername(e.target.value); setAddError('') }}
|
|
placeholder="e.g. alice"
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)]"
|
|
/>
|
|
</div>
|
|
<div className="w-36">
|
|
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Permission</label>
|
|
<select
|
|
value={permission}
|
|
onChange={e => setPermission(e.target.value)}
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]"
|
|
>
|
|
<option value="read">Read</option>
|
|
<option value="write">Write</option>
|
|
<option value="admin">Admin</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-xs text-[var(--c-muted)]">
|
|
{PERMISSION_LABELS[permission]?.description}
|
|
</div>
|
|
|
|
{addError && (
|
|
<p className="text-xs text-[var(--c-danger)]">{addError}</p>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={addMember.isPending || !username.trim()}
|
|
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
|
|
>
|
|
{addMember.isPending ? 'Adding…' : 'Add member'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* Info for non-owners */}
|
|
{!isOwner && !isLoading && (
|
|
<div className="flex items-start gap-3 p-4 border border-[var(--c-border)] rounded-lg bg-[var(--c-surface-raised)]">
|
|
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0 mt-0.5">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
|
</svg>
|
|
<p className="text-sm text-[var(--c-muted)]">
|
|
Only the repository owner and admins can manage member permissions.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
|
|
|
function TokenReveal({ token, onDismiss }: { token: string; onDismiss: () => void }) {
|
|
const [copied, setCopied] = useState(false)
|
|
function copy() {
|
|
navigator.clipboard.writeText(token)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
return (
|
|
<div className="border border-[var(--c-success)] rounded-lg overflow-hidden">
|
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-[var(--c-surface-raised)] border-b border-[var(--c-success)]/30">
|
|
<svg width="14" height="14" fill="none" stroke="var(--c-success)" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
|
</svg>
|
|
<span className="text-sm font-semibold text-[var(--c-success)]">Token created — copy it now, it won't be shown again</span>
|
|
</div>
|
|
<div className="p-4 space-y-3 bg-[var(--c-surface)]">
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 font-mono text-xs bg-[var(--c-surface-muted)] border border-[var(--c-border)] rounded px-3 py-2 text-[var(--c-text)] break-all">
|
|
{token}
|
|
</code>
|
|
<button onClick={copy}
|
|
className="shrink-0 px-3 py-2 rounded border border-[var(--c-border)] text-xs font-medium text-[var(--c-text)] hover:bg-[var(--c-surface-muted)] transition-colors">
|
|
{copied ? 'Copied!' : 'Copy'}
|
|
</button>
|
|
</div>
|
|
<button onClick={onDismiss} className="text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] underline">
|
|
I've copied it, dismiss
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Access keys (deploy keys) section ───────────────────────────────────────
|
|
|
|
function AccessKeysSection({ owner, repo }: { owner: string; repo: string }) {
|
|
const { data: keys, isLoading } = useDeployKeys(owner, repo)
|
|
const createKey = useCreateDeployKey(owner, repo)
|
|
const deleteKey = useDeleteDeployKey(owner, repo)
|
|
|
|
const [title, setTitle] = useState('')
|
|
const [readOnly, setReadOnly] = useState(true)
|
|
const [newToken, setNewToken] = useState<string | null>(null)
|
|
const [formError, setFormError] = useState('')
|
|
|
|
async function handleCreate(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setFormError('')
|
|
if (!title.trim()) return
|
|
try {
|
|
const key = await createKey.mutateAsync({ title: title.trim(), readOnly })
|
|
if (key.token) setNewToken(key.token)
|
|
setTitle('')
|
|
setReadOnly(true)
|
|
} catch (err) {
|
|
setFormError((err as Error).message)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Access keys</h1>
|
|
<p className="text-sm text-[var(--c-muted)] mt-1">
|
|
Deploy keys grant git clone and push access to this repository over HTTP. Each key is shown only once.
|
|
</p>
|
|
<div className="mt-3 p-3 rounded-lg bg-[var(--c-surface-raised)] border border-[var(--c-border)] text-xs text-[var(--c-muted)]">
|
|
Use as git credentials: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">git clone http://x-deploy-key:<TOKEN>@{window.location.hostname}/{owner}/{repo}.git</code>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-[var(--c-border)]" />
|
|
|
|
{/* Token reveal banner */}
|
|
{newToken && <TokenReveal token={newToken} onDismiss={() => setNewToken(null)} />}
|
|
|
|
{/* Existing keys */}
|
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
|
|
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
|
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">Active keys</h2>
|
|
<span className="text-xs text-[var(--c-muted)]">{keys?.length ?? 0} key{keys?.length !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="p-4 space-y-3">
|
|
{[1, 2].map(i => <div key={i} className="flex gap-3"><Skeleton className="h-4 w-32" /><Skeleton className="h-4 w-16 ml-auto" /></div>)}
|
|
</div>
|
|
) : !keys?.length ? (
|
|
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No deploy keys yet.</div>
|
|
) : (
|
|
<ul className="divide-y divide-[var(--c-border)]">
|
|
{keys.map(key => (
|
|
<li key={key.id} className="flex items-center gap-3 px-4 py-3">
|
|
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
|
|
<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>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-[var(--c-text)]">{key.title}</p>
|
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
|
{key.readOnly ? 'Read-only' : 'Read & write'} · Added {new Date(key.createdAt).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<span className={`text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full ${key.readOnly ? 'bg-[var(--c-surface-muted)] text-[var(--c-muted)]' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'}`}>
|
|
{key.readOnly ? 'Read' : 'Write'}
|
|
</span>
|
|
<button
|
|
onClick={() => deleteKey.mutate(key.id)}
|
|
disabled={deleteKey.isPending}
|
|
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] transition-colors disabled:opacity-40"
|
|
title="Revoke key"
|
|
>
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create form */}
|
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
|
|
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
|
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">Add a deploy key</h2>
|
|
</div>
|
|
<form onSubmit={handleCreate} className="p-4 space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Label</label>
|
|
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. CI/CD pipeline"
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)]" />
|
|
</div>
|
|
<label className="flex items-start gap-2.5 cursor-pointer">
|
|
<input type="checkbox" checked={readOnly} onChange={e => setReadOnly(e.target.checked)}
|
|
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]" />
|
|
<div>
|
|
<span className="text-sm font-medium text-[var(--c-text)]">Read-only</span>
|
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">Uncheck to allow pushes with this key.</p>
|
|
</div>
|
|
</label>
|
|
{formError && <p className="text-xs text-[var(--c-danger)]">{formError}</p>}
|
|
<button type="submit" disabled={createKey.isPending || !title.trim()}
|
|
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
|
|
{createKey.isPending ? 'Generating…' : 'Generate key'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Access tokens section ────────────────────────────────────────────────────
|
|
|
|
function AccessTokensSection({ owner, repo }: { owner: string; repo: string }) {
|
|
const { data: tokens, isLoading } = useAccessTokens(owner, repo)
|
|
const createToken = useCreateAccessToken(owner, repo)
|
|
const deleteToken = useDeleteAccessToken(owner, repo)
|
|
|
|
const [title, setTitle] = useState('')
|
|
const [scopes, setScopes] = useState<string[]>(['read'])
|
|
const [expiresAt, setExpiresAt] = useState('')
|
|
const [newToken, setNewToken] = useState<string | null>(null)
|
|
const [formError, setFormError] = useState('')
|
|
|
|
function toggleScope(scope: string) {
|
|
setScopes(prev =>
|
|
prev.includes(scope) ? prev.filter(s => s !== scope) : [...prev, scope]
|
|
)
|
|
}
|
|
|
|
async function handleCreate(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setFormError('')
|
|
if (!title.trim() || scopes.length === 0) return
|
|
try {
|
|
const tok = await createToken.mutateAsync({
|
|
title: title.trim(),
|
|
scopes: scopes.join(','),
|
|
expiresAt: expiresAt || undefined,
|
|
})
|
|
if (tok.token) setNewToken(tok.token)
|
|
setTitle('')
|
|
setScopes(['read'])
|
|
setExpiresAt('')
|
|
} catch (err) {
|
|
setFormError((err as Error).message)
|
|
}
|
|
}
|
|
|
|
const scopeLabels: Record<string, { label: string; description: string }> = {
|
|
read: { label: 'Read', description: 'Read repo contents, branches, commits, PRs, issues' },
|
|
write: { label: 'Write', description: 'Push code, create PRs and issues' },
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Access tokens</h1>
|
|
<p className="text-sm text-[var(--c-muted)] mt-1">
|
|
Tokens grant programmatic access to this repository's API and git operations. Each token is shown only once.
|
|
</p>
|
|
<div className="mt-3 p-3 rounded-lg bg-[var(--c-surface-raised)] border border-[var(--c-border)] text-xs text-[var(--c-muted)] space-y-1">
|
|
<p>API: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">Authorization: Bearer <TOKEN></code></p>
|
|
<p>Git: <code className="font-mono bg-[var(--c-surface-muted)] px-1 rounded">git clone http://x-token:<TOKEN>@{window.location.hostname}/{owner}/{repo}.git</code></p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-[var(--c-border)]" />
|
|
|
|
{newToken && <TokenReveal token={newToken} onDismiss={() => setNewToken(null)} />}
|
|
|
|
{/* Token list */}
|
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
|
|
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex items-center justify-between">
|
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">Active tokens</h2>
|
|
<span className="text-xs text-[var(--c-muted)]">{tokens?.length ?? 0} token{tokens?.length !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="p-4 space-y-3">
|
|
{[1, 2].map(i => <div key={i} className="flex gap-3"><Skeleton className="h-4 w-40" /><Skeleton className="h-4 w-20 ml-auto" /></div>)}
|
|
</div>
|
|
) : !tokens?.length ? (
|
|
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No access tokens yet.</div>
|
|
) : (
|
|
<ul className="divide-y divide-[var(--c-border)]">
|
|
{tokens.map(tok => {
|
|
const expired = tok.expiresAt && new Date(tok.expiresAt) < new Date()
|
|
return (
|
|
<li key={tok.id} className="flex items-center gap-3 px-4 py-3">
|
|
<svg width="16" height="16" fill="none" stroke="var(--c-muted)" strokeWidth="1.5" viewBox="0 0 24 24" className="shrink-0">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
|
|
</svg>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-[var(--c-text)]">{tok.title}</p>
|
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
|
Scopes: {tok.scopes}
|
|
{tok.expiresAt && (
|
|
<span className={expired ? ' · Expired' : ` · Expires ${new Date(tok.expiresAt).toLocaleDateString()}`} />
|
|
)}
|
|
{!tok.expiresAt && ' · No expiry'}
|
|
</p>
|
|
</div>
|
|
{expired && (
|
|
<span className="text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded-full bg-[var(--c-danger-tint)] text-[var(--c-danger)]">Expired</span>
|
|
)}
|
|
<button
|
|
onClick={() => deleteToken.mutate(tok.id)}
|
|
disabled={deleteToken.isPending}
|
|
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] transition-colors disabled:opacity-40"
|
|
title="Revoke token"
|
|
>
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create form */}
|
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
|
|
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
|
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">Create an access token</h2>
|
|
</div>
|
|
<form onSubmit={handleCreate} className="p-4 space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Token name</label>
|
|
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. GitHub Actions"
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)]" />
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--c-muted)] mb-2">Scopes</label>
|
|
<div className="space-y-2">
|
|
{Object.entries(scopeLabels).map(([key, val]) => (
|
|
<label key={key} className="flex items-start gap-2.5 cursor-pointer">
|
|
<input type="checkbox" checked={scopes.includes(key)} onChange={() => toggleScope(key)}
|
|
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]" />
|
|
<div>
|
|
<span className="text-sm font-medium text-[var(--c-text)]">{val.label}</span>
|
|
<p className="text-xs text-[var(--c-muted)]">{val.description}</p>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Expiry date <span className="text-[var(--c-subtle)]">(optional)</span></label>
|
|
<input type="date" value={expiresAt} onChange={e => setExpiresAt(e.target.value)}
|
|
min={new Date().toISOString().split('T')[0]}
|
|
className="border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
|
|
</div>
|
|
|
|
{formError && <p className="text-xs text-[var(--c-danger)]">{formError}</p>}
|
|
|
|
<button type="submit" disabled={createToken.isPending || !title.trim() || scopes.length === 0}
|
|
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
|
|
{createToken.isPending ? 'Generating…' : 'Generate token'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Branch restrictions ──────────────────────────────────────────────────────
|
|
|
|
function BranchRestrictionsSection({ owner, repo }: { owner: string; repo: string }) {
|
|
const { data: rules, isLoading } = useBranchProtections(owner, repo)
|
|
const createRule = useCreateBranchProtection(owner, repo)
|
|
const updateRule = useUpdateBranchProtection(owner, repo)
|
|
const deleteRule = useDeleteBranchProtection(owner, repo)
|
|
|
|
const [pattern, setPattern] = useState('')
|
|
const [requirePR, setRequirePR] = useState(true)
|
|
const [blockForcePush, setBlockForcePush] = useState(true)
|
|
const [allowedUsers, setAllowedUsers] = useState('')
|
|
const [error, setError] = useState('')
|
|
|
|
async function handleCreate(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setError('')
|
|
try {
|
|
await createRule.mutateAsync({ pattern: pattern.trim(), requirePR, blockForcePush, allowedUsers: allowedUsers.trim() })
|
|
setPattern('')
|
|
} catch (err) { setError((err as Error).message) }
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Branch restrictions</h1>
|
|
<p className="text-sm text-[var(--c-muted)] mt-1">
|
|
Protect branches from direct pushes. Matched branches can only be updated via pull requests.
|
|
</p>
|
|
</div>
|
|
<div className="border-t border-[var(--c-border)]" />
|
|
|
|
{/* Rule list */}
|
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
|
|
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)] flex justify-between">
|
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">Protected branches</h2>
|
|
<span className="text-xs text-[var(--c-muted)]">{rules?.length ?? 0} rule{rules?.length !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
{isLoading ? (
|
|
<div className="p-4"><Skeleton className="h-4 w-full" /></div>
|
|
) : !rules?.length ? (
|
|
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No branch protection rules yet.</div>
|
|
) : (
|
|
<ul className="divide-y divide-[var(--c-border)]">
|
|
{rules.map(rule => (
|
|
<li key={rule.id} className="px-4 py-3 space-y-2">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<svg width="14" height="14" fill="none" stroke="var(--c-brand)" strokeWidth="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>
|
|
<code className="text-sm font-mono font-semibold text-[var(--c-text)]">{rule.pattern}</code>
|
|
</div>
|
|
<button onClick={() => deleteRule.mutate(rule.id)} disabled={deleteRule.isPending}
|
|
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] disabled:opacity-40">
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="flex gap-4 text-xs text-[var(--c-muted)] ml-6">
|
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
|
<input type="checkbox" checked={rule.requirePR} className="w-3 h-3 accent-[var(--c-brand)]"
|
|
onChange={e => updateRule.mutate({ id: rule.id, requirePR: e.target.checked, blockForcePush: rule.blockForcePush, allowedUsers: rule.allowedUsers })} />
|
|
Require PR
|
|
</label>
|
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
|
<input type="checkbox" checked={rule.blockForcePush} className="w-3 h-3 accent-[var(--c-brand)]"
|
|
onChange={e => updateRule.mutate({ id: rule.id, requirePR: rule.requirePR, blockForcePush: e.target.checked, allowedUsers: rule.allowedUsers })} />
|
|
Block force push
|
|
</label>
|
|
{rule.allowedUsers && <span>Bypass: <code className="text-[var(--c-text)]">{rule.allowedUsers}</code></span>}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Create rule form */}
|
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
|
|
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
|
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">Add a rule</h2>
|
|
</div>
|
|
<form onSubmit={handleCreate} className="p-4 space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Branch pattern</label>
|
|
<input value={pattern} onChange={e => setPattern(e.target.value)} placeholder="main, release/*, v*"
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] font-mono focus:outline-none focus:border-[var(--c-brand-focus)] focus:ring-1 focus:ring-[var(--c-brand-focus)]" />
|
|
<p className="text-xs text-[var(--c-muted)] mt-1">Glob patterns supported: <code className="font-mono">release/*</code> matches all release branches.</p>
|
|
</div>
|
|
<div className="flex gap-6">
|
|
<label className="flex items-center gap-2 cursor-pointer text-sm text-[var(--c-text)]">
|
|
<input type="checkbox" checked={requirePR} onChange={e => setRequirePR(e.target.checked)} className="w-4 h-4 accent-[var(--c-brand)]" />
|
|
Require pull request
|
|
</label>
|
|
<label className="flex items-center gap-2 cursor-pointer text-sm text-[var(--c-text)]">
|
|
<input type="checkbox" checked={blockForcePush} onChange={e => setBlockForcePush(e.target.checked)} className="w-4 h-4 accent-[var(--c-brand)]" />
|
|
Block force push
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Bypass list <span className="text-[var(--c-subtle)]">(optional — comma-separated usernames)</span></label>
|
|
<input value={allowedUsers} onChange={e => setAllowedUsers(e.target.value)} placeholder="alice, bob"
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
|
|
</div>
|
|
{error && <p className="text-xs text-[var(--c-danger)]">{error}</p>}
|
|
<button type="submit" disabled={createRule.isPending || !pattern.trim()}
|
|
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
|
|
{createRule.isPending ? 'Adding…' : 'Add rule'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Branching model ──────────────────────────────────────────────────────────
|
|
|
|
function BranchingModelSection({ owner, repo }: { owner: string; repo: string }) {
|
|
const { data, isLoading } = useBranchingModel(owner, repo)
|
|
const updateModel = useUpdateBranchingModel(owner, repo)
|
|
const [form, setForm] = useState({ enabled: false, featurePrefix: 'feature/', bugfixPrefix: 'bugfix/', releasePrefix: 'release/', hotfixPrefix: 'hotfix/' })
|
|
const [saved, setSaved] = useState(false)
|
|
|
|
useEffect(() => { if (data) setForm(data) }, [data])
|
|
|
|
async function handleSave(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
await updateModel.mutateAsync(form)
|
|
setSaved(true)
|
|
setTimeout(() => setSaved(false), 3000)
|
|
}
|
|
|
|
const branches = [
|
|
{ key: 'featurePrefix' as const, label: 'Feature', example: 'feature/my-feature', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' },
|
|
{ key: 'bugfixPrefix' as const, label: 'Bugfix', example: 'bugfix/fix-login', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' },
|
|
{ key: 'releasePrefix' as const, label: 'Release', example: 'release/1.0.0', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300' },
|
|
{ key: 'hotfixPrefix' as const, label: 'Hotfix', example: 'hotfix/critical-fix', color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300' },
|
|
]
|
|
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Branching model</h1>
|
|
<p className="text-sm text-[var(--c-muted)] mt-1">Define naming conventions for branch types to keep your team consistent.</p>
|
|
</div>
|
|
<div className="border-t border-[var(--c-border)]" />
|
|
|
|
{isLoading ? <Skeleton className="h-48 w-full" /> : (
|
|
<form onSubmit={handleSave} className="space-y-5">
|
|
<label className="flex items-center gap-3 p-4 border border-[var(--c-border)] rounded-lg cursor-pointer bg-[var(--c-surface)] hover:bg-[var(--c-surface-raised)]">
|
|
<input type="checkbox" checked={form.enabled} onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))}
|
|
className="w-4 h-4 accent-[var(--c-brand)]" />
|
|
<div>
|
|
<span className="text-sm font-medium text-[var(--c-text)]">Enable branching model</span>
|
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">Apply the naming conventions below to this repository.</p>
|
|
</div>
|
|
</label>
|
|
|
|
<div className={`space-y-3 transition-opacity ${form.enabled ? '' : 'opacity-40 pointer-events-none'}`}>
|
|
{branches.map(b => (
|
|
<div key={b.key} className="flex items-center gap-3 p-3 border border-[var(--c-border)] rounded-lg bg-[var(--c-surface)]">
|
|
<span className={`text-[10px] font-bold uppercase tracking-wide px-2 py-1 rounded w-20 text-center shrink-0 ${b.color}`}>{b.label}</span>
|
|
<input value={form[b.key]} onChange={e => setForm(f => ({ ...f, [b.key]: e.target.value }))}
|
|
className="flex-1 border border-[var(--c-border)] rounded px-3 py-1.5 text-sm font-mono bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
|
|
<span className="text-xs text-[var(--c-muted)] font-mono hidden sm:block">{form[b.key] || '…'}my-branch</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 pt-2 border-t border-[var(--c-border)]">
|
|
{saved && <span className="text-xs text-[var(--c-success)] font-medium">Saved!</span>}
|
|
<button type="submit" disabled={updateModel.isPending}
|
|
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50 ml-auto">
|
|
{updateModel.isPending ? 'Saving…' : 'Save changes'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Merge strategies ─────────────────────────────────────────────────────────
|
|
|
|
function MergeStrategiesSection({ owner, repo }: { owner: string; repo: string }) {
|
|
const { data, isLoading } = useMergeStrategies(owner, repo)
|
|
const updateStrategies = useUpdateMergeStrategies(owner, repo)
|
|
const [form, setForm] = useState({ allowMergeCommit: true, allowSquash: true, allowRebase: true })
|
|
const [saved, setSaved] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
useEffect(() => { if (data) setForm(data) }, [data])
|
|
|
|
async function handleSave(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setError('')
|
|
try {
|
|
await updateStrategies.mutateAsync(form)
|
|
setSaved(true)
|
|
setTimeout(() => setSaved(false), 3000)
|
|
} catch (err) { setError((err as Error).message) }
|
|
}
|
|
|
|
const strategies = [
|
|
{ key: 'allowMergeCommit' as const, label: 'Merge commit', description: 'Combines all commits from the branch into a merge commit on the target branch.', icon: 'M' },
|
|
{ key: 'allowSquash' as const, label: 'Squash', description: 'Squashes all branch commits into a single commit on the target branch.', icon: 'S' },
|
|
{ key: 'allowRebase' as const, label: 'Rebase', description: 'Replays each branch commit on top of the target branch without a merge commit.', icon: 'R' },
|
|
]
|
|
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Merge strategies</h1>
|
|
<p className="text-sm text-[var(--c-muted)] mt-1">Control which merge strategies collaborators can use when closing pull requests.</p>
|
|
</div>
|
|
<div className="border-t border-[var(--c-border)]" />
|
|
|
|
{isLoading ? <Skeleton className="h-48 w-full" /> : (
|
|
<form onSubmit={handleSave} className="space-y-4">
|
|
{strategies.map(s => (
|
|
<label key={s.key} className="flex items-start gap-4 p-4 border border-[var(--c-border)] rounded-lg cursor-pointer hover:bg-[var(--c-surface-raised)] bg-[var(--c-surface)]">
|
|
<input type="checkbox" checked={form[s.key]} onChange={e => setForm(f => ({ ...f, [s.key]: e.target.checked }))}
|
|
className="mt-0.5 w-4 h-4 accent-[var(--c-brand)]" />
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-6 h-6 rounded bg-[var(--c-brand-tint)] text-[var(--c-brand)] text-xs font-bold flex items-center justify-center">{s.icon}</span>
|
|
<span className="text-sm font-medium text-[var(--c-text)]">{s.label}</span>
|
|
</div>
|
|
<p className="text-xs text-[var(--c-muted)] mt-1">{s.description}</p>
|
|
</div>
|
|
</label>
|
|
))}
|
|
|
|
{error && <p className="text-xs text-[var(--c-danger)]">{error}</p>}
|
|
<div className="flex items-center gap-3 pt-2 border-t border-[var(--c-border)]">
|
|
{saved && <span className="text-xs text-[var(--c-success)] font-medium">Saved!</span>}
|
|
<button type="submit" disabled={updateStrategies.isPending}
|
|
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50 ml-auto">
|
|
{updateStrategies.isPending ? 'Saving…' : 'Save changes'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Webhooks ─────────────────────────────────────────────────────────────────
|
|
|
|
const ALL_EVENTS = ['push', 'pull_request', 'issue']
|
|
|
|
function WebhooksSection({ owner, repo }: { owner: string; repo: string }) {
|
|
const { data: hooks, isLoading } = useWebhooks(owner, repo)
|
|
const createHook = useCreateWebhook(owner, repo)
|
|
const updateHook = useUpdateWebhook(owner, repo)
|
|
const deleteHook = useDeleteWebhook(owner, repo)
|
|
const testHook = useTestWebhook(owner, repo)
|
|
|
|
const [showForm, setShowForm] = useState(false)
|
|
const [editId, setEditId] = useState<number | null>(null)
|
|
const [title, setTitle] = useState('')
|
|
const [url, setUrl] = useState('')
|
|
const [secret, setSecret] = useState('')
|
|
const [events, setEvents] = useState<string[]>(['push'])
|
|
const [active, setActive] = useState(true)
|
|
const [formError, setFormError] = useState('')
|
|
const [testResult, setTestResult] = useState<Record<number, { ok: boolean; status: number }>>({})
|
|
|
|
function resetForm() {
|
|
setTitle(''); setUrl(''); setSecret(''); setEvents(['push']); setActive(true)
|
|
setFormError(''); setEditId(null); setShowForm(false)
|
|
}
|
|
|
|
function startEdit(wh: { id: number; title: string; url: string; events: string; active: boolean }) {
|
|
setEditId(wh.id); setTitle(wh.title); setUrl(wh.url)
|
|
setEvents(wh.events.split(',')); setActive(wh.active); setSecret('')
|
|
setShowForm(true)
|
|
}
|
|
|
|
function toggleEvent(ev: string) {
|
|
setEvents(prev => prev.includes(ev) ? prev.filter(e => e !== ev) : [...prev, ev])
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setFormError('')
|
|
if (!url.trim()) return setFormError('URL is required')
|
|
if (events.length === 0) return setFormError('Select at least one event')
|
|
try {
|
|
const payload = { title: title.trim(), url: url.trim(), secret: secret || undefined, events: events.join(','), active }
|
|
if (editId) {
|
|
await updateHook.mutateAsync({ id: editId, ...payload })
|
|
} else {
|
|
await createHook.mutateAsync(payload)
|
|
}
|
|
resetForm()
|
|
} catch (err) { setFormError((err as Error).message) }
|
|
}
|
|
|
|
async function handleTest(id: number) {
|
|
const result = await testHook.mutateAsync(id)
|
|
setTestResult(prev => ({ ...prev, [id]: result }))
|
|
setTimeout(() => setTestResult(prev => { const n = { ...prev }; delete n[id]; return n }), 5000)
|
|
}
|
|
|
|
function statusBadge(status: number) {
|
|
if (status === 0) return <span className="text-[10px] text-[var(--c-muted)]">Never delivered</span>
|
|
const ok = status >= 200 && status < 300
|
|
return (
|
|
<span className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full ${ok ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-[var(--c-danger-tint)] text-[var(--c-danger)]'}`}>
|
|
{status}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-6">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Webhooks</h1>
|
|
<p className="text-sm text-[var(--c-muted)] mt-1">Send HTTP POST notifications to external services when events occur.</p>
|
|
</div>
|
|
{!showForm && (
|
|
<button onClick={() => setShowForm(true)}
|
|
className="px-3 py-1.5 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] shrink-0">
|
|
Add webhook
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="border-t border-[var(--c-border)]" />
|
|
|
|
{/* Webhook list */}
|
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
|
|
{isLoading ? (
|
|
<div className="p-4 space-y-3">{[1,2].map(i => <Skeleton key={i} className="h-10 w-full" />)}</div>
|
|
) : !hooks?.length ? (
|
|
<div className="px-4 py-8 text-center text-sm text-[var(--c-muted)]">No webhooks yet.</div>
|
|
) : (
|
|
<ul className="divide-y divide-[var(--c-border)]">
|
|
{hooks.map(wh => (
|
|
<li key={wh.id} className="px-4 py-3 space-y-2">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-2 h-2 rounded-full shrink-0 ${wh.active ? 'bg-[var(--c-success)]' : 'bg-[var(--c-subtle)]'}`} />
|
|
<div className="flex-1 min-w-0">
|
|
{wh.title && <p className="text-sm font-medium text-[var(--c-text)]">{wh.title}</p>}
|
|
<p className="text-xs text-[var(--c-muted)] font-mono truncate">{wh.url}</p>
|
|
</div>
|
|
{statusBadge(wh.lastStatus)}
|
|
{testResult[wh.id] && (
|
|
<span className={`text-[10px] font-semibold ${testResult[wh.id].ok ? 'text-[var(--c-success)]' : 'text-[var(--c-danger)]'}`}>
|
|
Test: {testResult[wh.id].status || 'failed'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 ml-5">
|
|
<div className="flex gap-1 flex-wrap">
|
|
{wh.events.split(',').map(ev => (
|
|
<span key={ev} className="text-[9px] font-semibold uppercase px-1.5 py-0.5 rounded bg-[var(--c-surface-muted)] text-[var(--c-muted)]">{ev}</span>
|
|
))}
|
|
</div>
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<button onClick={() => handleTest(wh.id)} disabled={testHook.isPending}
|
|
className="text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1 rounded hover:bg-[var(--c-surface-muted)] disabled:opacity-40">
|
|
Test
|
|
</button>
|
|
<button onClick={() => startEdit(wh)}
|
|
className="text-xs text-[var(--c-muted)] hover:text-[var(--c-text)] px-2 py-1 rounded hover:bg-[var(--c-surface-muted)]">
|
|
Edit
|
|
</button>
|
|
<button onClick={() => deleteHook.mutate(wh.id)} disabled={deleteHook.isPending}
|
|
className="text-[var(--c-danger)] hover:text-[var(--c-danger-dark)] p-1 rounded hover:bg-[var(--c-danger-tint)] disabled:opacity-40">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add / Edit form */}
|
|
{showForm && (
|
|
<div className="border border-[var(--c-brand-focus)] rounded-lg overflow-hidden bg-[var(--c-surface)]">
|
|
<div className="px-4 py-3 border-b border-[var(--c-border)] bg-[var(--c-surface-raised)]">
|
|
<h2 className="text-sm font-semibold text-[var(--c-text)]">{editId ? 'Edit webhook' : 'Add webhook'}</h2>
|
|
</div>
|
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Title <span className="text-[var(--c-subtle)]">(optional)</span></label>
|
|
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="e.g. Slack notifications"
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Payload URL <span className="text-[var(--c-danger)]">*</span></label>
|
|
<input value={url} onChange={e => setUrl(e.target.value)} placeholder="https://example.com/webhook"
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--c-muted)] mb-1">Secret <span className="text-[var(--c-subtle)]">(optional — used for HMAC-SHA256 signature)</span></label>
|
|
<input type="password" value={secret} onChange={e => setSecret(e.target.value)} placeholder={editId ? 'Leave blank to keep existing' : ''}
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-[var(--c-muted)] mb-2">Events</label>
|
|
<div className="flex gap-3 flex-wrap">
|
|
{ALL_EVENTS.map(ev => (
|
|
<label key={ev} className="flex items-center gap-1.5 cursor-pointer text-sm text-[var(--c-text)]">
|
|
<input type="checkbox" checked={events.includes(ev)} onChange={() => toggleEvent(ev)} className="w-4 h-4 accent-[var(--c-brand)]" />
|
|
{ev}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<label className="flex items-center gap-2 cursor-pointer text-sm text-[var(--c-text)]">
|
|
<input type="checkbox" checked={active} onChange={e => setActive(e.target.checked)} className="w-4 h-4 accent-[var(--c-brand)]" />
|
|
Active
|
|
</label>
|
|
{formError && <p className="text-xs text-[var(--c-danger)]">{formError}</p>}
|
|
<div className="flex gap-2">
|
|
<button type="submit" disabled={createHook.isPending || updateHook.isPending}
|
|
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50">
|
|
{(createHook.isPending || updateHook.isPending) ? 'Saving…' : editId ? 'Update webhook' : 'Add webhook'}
|
|
</button>
|
|
<button type="button" onClick={resetForm}
|
|
className="px-4 py-2 rounded border border-[var(--c-border)] text-sm text-[var(--c-text)] hover:bg-[var(--c-surface-muted)]">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Default reviewers ────────────────────────────────────────────────────────
|
|
|
|
function DefaultReviewersSection({ owner, repo }: { owner: string; repo: string }) {
|
|
const { data: reviewers = [], isLoading } = useDefaultReviewers(owner, repo)
|
|
const addReviewer = useAddDefaultReviewer(owner, repo)
|
|
const removeReviewer = useRemoveDefaultReviewer(owner, repo)
|
|
const [username, setUsername] = useState('')
|
|
const [error, setError] = useState('')
|
|
|
|
async function handleAdd(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setError('')
|
|
const u = username.trim()
|
|
if (!u) return
|
|
try {
|
|
await addReviewer.mutateAsync(u)
|
|
setUsername('')
|
|
} catch (err: any) {
|
|
setError(err.message ?? 'Failed to add reviewer')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Default reviewers</h1>
|
|
<p className="text-sm text-[var(--c-muted)] mt-1">These users are automatically added as reviewers whenever a pull request is opened in this repository.</p>
|
|
</div>
|
|
|
|
<form onSubmit={handleAdd} className="flex gap-2">
|
|
<input
|
|
value={username}
|
|
onChange={e => setUsername(e.target.value)}
|
|
placeholder="Username"
|
|
className="flex-1 border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)]"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={addReviewer.isPending || !username.trim()}
|
|
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
|
|
>
|
|
{addReviewer.isPending ? 'Adding…' : 'Add reviewer'}
|
|
</button>
|
|
</form>
|
|
{error && <p className="text-xs text-[var(--c-danger)]">{error}</p>}
|
|
|
|
<div className="border border-[var(--c-border)] rounded-lg overflow-hidden">
|
|
{isLoading ? (
|
|
<div className="p-4 space-y-3">
|
|
{[0,1,2].map(i => <Skeleton key={i} className="h-10 rounded" />)}
|
|
</div>
|
|
) : reviewers.length === 0 ? (
|
|
<div className="p-8 text-center text-sm text-[var(--c-muted)]">No default reviewers configured.</div>
|
|
) : (
|
|
<ul className="divide-y divide-[var(--c-border)]">
|
|
{reviewers.map(rv => (
|
|
<li key={rv.userId} className="flex items-center justify-between px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
{rv.avatarUrl ? (
|
|
<img src={rv.avatarUrl} alt={rv.username} className="w-8 h-8 rounded-full object-cover" />
|
|
) : (
|
|
<div className="w-8 h-8 rounded-full bg-[var(--c-brand)] flex items-center justify-center text-white text-xs font-semibold">
|
|
{rv.username.slice(0, 1).toUpperCase()}
|
|
</div>
|
|
)}
|
|
<span className="text-sm font-medium text-[var(--c-text)]">{rv.username}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => removeReviewer.mutate(rv.username)}
|
|
disabled={removeReviewer.isPending}
|
|
className="text-xs text-[var(--c-danger)] hover:underline disabled:opacity-50"
|
|
>
|
|
Remove
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Default description ──────────────────────────────────────────────────────
|
|
|
|
function DefaultDescriptionSection({ owner, repo }: { owner: string; repo: string }) {
|
|
const { data, isLoading } = useDefaultDescription(owner, repo)
|
|
const updateDesc = useUpdateDefaultDescription(owner, repo)
|
|
const [template, setTemplate] = useState('')
|
|
const [saved, setSaved] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (data) setTemplate(data.template ?? '')
|
|
}, [data])
|
|
|
|
async function handleSave(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
await updateDesc.mutateAsync(template)
|
|
setSaved(true)
|
|
setTimeout(() => setSaved(false), 2000)
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Default description</h1>
|
|
<p className="text-sm text-[var(--c-muted)] mt-1">This template pre-fills the pull request body when a new PR is opened. Supports Markdown.</p>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<Skeleton className="h-48 rounded" />
|
|
) : (
|
|
<form onSubmit={handleSave} className="space-y-4">
|
|
<textarea
|
|
value={template}
|
|
onChange={e => setTemplate(e.target.value)}
|
|
rows={12}
|
|
placeholder={'## Summary\n\n## Test plan\n\n## Screenshots'}
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2.5 text-sm font-mono bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] resize-y"
|
|
/>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="submit"
|
|
disabled={updateDesc.isPending}
|
|
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
|
|
>
|
|
{updateDesc.isPending ? 'Saving…' : 'Save template'}
|
|
</button>
|
|
{saved && <span className="text-xs text-[var(--c-success)]">Saved</span>}
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Excluded files ───────────────────────────────────────────────────────────
|
|
|
|
function ExcludedFilesSection({ owner, repo }: { owner: string; repo: string }) {
|
|
const { data, isLoading } = useExcludedFiles(owner, repo)
|
|
const updateFiles = useUpdateExcludedFiles(owner, repo)
|
|
const [patterns, setPatterns] = useState('')
|
|
const [saved, setSaved] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (data) setPatterns(data.patterns ?? '')
|
|
}, [data])
|
|
|
|
async function handleSave(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
await updateFiles.mutateAsync(patterns)
|
|
setSaved(true)
|
|
setTimeout(() => setSaved(false), 2000)
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Excluded files</h1>
|
|
<p className="text-sm text-[var(--c-muted)] mt-1">
|
|
Files matching these glob patterns are excluded from pull request diff views. One pattern per line.
|
|
Example: <code className="text-xs bg-[var(--c-surface-muted)] px-1 py-0.5 rounded font-mono">package-lock.json</code>
|
|
</p>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<Skeleton className="h-48 rounded" />
|
|
) : (
|
|
<form onSubmit={handleSave} className="space-y-4">
|
|
<textarea
|
|
value={patterns}
|
|
onChange={e => setPatterns(e.target.value)}
|
|
rows={10}
|
|
placeholder={'package-lock.json\nyarn.lock\ndist/**\n*.min.js'}
|
|
className="w-full border border-[var(--c-border)] rounded px-3 py-2.5 text-sm font-mono bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] resize-y"
|
|
/>
|
|
<div className="text-xs text-[var(--c-muted)]">
|
|
Patterns use glob syntax. <code className="font-mono">*</code> matches any file, <code className="font-mono">**</code> matches any path segment.
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="submit"
|
|
disabled={updateFiles.isPending}
|
|
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
|
|
>
|
|
{updateFiles.isPending ? 'Saving…' : 'Save patterns'}
|
|
</button>
|
|
{saved && <span className="text-xs text-[var(--c-success)]">Saved</span>}
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Git LFS ─────────────────────────────────────────────────────────────────
|
|
|
|
function GitLFSSection({ owner, repo }: { owner: string; repo: string }) {
|
|
const { data, isLoading } = useLFSSettings(owner, repo)
|
|
const update = useUpdateLFSSettings(owner, repo)
|
|
const [maxSizeInput, setMaxSizeInput] = useState('')
|
|
const [sizeError, setSizeError] = useState('')
|
|
const [saved, setSaved] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (data) setMaxSizeInput(data.maxFileSizeMB === 0 ? '' : String(data.maxFileSizeMB))
|
|
}, [data])
|
|
|
|
async function toggle(field: 'enabled' | 'lockingEnabled', value: boolean) {
|
|
await update.mutateAsync({ [field]: value })
|
|
}
|
|
|
|
async function saveMaxSize(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setSizeError('')
|
|
const raw = maxSizeInput.trim()
|
|
const mb = raw === '' ? 0 : parseInt(raw, 10)
|
|
if (raw !== '' && (isNaN(mb) || mb < 0)) {
|
|
setSizeError('Enter a positive number of megabytes, or leave blank for unlimited.')
|
|
return
|
|
}
|
|
await update.mutateAsync({ maxFileSizeMB: mb })
|
|
setSaved(true)
|
|
setTimeout(() => setSaved(false), 2000)
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-4">
|
|
<Skeleton className="h-6 w-48 rounded" />
|
|
<Skeleton className="h-24 rounded" />
|
|
<Skeleton className="h-24 rounded" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const enabled = data?.enabled ?? false
|
|
const lockingEnabled = data?.lockingEnabled ?? true
|
|
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6 space-y-6">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)]">Git LFS</h1>
|
|
<p className="text-sm text-[var(--c-muted)] mt-1">
|
|
Git Large File Storage replaces large binary files (images, videos, datasets) with lightweight text pointers inside Git,
|
|
while storing the actual file content on the server.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Enable / disable */}
|
|
<div className="border border-[var(--c-border)] rounded-lg divide-y divide-[var(--c-border)]">
|
|
<div className="flex items-start justify-between gap-4 px-4 py-4">
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--c-text)]">Enable Git LFS</p>
|
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
|
Allow contributors to push large files using the LFS protocol.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => toggle('enabled', !enabled)}
|
|
disabled={update.isPending}
|
|
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${enabled ? 'bg-[var(--c-brand)]' : 'bg-[var(--c-border)]'}`}
|
|
>
|
|
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className={`flex items-start justify-between gap-4 px-4 py-4 transition-opacity ${enabled ? '' : 'opacity-40 pointer-events-none'}`}>
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--c-text)]">File locking</p>
|
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
|
Allow contributors to lock LFS files to prevent conflicting edits on binary assets.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => toggle('lockingEnabled', !lockingEnabled)}
|
|
disabled={update.isPending || !enabled}
|
|
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50 ${lockingEnabled ? 'bg-[var(--c-brand)]' : 'bg-[var(--c-border)]'}`}
|
|
>
|
|
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${lockingEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Max file size */}
|
|
<div className={`space-y-3 transition-opacity ${enabled ? '' : 'opacity-40 pointer-events-none'}`}>
|
|
<div>
|
|
<p className="text-sm font-medium text-[var(--c-text)]">Maximum file size</p>
|
|
<p className="text-xs text-[var(--c-muted)] mt-0.5">
|
|
Reject LFS pushes for individual files exceeding this size. Leave blank for no limit.
|
|
</p>
|
|
</div>
|
|
<form onSubmit={saveMaxSize} className="flex items-center gap-2">
|
|
<div className="relative">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={maxSizeInput}
|
|
onChange={e => setMaxSizeInput(e.target.value)}
|
|
disabled={!enabled}
|
|
placeholder="Unlimited"
|
|
className="w-36 border border-[var(--c-border)] rounded px-3 py-2 text-sm bg-[var(--c-surface)] text-[var(--c-text)] focus:outline-none focus:border-[var(--c-brand-focus)] disabled:opacity-50 pr-10"
|
|
/>
|
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--c-muted)] pointer-events-none">MB</span>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={update.isPending || !enabled}
|
|
className="px-4 py-2 rounded bg-[var(--c-brand)] text-white text-sm font-medium hover:bg-[var(--c-brand-hover)] disabled:opacity-50"
|
|
>
|
|
{update.isPending ? 'Saving…' : 'Save'}
|
|
</button>
|
|
{saved && <span className="text-xs text-[var(--c-success)]">Saved</span>}
|
|
</form>
|
|
{sizeError && <p className="text-xs text-[var(--c-danger)]">{sizeError}</p>}
|
|
</div>
|
|
|
|
{/* Info box */}
|
|
<div className="rounded-lg bg-[var(--c-surface-muted)] border border-[var(--c-border)] px-4 py-3 flex gap-3">
|
|
<svg className="shrink-0 mt-0.5 text-[var(--c-brand)]" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
|
</svg>
|
|
<p className="text-xs text-[var(--c-muted)] leading-relaxed">
|
|
Git LFS must be <a href="https://git-lfs.com" target="_blank" rel="noreferrer" className="text-[var(--c-brand)] hover:underline">installed on the client</a> to push and pull tracked files.
|
|
Add a <code className="font-mono bg-[var(--c-surface)] px-1 rounded">.gitattributes</code> file to your repository to specify which file patterns LFS should track.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Coming soon ──────────────────────────────────────────────────────────────
|
|
|
|
function ComingSoon({ sectionId }: { sectionId: SectionId }) {
|
|
const meta = SECTION_META[sectionId]
|
|
return (
|
|
<div className="max-w-2xl px-6 py-6">
|
|
<h1 className="text-xl font-semibold text-[var(--c-text)] mb-1">{meta.title}</h1>
|
|
<div className="mt-8 flex flex-col items-center justify-center py-16 border border-dashed border-[var(--c-border)] rounded-lg text-center bg-[var(--c-surface-raised)]">
|
|
<svg width="40" height="40" fill="none" stroke="var(--c-subtle)" strokeWidth="1.2" viewBox="0 0 24 24" className="mb-4">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75" />
|
|
</svg>
|
|
<h2 className="text-base font-semibold text-[var(--c-text)] mb-2">{meta.title}</h2>
|
|
<p className="text-sm text-[var(--c-muted)] max-w-sm leading-relaxed">{meta.description}</p>
|
|
<span className="mt-5 text-[10px] font-semibold uppercase tracking-wider text-white bg-[var(--c-subtle)] px-2.5 py-1 rounded-full">Coming soon</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|