more files
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
Generated
+2152
File diff suppressed because it is too large
Load Diff
+46
-115
@@ -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">
|
|
||||||
<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>
|
||||||
<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 { 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,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user