more files

This commit is contained in:
2026-05-06 23:19:35 +02:00
parent 563f82d497
commit 1634c4cc0d
22 changed files with 2959 additions and 119 deletions
+9 -1
View File
@@ -10,19 +10,27 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/merge": "^6.12.1",
"@tanstack/react-query": "^5.100.9",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"react-router-dom": "^7.15.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.2.4",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.2.1", "eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0", "globals": "^17.5.0",
"tailwindcss": "^4.2.4",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
"vite": "^8.0.10" "vite": "^8.0.10"
+2152
View File
File diff suppressed because it is too large Load Diff
+47 -116
View File
@@ -1,122 +1,53 @@
import { useState } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import reactLogo from './assets/react.svg' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import viteLogo from './assets/vite.svg' import { AppShell } from './components/layout/AppShell'
import heroImg from './assets/hero.png' import { RepoListSkeleton } from './ui/Skeleton'
import './App.css' import { Suspense, lazy } from 'react'
import './index.css'
function App() { const queryClient = new QueryClient({
const [count, setCount] = useState(0) defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
placeholderData: (prev: unknown) => prev,
},
},
})
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const ReposPage = lazy(() => import('./pages/ReposPage'))
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 PageLoader() {
return ( return (
<> <div className="p-6">
<section id="center"> <RepoListSkeleton />
<div className="hero"> </div>
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
) )
} }
export default App export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route index element={<Suspense fallback={<PageLoader />}><DashboardPage /></Suspense>} />
<Route path="repos" element={<Suspense fallback={<PageLoader />}><ReposPage /></Suspense>} />
<Route path="pulls" element={<Suspense fallback={<PageLoader />}><PRsPage /></Suspense>} />
<Route path="pipelines" element={<Suspense fallback={<PageLoader />}><PipelinesPage /></Suspense>} />
<Route path="explore" element={<Suspense fallback={<PageLoader />}><ExplorePage /></Suspense>} />
<Route path="profile" element={<Suspense fallback={<PageLoader />}><ProfilePage /></Suspense>} />
<Route path="settings" element={<Suspense fallback={<PageLoader />}><SettingsPage /></Suspense>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { z } from 'zod'
let csrfToken: string | null = null
async function getCSRFToken(): Promise<string> {
if (csrfToken) return csrfToken
const res = await fetch('/api/v1/csrf', { credentials: 'include' })
if (!res.ok) throw new Error('Failed to fetch CSRF token')
csrfToken = res.headers.get('X-CSRF-Token') ?? ''
return csrfToken
}
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}
interface RequestOptions extends RequestInit {
json?: unknown
}
async function request<T>(
path: string,
schema: z.ZodType<T>,
options: RequestOptions = {},
): Promise<T> {
const { json, ...rest } = options
const headers: Record<string, string> = {}
if (json !== undefined) {
headers['Content-Type'] = 'application/json'
}
const method = (rest.method ?? 'GET').toUpperCase()
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
headers['X-CSRF-Token'] = await getCSRFToken()
}
const res = await fetch(path, {
...rest,
credentials: 'include',
headers: { ...headers, ...(rest.headers as Record<string, string> | undefined) },
body: json !== undefined ? JSON.stringify(json) : rest.body,
})
if (!res.ok) {
let message = res.statusText
try {
const body = await res.json()
if (typeof body.error === 'string') message = body.error
} catch {}
throw new ApiError(res.status, message)
}
const data = await res.json()
return schema.parse(data)
}
export const api = {
get: <T>(path: string, schema: z.ZodType<T>) => request(path, schema),
post: <T>(path: string, schema: z.ZodType<T>, body: unknown) =>
request(path, schema, { method: 'POST', json: body }),
put: <T>(path: string, schema: z.ZodType<T>, body: unknown) =>
request(path, schema, { method: 'PUT', json: body }),
delete: <T>(path: string, schema: z.ZodType<T>) =>
request(path, schema, { method: 'DELETE' }),
}
+37
View File
@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import type { Pipeline } from '../../types/api'
const pipelineSchema = z.object({
id: z.number(),
repoId: z.number(),
ref: z.string(),
status: z.enum(['pending', 'running', 'success', 'failure', 'cancelled']),
createdAt: z.string(),
updatedAt: z.string(),
})
const pipelinesSchema = z.array(pipelineSchema)
export function usePipelines(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'pipelines'],
queryFn: () =>
api.get<Pipeline[]>(`/api/v1/repos/${owner}/${repo}/pipelines`, pipelinesSchema),
enabled: Boolean(owner && repo),
refetchInterval: 5000, // poll while pipelines may be running
})
}
export function usePipeline(owner: string, repo: string, runId: number) {
return useQuery({
queryKey: ['repos', owner, repo, 'pipelines', runId],
queryFn: () =>
api.get<Pipeline>(
`/api/v1/repos/${owner}/${repo}/pipelines/${runId}`,
pipelineSchema,
),
enabled: Boolean(owner && repo && runId),
})
}
+50
View File
@@ -0,0 +1,50 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import type { PullRequest } from '../../types/api'
const prSchema = z.object({
id: z.number(),
repoId: z.number(),
authorId: z.number(),
title: z.string(),
body: z.string(),
sourceBranch: z.string(),
targetBranch: z.string(),
status: z.enum(['open', 'merged', 'closed']),
createdAt: z.string(),
updatedAt: z.string(),
})
const prsSchema = z.array(prSchema)
const mergeResponseSchema = z.object({ status: z.string() })
export function usePRs(owner: string, repo: string) {
return useQuery({
queryKey: ['repos', owner, repo, 'pulls'],
queryFn: () =>
api.get<PullRequest[]>(`/api/v1/repos/${owner}/${repo}/pulls`, prsSchema),
enabled: Boolean(owner && repo),
})
}
export function usePR(owner: string, repo: string, prId: number) {
return useQuery({
queryKey: ['repos', owner, repo, 'pulls', prId],
queryFn: () =>
api.get<PullRequest>(`/api/v1/repos/${owner}/${repo}/pulls/${prId}`, prSchema),
enabled: Boolean(owner && repo && prId),
})
}
export function useMergePR(owner: string, repo: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (prId: number) =>
api.post(`/api/v1/repos/${owner}/${repo}/pulls/${prId}/merge`, mergeResponseSchema, {}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repos', owner, repo, 'pulls'] })
},
})
}
+65
View File
@@ -0,0 +1,65 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { api } from '../client'
import type { Repository, TreeEntry } from '../../types/api'
const repositorySchema = z.object({
id: z.number(),
ownerId: z.number(),
name: z.string(),
description: z.string(),
isPrivate: z.boolean(),
defaultBranch: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
})
const repositoriesSchema = z.array(repositorySchema)
const treeEntrySchema = z.object({
mode: z.string(),
type: z.enum(['blob', 'tree']),
hash: z.string(),
name: z.string(),
})
const treeSchema = z.array(treeEntrySchema)
export function useRepos() {
return useQuery({
queryKey: ['repos'],
queryFn: () => api.get<Repository[]>('/api/v1/repos', repositoriesSchema),
})
}
export function useRepo(owner: string, name: string) {
return useQuery({
queryKey: ['repos', owner, name],
queryFn: () =>
api.get<Repository>(`/api/v1/repos/${owner}/${name}`, repositorySchema),
enabled: Boolean(owner && name),
})
}
export function useRepoTree(owner: string, name: string, ref: string, path = '') {
return useQuery({
queryKey: ['repos', owner, name, 'tree', ref, path],
queryFn: () =>
api.get<TreeEntry[]>(
`/api/v1/repos/${owner}/${name}/tree?ref=${ref}&path=${path}`,
treeSchema,
),
enabled: Boolean(owner && name && ref),
})
}
export function useCreateRepo() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: { name: string; description?: string; isPrivate?: boolean }) =>
api.post<Repository>('/api/v1/repos', repositorySchema, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['repos'] })
},
})
}
@@ -0,0 +1,24 @@
import { Outlet } from 'react-router-dom'
import { Sidebar } from './Sidebar'
import { BottomTabBar } from './BottomTabBar'
export function AppShell() {
return (
<div className="flex h-screen overflow-hidden bg-[#FAFBFC]">
{/* Desktop sidebar — hidden below md breakpoint */}
<Sidebar className="hidden md:flex" />
{/* Main content area — bottom padding leaves room for mobile tab bar */}
<main
id="main-content"
className="flex-1 overflow-y-auto pb-14 md:pb-0"
tabIndex={-1}
>
<Outlet />
</main>
{/* Mobile bottom tab bar — hidden above md breakpoint */}
<BottomTabBar className="md:hidden" />
</div>
)
}
@@ -0,0 +1,93 @@
import { NavLink } from 'react-router-dom'
import { cn } from '../../lib/utils'
const tabs = [
{
label: 'Home',
href: '/',
icon: (
<svg width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
),
},
{
label: 'Repos',
href: '/repos',
icon: (
<svg width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
</svg>
),
},
{
label: 'PRs',
href: '/pulls',
icon: (
<svg width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
),
},
{
label: 'Pipelines',
href: '/pipelines',
icon: (
<svg width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
</svg>
),
},
{
label: 'Profile',
href: '/profile',
icon: (
<svg width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
),
},
]
interface BottomTabBarProps {
className?: string
}
export function BottomTabBar({ className }: BottomTabBarProps) {
return (
<nav
className={cn(
'fixed bottom-0 left-0 right-0 h-14 bg-white border-t border-[#DFE1E6] pb-[env(safe-area-inset-bottom)]',
className,
)}
aria-label="Mobile navigation"
>
<ul className="flex h-full">
{tabs.map((tab) => (
<li key={tab.href} className="flex-1">
<NavLink
to={tab.href}
end={tab.href === '/'}
className={({ isActive }) =>
cn(
'flex flex-col items-center justify-center h-full gap-0.5 transition-colors',
'min-w-[44px]', // WCAG touch target width
isActive ? 'text-[#0052CC]' : 'text-[#5E6C84]',
)
}
>
{({ isActive }) => (
<>
<span className={cn(isActive && 'scale-110 transition-transform')}>
{tab.icon}
</span>
<span className="text-[10px] font-medium leading-none">{tab.label}</span>
</>
)}
</NavLink>
</li>
))}
</ul>
</nav>
)
}
+161
View File
@@ -0,0 +1,161 @@
import { useState } from 'react'
import { NavLink } from 'react-router-dom'
import { cn } from '../../lib/utils'
type SidebarState = 'expanded' | 'collapsed' | 'hidden'
interface NavItem {
label: string
href: string
icon: React.ReactNode
}
const navItems: NavItem[] = [
{
label: 'Dashboard',
href: '/',
icon: (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
),
},
{
label: 'Repositories',
href: '/repos',
icon: (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776" />
</svg>
),
},
{
label: 'Pull Requests',
href: '/pulls',
icon: (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
),
},
{
label: 'Pipelines',
href: '/pipelines',
icon: (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
</svg>
),
},
{
label: 'Explore',
href: '/explore',
icon: (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<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>
),
},
]
interface SidebarProps {
className?: string
}
export function Sidebar({ className }: SidebarProps) {
const [state, setState] = useState<SidebarState>('expanded')
const isCollapsed = state === 'collapsed'
const width = isCollapsed ? 'w-14' : 'w-80'
return (
<aside
className={cn(
'relative flex flex-col h-full bg-[#172B4D] text-white transition-[width] duration-200 ease-in-out overflow-hidden shrink-0',
width,
className,
)}
aria-label="Main navigation"
>
{/* Logo + toggle */}
<div className="flex items-center h-14 px-3 border-b border-white/10 shrink-0">
{!isCollapsed && (
<span className="flex-1 text-sm font-semibold tracking-wide truncate">
ForgeBucket
</span>
)}
<button
onClick={() => setState(isCollapsed ? 'expanded' : 'collapsed')}
className="flex items-center justify-center w-8 h-8 rounded hover:bg-white/10 transition-colors"
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<svg
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
aria-hidden="true"
className={cn('transition-transform duration-200', isCollapsed && 'rotate-180')}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
</button>
</div>
{/* Navigation */}
<nav className="flex-1 py-2 overflow-y-auto">
<ul className="flex flex-col gap-0.5 px-2">
{navItems.map((item) => (
<li key={item.href}>
<NavLink
to={item.href}
end={item.href === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded px-2 transition-colors',
'min-h-[44px]', // WCAG touch target
isActive
? 'bg-white/20 text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white',
)
}
title={isCollapsed ? item.label : undefined}
>
<span className="shrink-0">{item.icon}</span>
{!isCollapsed && (
<span className="text-sm font-medium truncate">{item.label}</span>
)}
</NavLink>
</li>
))}
</ul>
</nav>
{/* Bottom: settings */}
<div className="py-2 px-2 border-t border-white/10 shrink-0">
<NavLink
to="/settings"
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded px-2 min-h-[44px] transition-colors',
isActive
? 'bg-white/20 text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white',
)
}
title={isCollapsed ? 'Settings' : undefined}
>
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
{!isCollapsed && (
<span className="text-sm font-medium">Settings</span>
)}
</NavLink>
</div>
</aside>
)
}
+3
View File
@@ -0,0 +1,3 @@
export function cn(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' ')
}
+8
View File
@@ -0,0 +1,8 @@
export default function DashboardPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">Dashboard</h1>
<p className="text-[#5E6C84]">Coming soon Phase 2 implementation.</p>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
export default function ExplorePage() {
return (
<div className="p-6">
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">Explore</h1>
<p className="text-[#5E6C84]">Coming soon Phase 2 implementation.</p>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
export default function PRsPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">PRs</h1>
<p className="text-[#5E6C84]">Coming soon Phase 2 implementation.</p>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
export default function PipelinesPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">Pipelines</h1>
<p className="text-[#5E6C84]">Coming soon Phase 2 implementation.</p>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
export default function ProfilePage() {
return (
<div className="p-6">
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">Profile</h1>
<p className="text-[#5E6C84]">Coming soon Phase 2 implementation.</p>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
export default function ReposPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">Repos</h1>
<p className="text-[#5E6C84]">Coming soon Phase 2 implementation.</p>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
export default function SettingsPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-semibold text-[#172B4D] mb-4">Settings</h1>
<p className="text-[#5E6C84]">Coming soon Phase 2 implementation.</p>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
// Mirrors Go struct definitions in internal/models/
// Keep in sync with backend JSON serialization.
export interface User {
id: number
username: string
email: string
avatarUrl: string
isAdmin: boolean
createdAt: string // ISO 8601
updatedAt: string
}
export interface Repository {
id: number
ownerId: number
name: string
description: string
isPrivate: boolean
defaultBranch: string
createdAt: string
updatedAt: string
}
export type PRStatus = 'open' | 'merged' | 'closed'
export interface PullRequest {
id: number
repoId: number
authorId: number
title: string
body: string
sourceBranch: string
targetBranch: string
status: PRStatus
createdAt: string
updatedAt: string
}
export interface TreeEntry {
mode: string
type: 'blob' | 'tree'
hash: string
name: string
}
export interface Commit {
hash: string
author: string
message: string
date: string
}
export interface Pipeline {
id: number
repoId: number
ref: string
status: 'pending' | 'running' | 'success' | 'failure' | 'cancelled'
createdAt: string
updatedAt: string
}
export interface ApiError {
error: string
status: number
}
export interface HealthResponse {
status: 'ok'
}
+59
View File
@@ -0,0 +1,59 @@
import { cn } from '../lib/utils'
interface SkeletonProps {
className?: string
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={cn('animate-pulse rounded bg-[#F4F5F7]', className)}
aria-hidden="true"
/>
)
}
export function RepoListSkeleton() {
return (
<div className="flex flex-col gap-3" aria-label="Loading repositories">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-4 border border-[#DFE1E6] rounded">
<Skeleton className="size-8 rounded-full shrink-0" />
<div className="flex-1 flex flex-col gap-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-72" />
</div>
</div>
))}
</div>
)
}
export function PRListSkeleton() {
return (
<div className="flex flex-col gap-2" aria-label="Loading pull requests">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 p-4 border border-[#DFE1E6] rounded">
<Skeleton className="h-5 w-12 rounded-full shrink-0" />
<div className="flex-1 flex flex-col gap-2">
<Skeleton className="h-4 w-64" />
<Skeleton className="h-3 w-40" />
</div>
</div>
))}
</div>
)
}
export function DiffViewerSkeleton() {
return (
<div className="font-mono text-sm" aria-label="Loading diff">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="flex gap-2 py-1 px-2">
<Skeleton className="h-4 w-8 shrink-0" />
<Skeleton className={`h-4 ${i % 3 === 0 ? 'w-full' : i % 3 === 1 ? 'w-3/4' : 'w-1/2'}`} />
</div>
))}
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
export const tokens = {
space: {
xs: 4, // 4px
sm: 8, // 8px
md: 16, // 16px
lg: 24, // 24px
xl: 32, // 32px
xxl: 48, // 48px
},
color: {
brand: '#0052CC', // Atlassian Blue
brandHover: '#0065FF',
surface: '#FFFFFF',
subtle: '#F4F5F7',
elevated: '#FAFBFC',
text: '#172B4D',
muted: '#5E6C84',
border: '#DFE1E6',
borderFocus: '#4C9AFF',
danger: '#DE350B',
dangerSubtle: '#FFEBE6',
success: '#00875A',
successSubtle: '#E3FCEF',
warning: '#FF8B00',
warningSubtle: '#FFFAE6',
info: '#0052CC',
infoSubtle: '#DEEBFF',
},
sidebar: {
collapsed: 56,
expanded: 320,
},
touchTarget: 44, // WCAG 2.5.5 minimum
borderRadius: {
sm: 3,
md: 4,
lg: 8,
full: 9999,
},
} as const
export type ColorToken = keyof typeof tokens.color
export type SpaceToken = keyof typeof tokens.space
+18 -2
View File
@@ -1,7 +1,23 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [tailwindcss(), react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
}) })