feat: add project settings page with tabbed layout

- Create SettingsTabLayout component with sidebar navigation
- Create ProjectSettingsPage with General settings tab
- Create RepositoriesSettingsTab for repo management
- Add Members placeholder tab
- Update router with settings routes
- Add CSS styles for settings layout
- Navigate to /projects/:id/settings from workspace header
This commit is contained in:
Fusion
2026-05-19 15:26:57 +02:00
parent 965f6f6585
commit 0bc0d99c21
17 changed files with 1136 additions and 16 deletions
@@ -0,0 +1,71 @@
import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { apiClient } from "../api/client";
import { GitRepository } from "../api/git_repositories";
export const RepositoriesSettingsTab: React.FC = () => {
const { projectId } = useParams<{ projectId: string }>();
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(">");
useEffect(() => {
const fetchRepositories = async () => {
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories`
);
setRepositories(response.data);
} catch (err) {
setError("Failed to load repositories");
} finally {
setLoading(false);
}
};
fetchRepositories();
}, [projectId]);
const handleDelete = async (repoId: string) => {
if (!window.confirm("Are you sure you want to delete this repository?")) return;
try {
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
setRepositories(repositories.filter((r) => r.id !== repoId));
} catch (err) {
setError("Failed to delete repository");
}
};
if (loading) return <div>Loading...</div>;
return (
<div className="repositories-settings-tab">
<h2>Repositories</h2>
{error && <div className="error-message">{error}</div>}
<div className="repositories-list">
{repositories.length === 0 ? (
<p>No repositories yet.</p>
) : (
repositories.map((repo) => (
<div key={repo.id} className="repository-card">
<div className="repository-info">
<h3>{repo.name}</h3>
<p>{repo.remote_url}</p>
<span className="repo-type">
{repo.is_mirror ? "Mirror" : "Clone"}
</span>
</div>
<button
onClick={() => handleDelete(repo.id)}
className="btn-danger"
>
Delete
</button>
</div>
))
)}
</div>
</div>
);
};
@@ -0,0 +1,43 @@
import React from "react";
import { Link, useLocation } from "react-router-dom";
interface Tab {
id: string;
label: string;
path: string;
}
interface SettingsTabLayoutProps {
tabs: Tab[];
children: React.ReactNode;
basePath: string;
}
export const SettingsTabLayout: React.FC<SettingsTabLayoutProps> = ({
tabs,
children,
basePath,
}) => {
const location = useLocation();
return (
<div className="settings-layout">
<aside className="settings-sidebar">
<nav className="settings-nav">
{tabs.map((tab) => (
<Link
key={tab.id}
to={`${basePath}/${tab.path}`}
className={`settings-nav-link ${
location.pathname.includes(tab.path) ? "active" : ""
}`}
>
{tab.label}
</Link>
))}
</nav>
</aside>
<main className="settings-content">{children}</main>
</div>
);
};
@@ -0,0 +1,45 @@
import { Link } from "react-router-dom";
interface WorkspaceHeaderProps {
project: {
id: string;
name: string;
description?: string | null;
};
currentRepo?: {
id: string;
name: string;
} | null;
}
export const WorkspaceHeader = ({ project, currentRepo }: WorkspaceHeaderProps) => {
return (
<div className="workspace-header">
<div className="workspace-header-left">
<div className="workspace-header-icon">📁</div>
<div className="workspace-header-info">
<h1 className="workspace-header-title">{project.name}</h1>
{currentRepo && (
<span className="workspace-header-subtitle">{currentRepo.name}</span>
)}
</div>
</div>
<div className="workspace-header-actions">
<Link
className="workspace-header-action-btn"
to={`/projects/${project.id}/repositories/${currentRepo?.id || ""}/history`}
>
<span className="icon">🕐</span>
History
</Link>
<Link
className="workspace-header-action-btn"
to={`/projects/${project.id}/settings`}
>
<span className="icon"></span>
Settings
</Link>
</div>
</div>
);
};
+148
View File
@@ -0,0 +1,148 @@
import React, { useState, useEffect } from "react";
import { useParams, Link, useNavigate, Routes, Route } from "react-router-dom";
import { SettingsTabLayout } from "../components/settings-tab-layout";
import { RepositoriesSettingsTab } from "../components/repositories-settings-tab";
import { apiClient } from "../api/client";
import { Project } from "../types";
const tabs = [
{ id: "general", label: "General", path: "general" },
{ id: "repositories", label: "Repositories", path: "repositories" },
{ id: "members", label: "Members", path: "members" },
];
const GeneralSettings: React.FC<{
project: Project;
projectId: string;
}> = ({ project, projectId }) => {
const navigate = useNavigate();
const [name, setName] = useState(project.name);
const [description, setDescription] = useState(project.description || "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const handleSave = async () => {
setSaving(true);
try {
await apiClient.patch(`/projects/${projectId}`, {
name,
description,
});
} catch (err) {
setError("Failed to save changes");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!window.confirm("Are you sure you want to delete this project?")) return;
try {
await apiClient.delete(`/projects/${projectId}`);
navigate("/projects");
} catch (err) {
setError("Failed to delete project");
}
};
return (
<div className="settings-panel">
<h2>General Settings</h2>
{error && <div className="error-message">{error}</div>}
<div className="form-group">
<label>Project Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="form-input"
/>
</div>
<div className="form-group">
<label>Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="form-textarea"
rows={4}
/>
</div>
<div className="form-actions">
<button
onClick={handleSave}
disabled={saving}
className="btn-primary"
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
<div className="danger-zone">
<h3>Danger Zone</h3>
<button onClick={handleDelete} className="btn-danger">
Delete Project
</button>
</div>
</div>
);
};
const MembersSettingsTab: React.FC = () => (
<div className="settings-panel">
<h2>Members</h2>
<p>Team members management coming soon.</p>
</div>
);
export const ProjectSettingsPage: React.FC = () => {
const { projectId } = useParams<{ projectId: string }>();
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const fetchProject = async () => {
try {
const response = await apiClient.get(`/projects/${projectId}`);
setProject(response.data);
} catch (err) {
setError("Failed to load project");
} finally {
setLoading(false);
}
};
fetchProject();
}, [projectId]);
if (loading) return <div>Loading...</div>;
if (error) return <div className="error-message">{error}</div>;
if (!project) return <div>Project not found</div>;
return (
<div className="project-settings-page">
<div className="settings-breadcrumb">
<Link to="/projects">Projects</Link>
<span> &gt; </span>
<Link to={`/projects/${projectId}`}>{project.name}</Link>
<span> &gt; </span>
<span>Settings</span>
</div>
<SettingsTabLayout tabs={tabs} basePath={`/projects/${projectId}/settings`}>
<Routes>
<Route
path="general"
element={<GeneralSettings project={project} projectId={projectId!} />}
/>
<Route path="repositories" element={<RepositoriesSettingsTab />} />
<Route path="members" element={<MembersSettingsTab />} />
<Route path="*" element={<GeneralSettings project={project} projectId={projectId!} />} />
</Routes>
</SettingsTabLayout>
</div>
);
};
+33 -15
View File
@@ -11,6 +11,7 @@ import {
} from "../api/git_repositories";
import { CommitPanel } from "../components/commit-panel";
import { GitToolbar } from "../components/git-toolbar";
import { WorkspaceHeader } from "../components/workspace-header";
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
@@ -28,11 +29,18 @@ interface FileTreeEntry {
} | null;
}
interface Project {
id: string;
name: string;
description?: string | null;
}
export const RepoWorkspace = () => {
const { projectId } = useParams<{ projectId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const [status, setStatus] = useState<WorkspaceStatus>("loading");
const [project, setProject] = useState<Project | null>(null);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
searchParams.get("repo")
@@ -41,6 +49,16 @@ export const RepoWorkspace = () => {
const [currentBranch, setCurrentBranch] = useState<string>("main");
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
const loadProject = useCallback(async () => {
if (!projectId) return;
try {
const response = await apiClient.get(`/projects/${projectId}`);
setProject(response.data);
} catch {
setProject(null);
}
}, [projectId]);
const loadRepositories = useCallback(async () => {
if (!projectId) return;
@@ -95,8 +113,9 @@ export const RepoWorkspace = () => {
}, [projectId, selectedRepoId]);
useEffect(() => {
void loadProject();
void loadRepositories();
}, [loadRepositories]);
}, [loadProject, loadRepositories]);
useEffect(() => {
void loadBranches();
@@ -116,20 +135,12 @@ export const RepoWorkspace = () => {
return (
<section className="repo-workspace">
<div className="workspace-header">
<div className="workspace-title">
<h1>Repository Workspace</h1>
{selectedRepo && <span className="repo-name">{selectedRepo.name}</span>}
</div>
<div className="workspace-actions">
<Link
className="secondary-button"
to={`/projects/${projectId}/repositories`}
>
Manage Repositories
</Link>
</div>
</div>
{project && (
<WorkspaceHeader
project={project}
currentRepo={selectedRepo || null}
/>
)}
{status === "loading" && (
<p className="muted">Loading repositories...</p>
@@ -160,6 +171,13 @@ export const RepoWorkspace = () => {
</div>
)}
{project && (
<WorkspaceHeader
project={project}
currentRepo={selectedRepo || null}
/>
)}
{status === "ready" && repositories.length > 0 && (
<div className="workspace-layout">
<aside className="workspace-sidebar">
+2
View File
@@ -8,6 +8,7 @@ import { ProfilePage } from "./pages/profile";
import { ProjectsPage } from "./pages/projects";
import { GitRepositoriesPage } from "./pages/git-repositories";
import { GitHistoryPage } from "./pages/git-history";
import { ProjectSettingsPage } from "./pages/project-settings";
import { RepoWorkspace } from "./pages/repo-workspace";
import { SSHKeysPage } from "./pages/ssh-keys";
import { SettingsPage } from "./pages/settings";
@@ -30,6 +31,7 @@ export const AppRouter = () => {
<Route path="projects/:projectId" element={<RepoWorkspace />} />
<Route path="projects/:projectId/repositories" element={<GitRepositoriesPage />} />
<Route path="projects/:projectId/repositories/:repoId/history" element={<GitHistoryPage />} />
<Route path="projects/:projectId/settings/*" element={<ProjectSettingsPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="settings" element={<SettingsPage />} />
+170 -1
View File
@@ -707,11 +707,62 @@ a {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.workspace-header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.workspace-header-icon {
font-size: 1.5rem;
}
.workspace-header-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.workspace-header-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.workspace-header-subtitle {
color: var(--muted);
font-size: 0.875rem;
}
.workspace-header-actions {
display: flex;
gap: 0.5rem;
}
.workspace-header-action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--ink);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.workspace-header-action-btn:hover {
background: var(--bg);
border-color: var(--brand);
}
.workspace-title {
display: flex;
align-items: center;
@@ -1034,3 +1085,121 @@ a {
background: rgba(16, 185, 129, 0.1);
border-radius: 4px;
}
/* Settings Layout */
.settings-layout {
display: flex;
gap: 2rem;
padding: 1.5rem 0;
}
.settings-sidebar {
width: 200px;
flex-shrink: 0;
}
.settings-nav {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.settings-nav-link {
padding: 0.625rem 1rem;
border-radius: 8px;
color: var(--muted);
text-decoration: none;
font-size: 0.95rem;
transition: all 0.2s;
}
.settings-nav-link:hover {
background: var(--bg);
color: var(--ink);
}
.settings-nav-link.active {
background: var(--brand);
color: white;
font-weight: 500;
}
.settings-content {
flex: 1;
min-width: 0;
}
.settings-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.5rem;
}
.settings-panel h2 {
margin: 0 0 1.25rem;
font-size: 1.25rem;
}
.settings-breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--muted);
margin-bottom: 1rem;
}
.settings-breadcrumb a {
color: var(--brand);
text-decoration: none;
}
.settings-breadcrumb a:hover {
text-decoration: underline;
}
.project-settings-page {
padding: 1.5rem;
}
/* Repositories Settings Tab */
.repositories-settings-tab h2 {
margin: 0 0 1.25rem;
font-size: 1.25rem;
}
.repositories-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.repository-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
}
.repository-info h3 {
margin: 0 0 0.25rem;
font-size: 1rem;
}
.repository-info p {
margin: 0 0 0.5rem;
font-size: 0.85rem;
color: var(--muted);
}
.repo-type {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
background: var(--brand);
color: white;
border-radius: 999px;
}