making progress

This commit is contained in:
2026-05-07 02:06:54 +02:00
parent 7b7e2d399c
commit dea186c995
39 changed files with 2021 additions and 67 deletions
+19 -19
View File
@@ -17,18 +17,20 @@ const queryClient = new QueryClient({
},
})
const LoginPage = lazy(() => import('./pages/LoginPage'))
const RegisterPage = lazy(() => import('./pages/RegisterPage'))
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const ReposPage = lazy(() => import('./pages/ReposPage'))
const RepoPage = lazy(() => import('./pages/RepoPage'))
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
const PRDetailPage = lazy(() => import('./pages/PRDetailPage'))
const PRsPage = lazy(() => import('./pages/PRsPage'))
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
const LoginPage = lazy(() => import('./pages/LoginPage'))
const RegisterPage = lazy(() => import('./pages/RegisterPage'))
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const ReposPage = lazy(() => import('./pages/ReposPage'))
const RepoPage = lazy(() => import('./pages/RepoPage'))
const RepoSettingsPage = lazy(() => import('./pages/RepoSettingsPage'))
const RepoIssuesPage = lazy(() => import('./pages/RepoIssuesPage'))
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
const PRDetailPage = lazy(() => import('./pages/PRDetailPage'))
const PRsPage = lazy(() => import('./pages/PRsPage'))
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
function S({ children }: { children: React.ReactNode }) {
return <Suspense fallback={<div className="p-6"><RepoListSkeleton /></div>}>{children}</Suspense>
@@ -55,19 +57,18 @@ export default function App() {
<CSRFBootstrap />
<AuthProvider>
<Routes>
{/* Auth pages — full screen, no shell */}
<Route path="/login" element={<S><LoginPage /></S>} />
<Route path="/register" element={<S><RegisterPage /></S>} />
{/* Shell layout — explicit root path prevents ambiguous wildcard matching */}
<Route path="/" element={<AppShell />}>
<Route index element={<S><DashboardPage /></S>} />
{/* Repos — nested so /repos/:owner/:repo is unambiguous */}
<Route path="repos" element={<S><ReposPage /></S>} />
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
<Route path="repos/:owner/:repo/pulls" element={<S><RepoPRsPage /></S>} />
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
<Route path="repos/:owner/:repo/settings" element={<S><RepoSettingsPage /></S>} />
<Route path="repos/:owner/:repo/issues" element={<S><RepoIssuesPage /></S>} />
<Route path="repos/:owner/:repo/pulls" element={<S><RepoPRsPage /></S>} />
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
<Route path="pulls" element={<S><PRsPage /></S>} />
<Route path="pipelines" element={<S><PipelinesPage /></S>} />
@@ -75,7 +76,6 @@ export default function App() {
<Route path="profile" element={<S><ProfilePage /></S>} />
<Route path="settings" element={<S><SettingsPage /></S>} />
{/* 404 within shell — shows a message, does NOT redirect */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
+2
View File
@@ -75,6 +75,8 @@ export const api = {
request(path, schema, { method: 'POST', json: body }),
put: <T>(path: string, schema: z.ZodType<T>, body: unknown) =>
request(path, schema, { method: 'PUT', json: body }),
patch: <T>(path: string, schema: z.ZodType<T>, body: unknown) =>
request(path, schema, { method: 'PATCH', json: body }),
delete: <T>(path: string, schema: z.ZodType<T>) =>
request(path, schema, { method: 'DELETE' }),
}
+69
View File
@@ -0,0 +1,69 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import type { Issue, IssueState } from '../../types/api'
const issueSchema = z.object({
id: z.number(),
repoId: z.number(),
authorId: z.number(),
authorName: z.string(),
number: z.number(),
title: z.string(),
body: z.string(),
state: z.enum(['open', 'closed']),
createdAt: z.string(),
updatedAt: z.string(),
})
const issuesSchema = z.array(issueSchema)
export function useIssues(owner: string, repo: string, state: IssueState = 'open') {
return useQuery({
queryKey: ['repos', owner, repo, 'issues', state],
queryFn: () =>
api.get<Issue[]>(`/api/v1/repos/${owner}/${repo}/issues?state=${state}`, issuesSchema),
enabled: Boolean(owner && repo),
})
}
export function useCreateIssue(owner: string, repo: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: { title: string; body?: string }) =>
api.post<Issue>(`/api/v1/repos/${owner}/${repo}/issues`, issueSchema, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'issues'] })
},
})
}
export function useCloseIssue(owner: string, repo: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (issueNum: number) =>
api.post<Issue>(
`/api/v1/repos/${owner}/${repo}/issues/${issueNum}/close`,
issueSchema,
{},
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'issues'] })
},
})
}
export function useReopenIssue(owner: string, repo: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (issueNum: number) =>
api.post<Issue>(
`/api/v1/repos/${owner}/${repo}/issues/${issueNum}/reopen`,
issueSchema,
{},
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'issues'] })
},
})
}
+24
View File
@@ -74,6 +74,30 @@ export function useRepoDiff(owner: string, name: string, base: string, head: str
})
}
export function useUpdateRepo(owner: string, name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: { description?: string; isPrivate?: boolean; defaultBranch?: string }) =>
api.patch<Repository>(
`/api/v1/repos/${owner}/${name}`,
repositorySchema,
data,
),
onSuccess: (updated) => {
queryClient.invalidateQueries({ queryKey: ['repos'] })
queryClient.setQueryData(['repos', owner, name], updated)
},
})
}
export function useDeleteRepo(owner: string, name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => api.delete(`/api/v1/repos/${owner}/${name}`, z.any()),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['repos'] }),
})
}
export function useCreateRepo() {
const queryClient = useQueryClient()
return useMutation({
+40
View File
@@ -0,0 +1,40 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import type { SSHKey } from '../../types/api'
const sshKeySchema = z.object({
id: z.number(),
userId: z.number(),
title: z.string(),
fingerprint: z.string(),
publicKey: z.string(),
createdAt: z.string(),
})
const sshKeysSchema = z.array(sshKeySchema)
export function useSSHKeys() {
return useQuery({
queryKey: ['user', 'keys'],
queryFn: () => api.get<SSHKey[]>('/api/v1/user/keys', sshKeysSchema),
})
}
export function useAddSSHKey() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: { title: string; publicKey: string }) =>
api.post<SSHKey>('/api/v1/user/keys', sshKeySchema, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user', 'keys'] }),
})
}
export function useDeleteSSHKey() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (keyId: number) =>
api.delete(`/api/v1/user/keys/${keyId}`, z.any()),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user', 'keys'] }),
})
}
+7 -21
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { usePR } from '../api/queries/prs'
import { useRepoDiff } from '../api/queries/repos'
import { DiffViewer } from '../components/diff/DiffViewer'
import { MobileComment } from '../components/diff/MobileComment'
import { Skeleton } from '../ui/Skeleton'
@@ -9,6 +10,11 @@ import { cn } from '../lib/utils'
export default function PRDetailPage() {
const { owner = '', repo = '', prId = '' } = useParams<{ owner: string; repo: string; prId: string }>()
const { data: pr, isLoading, isError } = usePR(owner, repo, parseInt(prId, 10))
const { data: diffs } = useRepoDiff(
owner, repo,
pr?.targetBranch ?? '',
pr?.sourceBranch ?? '',
)
const [comment, setComment] = useState<{ file: string; line: number } | null>(null)
if (isLoading) {
@@ -73,8 +79,7 @@ export default function PRDetailPage() {
Files changed
</h2>
{/* Demo diff — real diff loads when repo has commits on both branches */}
<DiffViewer files={DEMO_DIFF} />
<DiffViewer files={diffs ?? []} />
</div>
{/* Mobile comment sheet */}
@@ -88,22 +93,3 @@ export default function PRDetailPage() {
)
}
// Demo diff to show the component before real git data is available
const DEMO_DIFF = [
{
path: 'README.md',
additions: 6,
deletions: 1,
patch: `@@ -1,3 +1,8 @@
-# Project
+# My Project
+
+A sovereign, federated git collaboration platform.
+
+## Features
+- Fast Go backend
+- React 18 frontend
+- ActivityPub federation
`,
},
]
+109
View File
@@ -0,0 +1,109 @@
import { useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useIssues, useCreateIssue, useCloseIssue, useReopenIssue } from '../api/queries/issues'
import { cn } from '../lib/utils'
import type { IssueState } from '../types/api'
export default function RepoIssuesPage() {
const { owner = '', repo = '' } = useParams<{ owner: string; repo: string }>()
const [state, setState] = useState<IssueState>('open')
const [showNew, setShowNew] = useState(false)
const { data: issues, isLoading } = useIssues(owner, repo, state)
const createIssue = useCreateIssue(owner, repo)
const closeIssue = useCloseIssue(owner, repo)
const reopenIssue = useReopenIssue(owner, repo)
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) return
await createIssue.mutateAsync({ title: title.trim(), body })
setTitle(''); setBody(''); setShowNew(false)
}
return (
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6">
<div className="flex items-center gap-1 text-sm mb-4">
<Link to={`/repos/${owner}/${repo}`} className="text-[#0052CC] hover:underline">{repo}</Link>
<span className="text-[#5E6C84]">/</span>
<span className="font-semibold text-[#172B4D]">Issues</span>
</div>
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-semibold text-[#172B4D]">Issues</h1>
<button onClick={() => setShowNew(true)}
className="px-3 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] min-h-[44px]">
New issue
</button>
</div>
{showNew && (
<form onSubmit={handleCreate} className="mb-6 p-5 border border-[#4C9AFF] rounded bg-white space-y-3">
<h2 className="text-sm font-semibold text-[#172B4D]">New Issue</h2>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="Title" required
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]" />
<textarea value={body} onChange={e => setBody(e.target.value)} placeholder="Description (optional)" rows={4}
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm resize-none focus:outline-none focus:border-[#4C9AFF]" />
<div className="flex gap-2">
<button type="submit" disabled={createIssue.isPending || !title.trim()}
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm hover:bg-[#0065FF] disabled:opacity-50 min-h-[44px]">
{createIssue.isPending ? 'Submitting…' : 'Submit'}
</button>
<button type="button" onClick={() => setShowNew(false)}
className="px-4 py-2 rounded border border-[#DFE1E6] text-sm text-[#172B4D] hover:bg-[#F4F5F7] min-h-[44px]">
Cancel
</button>
</div>
</form>
)}
<div className="flex gap-1 mb-4 border-b border-[#DFE1E6]">
{(['open', 'closed'] as IssueState[]).map(s => {
const count = issues?.filter(i => i.state === s).length ?? 0
return (
<button key={s} onClick={() => setState(s)}
className={cn('px-4 py-2 text-sm font-medium capitalize border-b-2 -mb-px min-h-[44px]',
state === s ? 'border-[#0052CC] text-[#0052CC]' : 'border-transparent text-[#5E6C84] hover:text-[#172B4D]')}>
{s} {count > 0 && `(${count})`}
</button>
)
})}
</div>
{isLoading ? (
<p className="text-sm text-[#5E6C84] py-4">Loading</p>
) : !issues?.length ? (
<p className="text-sm text-[#5E6C84] py-8 text-center">No {state} issues.</p>
) : (
<div className="flex flex-col gap-2">
{issues.map(issue => (
<div key={issue.id}
className="flex items-start gap-3 p-4 border border-[#DFE1E6] rounded hover:bg-[#FAFBFC]">
<svg width="16" height="16" viewBox="0 0 16 16" fill={issue.state === 'open' ? '#00875A' : '#5E6C84'}
className="mt-0.5 shrink-0">
<path d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm9 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0Zm-.25-6.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 1.5 0v-3.5Z"/>
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#172B4D]">#{issue.number} {issue.title}</p>
<p className="text-xs text-[#5E6C84] mt-0.5">
opened by {issue.authorName} · {new Date(issue.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => issue.state === 'open'
? closeIssue.mutate(issue.number)
: reopenIssue.mutate(issue.number)
}
className="text-xs px-3 py-1.5 rounded border border-[#DFE1E6] text-[#5E6C84] hover:bg-[#F4F5F7] shrink-0 min-h-[32px]">
{issue.state === 'open' ? 'Close' : 'Reopen'}
</button>
</div>
))}
</div>
)}
</div>
)
}
+4 -4
View File
@@ -39,7 +39,7 @@ export default function RepoPage() {
<GettingStarted owner={owner} repoName={repoName} branch={branch} cloneUrl={cloneUrl} />
) : (
<>
{/* Branch pill + PR link */}
{/* Branch pill + repo nav tabs */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 border border-[#DFE1E6] rounded text-sm text-[#172B4D]">
<svg width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">
@@ -47,9 +47,9 @@ export default function RepoPage() {
</svg>
{branch}
</div>
<Link to={`/repos/${owner}/${repoName}/pulls`} className="text-sm text-[#0052CC] hover:underline">
Pull requests
</Link>
<Link to={`/repos/${owner}/${repoName}/issues`} className="text-sm text-[#0052CC] hover:underline">Issues</Link>
<Link to={`/repos/${owner}/${repoName}/pulls`} className="text-sm text-[#0052CC] hover:underline">Pull requests</Link>
<Link to={`/repos/${owner}/${repoName}/settings`} className="text-sm text-[#5E6C84] hover:text-[#172B4D] hover:underline ml-auto">Settings</Link>
</div>
<TreeBrowser owner={owner} repo={repoName} ref={branch} path={path} />
+92
View File
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { useRepo, useUpdateRepo, useDeleteRepo } from '../api/queries/repos'
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 [description, setDescription] = useState(repo?.description ?? '')
const [isPrivate, setIsPrivate] = useState(repo?.isPrivate ?? 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)
}
const handleDelete = async () => {
if (confirmDelete !== repoName) return
await deleteRepo.mutateAsync()
navigate('/repos')
}
return (
<div className="max-w-2xl mx-auto px-4 md:px-6 py-6 space-y-8">
<div className="flex items-center gap-1 text-sm">
<Link to={`/repos/${owner}/${repoName}`} className="text-[#0052CC] hover:underline">{repoName}</Link>
<span className="text-[#5E6C84]">/</span>
<span className="font-semibold text-[#172B4D]">Settings</span>
</div>
<h1 className="text-xl font-semibold text-[#172B4D]">Repository Settings</h1>
<section className="border border-[#DFE1E6] rounded-lg overflow-hidden">
<div className="px-5 py-4 border-b border-[#DFE1E6] bg-[#FAFBFC]">
<h2 className="text-sm font-semibold text-[#172B4D]">General</h2>
</div>
<form onSubmit={handleSave} className="px-5 py-5 space-y-4">
<div>
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Repository name</label>
<input value={repoName} disabled
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm bg-[#F4F5F7] text-[#5E6C84] cursor-not-allowed" />
<p className="text-xs text-[#5E6C84] mt-1">Renaming requires migrating git remotes coming soon.</p>
</div>
<div>
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Description</label>
<input value={description} onChange={e => 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]" />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isPrivate} onChange={e => setIsPrivate(e.target.checked)} />
<span className="text-sm text-[#172B4D]">Private repository</span>
</label>
<div className="flex items-center gap-3">
<button type="submit" disabled={updateRepo.isPending}
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] disabled:opacity-50 min-h-[44px]">
{updateRepo.isPending ? 'Saving…' : 'Save changes'}
</button>
{saved && <span className="text-xs text-[#00875A] font-medium">Saved!</span>}
</div>
</form>
</section>
<section className="border border-[#FFEBE6] rounded-lg overflow-hidden">
<div className="px-5 py-4 border-b border-[#FFEBE6] bg-[#FFEBE6]/50">
<h2 className="text-sm font-semibold text-[#BF2600]">Danger zone</h2>
</div>
<div className="px-5 py-5 space-y-3">
<p className="text-sm text-[#172B4D] font-medium">Delete this repository</p>
<p className="text-xs text-[#5E6C84]">
This action is permanent. Type <code className="font-mono bg-[#F4F5F7] px-1 rounded">{repoName}</code> to confirm.
</p>
<input value={confirmDelete} onChange={e => 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]" />
<button onClick={handleDelete}
disabled={confirmDelete !== repoName || deleteRepo.isPending}
className="px-4 py-2 rounded bg-[#DE350B] text-white text-sm font-medium hover:bg-[#BF2600] disabled:opacity-40 min-h-[44px]">
{deleteRepo.isPending ? 'Deleting…' : 'Delete repository'}
</button>
</div>
</section>
</div>
)
}
+74 -9
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { useSSHKeys, useAddSSHKey, useDeleteSSHKey } from '../api/queries/sshkeys'
export default function SettingsPage() {
const { user, logout } = useAuth()
@@ -58,15 +59,7 @@ export default function SettingsPage() {
</form>
</section>
{/* SSH keys placeholder */}
<section className="border border-[#DFE1E6] rounded-lg overflow-hidden">
<div className="px-5 py-4 border-b border-[#DFE1E6] bg-[#FAFBFC]">
<h2 className="text-sm font-semibold text-[#172B4D]">SSH keys</h2>
</div>
<div className="px-5 py-5 text-sm text-[#5E6C84]">
SSH key management coming soon.
</div>
</section>
<SSHKeySection />
{/* Danger zone */}
<section className="border border-[#FFEBE6] rounded-lg overflow-hidden">
@@ -100,3 +93,75 @@ function Row({ label, value }: { label: string; value?: string }) {
</div>
)
}
function SSHKeySection() {
const { data: keys } = useSSHKeys()
const addKey = useAddSSHKey()
const deleteKey = useDeleteSSHKey()
const [showAdd, setShowAdd] = useState(false)
const [title, setTitle] = useState('')
const [publicKey, setPublicKey] = useState('')
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
await addKey.mutateAsync({ title, publicKey })
setTitle(''); setPublicKey(''); setShowAdd(false)
}
return (
<section className="border border-[#DFE1E6] rounded-lg overflow-hidden">
<div className="px-5 py-4 border-b border-[#DFE1E6] bg-[#FAFBFC] flex items-center justify-between">
<h2 className="text-sm font-semibold text-[#172B4D]">SSH keys</h2>
<button onClick={() => setShowAdd(s => !s)}
className="text-xs text-[#0052CC] hover:underline font-medium">
{showAdd ? 'Cancel' : '+ Add key'}
</button>
</div>
{showAdd && (
<form onSubmit={handleAdd} className="px-5 py-4 border-b border-[#DFE1E6] space-y-3 bg-[#FAFBFC]">
<div>
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Title</label>
<input value={title} onChange={e => setTitle(e.target.value)} required placeholder="e.g. MacBook Pro"
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF]" />
</div>
<div>
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Public key</label>
<textarea value={publicKey} onChange={e => setPublicKey(e.target.value)} required rows={3}
placeholder="ssh-ed25519 AAAA… or ssh-rsa AAAA…"
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-xs font-mono resize-none focus:outline-none focus:border-[#4C9AFF]" />
</div>
{addKey.isError && (
<p className="text-xs text-[#DE350B]">{addKey.error instanceof Error ? addKey.error.message : 'Error'}</p>
)}
<button type="submit" disabled={addKey.isPending}
className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] disabled:opacity-50 min-h-[44px]">
{addKey.isPending ? 'Adding…' : 'Add SSH key'}
</button>
</form>
)}
{!keys?.length ? (
<div className="px-5 py-5 text-sm text-[#5E6C84]">No SSH keys added yet.</div>
) : (
<ul>
{keys.map(key => (
<li key={key.id} className="flex items-center gap-3 px-5 py-3 border-b border-[#DFE1E6] last:border-0">
<svg width="16" height="16" fill="none" stroke="#5E6C84" 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-[#172B4D] truncate">{key.title}</p>
<p className="text-xs font-mono text-[#5E6C84] truncate">{key.fingerprint}</p>
</div>
<button onClick={() => deleteKey.mutate(key.id)}
className="text-xs px-3 py-1.5 rounded border border-[#DE350B] text-[#DE350B] hover:bg-[#FFEBE6] shrink-0 min-h-[32px]">
Delete
</button>
</li>
))}
</ul>
)}
</section>
)
}
+24
View File
@@ -62,6 +62,30 @@ export interface Pipeline {
updatedAt: string
}
export type IssueState = 'open' | 'closed'
export interface Issue {
id: number
repoId: number
authorId: number
authorName: string
number: number
title: string
body: string
state: IssueState
createdAt: string
updatedAt: string
}
export interface SSHKey {
id: number
userId: number
title: string
fingerprint: string
publicKey: string
createdAt: string
}
export interface ApiError {
error: string
status: number