From 00aede9c91df5656a67b015f9b1e9ce6608f6b8b Mon Sep 17 00:00:00 2001 From: erangel1 Date: Thu, 7 May 2026 12:32:07 +0200 Subject: [PATCH] changed layout of repo settings page --- frontend/src/api/queries/repos.ts | 1 + frontend/src/pages/RepoSettingsPage.tsx | 574 +++++++++++++++++++++--- frontend/src/types/api.ts | 1 + internal/api/handlers/repos.go | 6 +- internal/domain/git/binary.go | 12 + 5 files changed, 525 insertions(+), 69 deletions(-) diff --git a/frontend/src/api/queries/repos.ts b/frontend/src/api/queries/repos.ts index c3cd2fe..71c9513 100644 --- a/frontend/src/api/queries/repos.ts +++ b/frontend/src/api/queries/repos.ts @@ -18,6 +18,7 @@ const repositorySchema = z.object({ ownerId: z.number(), ownerName: z.string(), isEmpty: z.boolean(), + size: z.number().default(0), name: z.string(), description: z.string(), isPrivate: z.boolean(), diff --git a/frontend/src/pages/RepoSettingsPage.tsx b/frontend/src/pages/RepoSettingsPage.tsx index 4a17d00..6cdcad6 100644 --- a/frontend/src/pages/RepoSettingsPage.tsx +++ b/frontend/src/pages/RepoSettingsPage.tsx @@ -1,92 +1,530 @@ -import { useState } from 'react' -import { useParams, Link, useNavigate } from 'react-router-dom' +import { useState, useEffect } from 'react' +import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom' import { useRepo, useUpdateRepo, useDeleteRepo } from '../api/queries/repos' +import { Skeleton } from '../ui/Skeleton' + +// ─── Sidebar definition ─────────────────────────────────────────────────────── + +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; badge?: 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 = { + 'repository-details': { title: 'Repository details', description: '' }, + 'repository-permissions': { + title: 'Repository permissions', + description: 'Control who can read, write, or administer this repository. Invite collaborators and manage team access.', + }, + 'access-keys': { + title: 'Access keys', + description: 'SSH deploy keys give read or write access to this repository without requiring an interactive user account. Useful for CI/CD systems.', + }, + 'access-tokens': { + title: 'Access tokens', + description: 'Repository access tokens give third-party tools secure, scoped access to this repository via the API.', + }, + 'branch-restrictions': { + title: 'Branch restrictions', + description: 'Protect important branches by restricting who can push, delete, or force-push to them. Define which users or groups can merge pull requests.', + }, + 'branching-model': { + title: 'Branching model', + description: 'Define a branching strategy (e.g. Gitflow) to enforce consistent branch naming across your team. Automatically categorise branches.', + }, + 'merge-strategies': { + title: 'Merge strategies', + description: 'Configure which merge strategies (merge commit, squash, rebase) are allowed when closing pull requests.', + }, + 'webhooks': { + title: 'Webhooks', + description: 'Send real-time HTTP POST notifications to external services when events occur in this repository — push, PR creation, comments, and more.', + }, + 'default-reviewers': { + title: 'Default reviewers', + description: 'Automatically add reviewers to pull requests based on the files changed or the target branch.', + }, + 'default-description': { + title: 'Default description', + description: 'Set a default description template that pre-fills the body field when a pull request is created.', + }, + 'excluded-files': { + title: 'Excluded files', + description: 'Specify files that should be excluded from pull request diff views and review requirements.', + }, + 'git-lfs': { + title: 'Git LFS', + description: 'Git Large File Storage replaces large files with text pointers inside git, while storing the actual files on a remote server.', + }, +} + +// ─── Main page ──────────────────────────────────────────────────────────────── export default function RepoSettingsPage() { const { owner = '', repo: repoName = '' } = useParams<{ owner: string; repo: string }>() - const navigate = useNavigate() - const { data: repo } = useRepo(owner, repoName) - const updateRepo = useUpdateRepo(owner, repoName) - const deleteRepo = useDeleteRepo(owner, repoName) + const [searchParams, setSearchParams] = useSearchParams() + const [sidebarSearch, setSidebarSearch] = useState('') + const section = (searchParams.get('section') ?? 'repository-details') as SectionId - const [description, setDescription] = useState(repo?.description ?? '') - const [isPrivate, setIsPrivate] = useState(repo?.isPrivate ?? false) + function goTo(id: SectionId) { + setSearchParams({ section: id }) + } + + const filtered = sidebarSearch.trim() + ? SIDEBAR.map(g => ({ + ...g, + items: g.items.filter(i => i.label.toLowerCase().includes(sidebarSearch.toLowerCase())), + })).filter(g => g.items.length > 0) + : SIDEBAR + + return ( +
+ {/* ── Left sidebar ── */} + + + {/* ── Main content ── */} +
+ {/* Mobile section selector */} +
+ +
+ + {section === 'repository-details' + ? + : + } +
+
+ ) +} + +// ─── Repository details section ─────────────────────────────────────────────── + +function formatSize(bytes: number): string { + if (bytes === 0) return '0 B' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) 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 { data: repoData, isLoading } = useRepo(owner, repo) + const updateRepo = useUpdateRepo(owner, repo) + const deleteRepo = useDeleteRepo(owner, repo) + const navigate = useNavigate() + + const [description, setDescription] = useState('') + const [isPrivate, setIsPrivate] = useState(false) + const [defaultBranch, setDefaultBranch] = useState('') + const [showAdvanced, setShowAdvanced] = useState(false) const [confirmDelete, setConfirmDelete] = useState('') const [saved, setSaved] = useState(false) - const handleSave = async (e: React.FormEvent) => { - e.preventDefault() - await updateRepo.mutateAsync({ description, isPrivate }) - setSaved(true) - setTimeout(() => setSaved(false), 2000) + useEffect(() => { + if (repoData) { + setDescription(repoData.description ?? '') + setIsPrivate(repoData.isPrivate) + setDefaultBranch(repoData.defaultBranch) + } + }, [repoData]) + + const isDirty = repoData != null && ( + description !== (repoData.description ?? '') || + isPrivate !== repoData.isPrivate || + defaultBranch !== repoData.defaultBranch + ) + + function handleDiscard() { + if (!repoData) return + setDescription(repoData.description ?? '') + setIsPrivate(repoData.isPrivate) + setDefaultBranch(repoData.defaultBranch) } - const handleDelete = async () => { - if (confirmDelete !== repoName) return + async function handleSave(e: React.FormEvent) { + e.preventDefault() + await updateRepo.mutateAsync({ description, isPrivate, defaultBranch }) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + } + + async function handleDelete() { + if (confirmDelete !== repo || !repoData) return await deleteRepo.mutateAsync() navigate('/repos') } - return ( -
-
- {repoName} - / - Settings + if (isLoading || !repoData) { + return ( +
+ + +
+ ) + } -

Repository Settings

+ // Initials avatar colour based on repo name + const avatarColor = '#0052CC' -
-
-

General

+ return ( +
+ + {/* Page header */} +
+
+
+ {owner} + / + {repo} +
+

Repository details

-
-
- - -

Renaming requires migrating git remotes — coming soon.

-
-
- - setDescription(e.target.value)} - placeholder="Short description of this repository" - className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]" /> -
- -
- - {saved && Saved!} -
-
-
-
-
-

Danger zone

-
-
-

Delete this repository

-

- This action is permanent. Type {repoName} to confirm. -

- setConfirmDelete(e.target.value)} - placeholder={repoName} - className="w-full border border-[#DE350B] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#DE350B]" /> -
-
+
+ +
+ +
+ + {/* Avatar */} +
+ +
+
+ {repoData.name[0]?.toUpperCase()} +
+ +
+
+ + {/* Repository name (read-only) */} +
+ + +

+ Renaming would require all collaborators to update their git remotes. Coming soon. +

+
+ + {/* Size (read-only) */} +
+ +

{formatSize(repoData.size)}

+
+ + {/* Description */} +
+ +