more files
This commit is contained in:
@@ -10,19 +10,27 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/merge": "^6.12.1",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"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": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10"
|
||||
|
||||
Generated
+2152
File diff suppressed because it is too large
Load Diff
+46
-115
@@ -1,122 +1,53 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import './App.css'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AppShell } from './components/layout/AppShell'
|
||||
import { RepoListSkeleton } from './ui/Skeleton'
|
||||
import { Suspense, lazy } from 'react'
|
||||
import './index.css'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const queryClient = new QueryClient({
|
||||
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 (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<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 className="p-6">
|
||||
<RepoListSkeleton />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cn(...classes: (string | undefined | null | false)[]): string {
|
||||
return classes.filter(Boolean).join(' ')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -1,7 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user