phase 3 bug fixing
This commit is contained in:
+37
-32
@@ -1,6 +1,7 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { AppShell } from './components/layout/AppShell'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { RepoListSkeleton } from './ui/Skeleton'
|
||||
import { Suspense, lazy, useEffect } from 'react'
|
||||
import { bootstrapCSRF } from './api/client'
|
||||
@@ -10,33 +11,30 @@ const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
retry: false,
|
||||
placeholderData: (prev: unknown) => prev,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Pages — code-split per route
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
|
||||
const ReposPage = lazy(() => import('./pages/ReposPage'))
|
||||
const RepoPage = lazy(() => import('./pages/RepoPage'))
|
||||
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
|
||||
const PRDetailPage = lazy(() => import('./pages/PRDetailPage'))
|
||||
const PRsPage = lazy(() => import('./pages/PRsPage'))
|
||||
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
|
||||
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
||||
|
||||
function PageLoader() {
|
||||
return <div className="p-6"><RepoListSkeleton /></div>
|
||||
}
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage'))
|
||||
const RegisterPage = lazy(() => import('./pages/RegisterPage'))
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
|
||||
const ReposPage = lazy(() => import('./pages/ReposPage'))
|
||||
const RepoPage = lazy(() => import('./pages/RepoPage'))
|
||||
const RepoPRsPage = lazy(() => import('./pages/RepoPRsPage'))
|
||||
const PRDetailPage = lazy(() => import('./pages/PRDetailPage'))
|
||||
const PRsPage = lazy(() => import('./pages/PRsPage'))
|
||||
const PipelinesPage = lazy(() => import('./pages/PipelinesPage'))
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'))
|
||||
const ExplorePage = lazy(() => import('./pages/ExplorePage'))
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
||||
|
||||
function S({ children }: { children: React.ReactNode }) {
|
||||
return <Suspense fallback={<PageLoader />}>{children}</Suspense>
|
||||
return <Suspense fallback={<div className="p-6"><RepoListSkeleton /></div>}>{children}</Suspense>
|
||||
}
|
||||
|
||||
// Primes the CSRF cookie once when the SPA mounts
|
||||
function CSRFBootstrap() {
|
||||
useEffect(() => { bootstrapCSRF() }, [])
|
||||
return null
|
||||
@@ -47,21 +45,28 @@ export default function App() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<CSRFBootstrap />
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<S><DashboardPage /></S>} />
|
||||
<Route path="repos" element={<S><ReposPage /></S>} />
|
||||
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pulls" element={<S><RepoPRsPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
|
||||
<Route path="pulls" element={<S><PRsPage /></S>} />
|
||||
<Route path="pipelines" element={<S><PipelinesPage /></S>} />
|
||||
<Route path="explore" element={<S><ExplorePage /></S>} />
|
||||
<Route path="profile" element={<S><ProfilePage /></S>} />
|
||||
<Route path="settings" element={<S><SettingsPage /></S>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Public — no shell */}
|
||||
<Route path="login" element={<S><LoginPage /></S>} />
|
||||
<Route path="register" element={<S><RegisterPage /></S>} />
|
||||
|
||||
{/* App shell wraps all authenticated pages */}
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<S><DashboardPage /></S>} />
|
||||
<Route path="repos" element={<S><ReposPage /></S>} />
|
||||
<Route path="repos/:owner/:repo" element={<S><RepoPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pulls" element={<S><RepoPRsPage /></S>} />
|
||||
<Route path="repos/:owner/:repo/pulls/:prId" element={<S><PRDetailPage /></S>} />
|
||||
<Route path="pulls" element={<S><PRsPage /></S>} />
|
||||
<Route path="pipelines" element={<S><PipelinesPage /></S>} />
|
||||
<Route path="explore" element={<S><ExplorePage /></S>} />
|
||||
<Route path="profile" element={<S><ProfilePage /></S>} />
|
||||
<Route path="settings" element={<S><SettingsPage /></S>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { NavLink, Link } from 'react-router-dom'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
|
||||
type SidebarState = 'expanded' | 'collapsed' | 'hidden'
|
||||
|
||||
@@ -64,6 +65,7 @@ interface SidebarProps {
|
||||
|
||||
export function Sidebar({ className }: SidebarProps) {
|
||||
const [state, setState] = useState<SidebarState>('expanded')
|
||||
const { user, isAuthenticated } = useAuth()
|
||||
|
||||
const isCollapsed = state === 'collapsed'
|
||||
const width = isCollapsed ? 'w-14' : 'w-80'
|
||||
@@ -133,8 +135,37 @@ export function Sidebar({ className }: SidebarProps) {
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Bottom: settings */}
|
||||
<div className="py-2 px-2 border-t border-white/10 shrink-0">
|
||||
{/* Bottom: user + settings */}
|
||||
<div className="py-2 px-2 border-t border-white/10 shrink-0 flex flex-col gap-0.5">
|
||||
{isAuthenticated ? (
|
||||
<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 ? user?.username : undefined}
|
||||
>
|
||||
<div className="w-5 h-5 rounded-full bg-[#0052CC] flex items-center justify-center text-[10px] font-bold shrink-0">
|
||||
{user?.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
{!isCollapsed && <span className="text-sm font-medium truncate">{user?.username}</span>}
|
||||
</NavLink>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="flex items-center gap-3 rounded px-2 min-h-[44px] text-white/70 hover:bg-white/10 hover:text-white transition-colors"
|
||||
title={isCollapsed ? 'Sign in' : 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="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75" />
|
||||
</svg>
|
||||
{!isCollapsed && <span className="text-sm font-medium">Sign in</span>}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import { z } from 'zod'
|
||||
import type { User } from '../types/api'
|
||||
|
||||
const userSchema = z.object({
|
||||
id: z.number(),
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
avatarUrl: z.string(),
|
||||
isAdmin: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null
|
||||
isLoading: boolean
|
||||
isAuthenticated: boolean
|
||||
logout: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
logout: async () => {},
|
||||
})
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: user, isLoading } = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => api.get<User>('/api/v1/me', userSchema),
|
||||
retry: false,
|
||||
// Don't treat 401 as a fatal error — it just means not logged in
|
||||
throwOnError: false,
|
||||
})
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRF-Token': document.cookie.match(/fb_csrf=([^;]+)/)?.[1] ?? '' },
|
||||
})
|
||||
} finally {
|
||||
queryClient.clear()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user: user ?? null, isLoading, isAuthenticated: !!user, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
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 className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-6">
|
||||
<h1 className="text-xl font-semibold text-[#172B4D]">Explore</h1>
|
||||
<div className="flex flex-col items-center justify-center py-16 border border-dashed border-[#DFE1E6] rounded text-center gap-3">
|
||||
<svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#172B4D]">Explore public repositories</p>
|
||||
<p className="text-xs text-[#5E6C84] mt-1">
|
||||
Federated discovery across ForgeBucket instances — coming soon.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { bootstrapCSRF, api, ApiError } from '../api/client'
|
||||
import { z } from 'zod'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
const userSchema = z.object({
|
||||
id: z.number(), username: z.string(), email: z.string(),
|
||||
avatarUrl: z.string(), isAdmin: z.boolean(), createdAt: z.string(), updatedAt: z.string(),
|
||||
})
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await bootstrapCSRF()
|
||||
await api.post('/api/v1/auth/login', userSchema, { username, password })
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] })
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError && err.status === 401 ? 'Invalid username or password.' : 'Login failed. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F4F5F7] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-[#172B4D]">ForgeBucket</h1>
|
||||
<p className="text-sm text-[#5E6C84] mt-1">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[#DFE1E6] rounded-lg p-6 shadow-sm">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Username</label>
|
||||
<input
|
||||
value={username} onChange={e => setUsername(e.target.value)}
|
||||
required autoFocus autoComplete="username"
|
||||
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Password</label>
|
||||
<input
|
||||
type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
required autoComplete="current-password"
|
||||
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF]"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-xs text-[#DE350B] bg-[#FFEBE6] rounded px-3 py-2">{error}</p>}
|
||||
<button
|
||||
type="submit" disabled={loading}
|
||||
className="w-full py-2.5 rounded bg-[#0052CC] text-white text-sm font-semibold hover:bg-[#0065FF] disabled:opacity-50 min-h-[44px]"
|
||||
>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-[#5E6C84] mt-4">
|
||||
No account?{' '}
|
||||
<Link to="/register" className="text-[#0052CC] hover:underline font-medium">Create one</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
import { PipelineWaterfall } from '../components/ci/PipelineWaterfall'
|
||||
import type { Pipeline } from '../types/api'
|
||||
|
||||
const DEMO_PIPELINE: Pipeline = {
|
||||
id: 1,
|
||||
repoId: 1,
|
||||
ref: 'main',
|
||||
status: 'running',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
export default function PipelinesPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-6 py-6 space-y-6">
|
||||
<h1 className="text-xl font-semibold text-[#172B4D]">Pipelines</h1>
|
||||
<p className="text-sm text-[#5E6C84]">CI/CD pipeline integration — preview below.</p>
|
||||
<PipelineWaterfall pipeline={DEMO_PIPELINE} />
|
||||
<PipelineWaterfall pipeline={{ ...DEMO_PIPELINE, id: 2, status: 'success' }} />
|
||||
<PipelineWaterfall pipeline={{ ...DEMO_PIPELINE, id: 3, status: 'failure' }} />
|
||||
<div className="flex flex-col items-center justify-center py-16 border border-dashed border-[#DFE1E6] rounded text-center gap-3">
|
||||
<svg width="40" height="40" fill="none" stroke="#97A0AF" strokeWidth="1" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#172B4D]">No pipelines yet</p>
|
||||
<p className="text-xs text-[#5E6C84] mt-1 max-w-xs">
|
||||
Pipelines run automatically when you push to a repository.<br />
|
||||
Add a <code className="font-mono bg-[#F4F5F7] px-1 rounded">.forgebucket.yml</code> file to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { bootstrapCSRF, api, ApiError } from '../api/client'
|
||||
import { z } from 'zod'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
const userSchema = z.object({
|
||||
id: z.number(), username: z.string(), email: z.string(),
|
||||
avatarUrl: z.string(), isAdmin: z.boolean(), createdAt: z.string(), updatedAt: z.string(),
|
||||
})
|
||||
|
||||
export default function RegisterPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await bootstrapCSRF()
|
||||
await api.post('/api/v1/auth/register', userSchema, { username, email, password })
|
||||
// Auto-login after register
|
||||
await api.post('/api/v1/auth/login', userSchema, { username, password })
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] })
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError && err.status === 409 ? 'Username or email already taken.' : 'Registration failed. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F4F5F7] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-[#172B4D]">ForgeBucket</h1>
|
||||
<p className="text-sm text-[#5E6C84] mt-1">Create your account</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-[#DFE1E6] rounded-lg p-6 shadow-sm">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Username</label>
|
||||
<input value={username} onChange={e => setUsername(e.target.value)} required autoFocus
|
||||
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Email</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required
|
||||
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF]" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#172B4D] mb-1">Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required
|
||||
className="w-full border border-[#DFE1E6] rounded px-3 py-2 text-sm focus:outline-none focus:border-[#4C9AFF] focus:ring-1 focus:ring-[#4C9AFF]" />
|
||||
</div>
|
||||
{error && <p className="text-xs text-[#DE350B] bg-[#FFEBE6] rounded px-3 py-2">{error}</p>}
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full py-2.5 rounded bg-[#0052CC] text-white text-sm font-semibold hover:bg-[#0065FF] disabled:opacity-50 min-h-[44px]">
|
||||
{loading ? 'Creating account…' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-[#5E6C84] mt-4">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-[#0052CC] hover:underline font-medium">Sign in</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useRepos, useCreateRepo } from '../api/queries/repos'
|
||||
import { RepoCard } from '../components/repos/RepoCard'
|
||||
import { RepoListSkeleton } from '../ui/Skeleton'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function ReposPage() {
|
||||
const { data: repos, isLoading, isError } = useRepos()
|
||||
@@ -32,9 +33,12 @@ export default function ReposPage() {
|
||||
{isLoading ? (
|
||||
<RepoListSkeleton />
|
||||
) : isError ? (
|
||||
<p className="text-sm text-[#DE350B]">Failed to load repositories.</p>
|
||||
<NotSignedIn />
|
||||
) : !repos?.length ? (
|
||||
<p className="text-sm text-[#5E6C84] py-8 text-center">No repositories yet.</p>
|
||||
<div className="py-12 text-center text-sm text-[#5E6C84]">
|
||||
No repositories yet.{' '}
|
||||
<button onClick={() => setShowCreate(true)} className="text-[#0052CC] hover:underline">Create your first one.</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{repos.map(r => <RepoCard key={r.id} repo={r} />)}
|
||||
@@ -44,6 +48,23 @@ export default function ReposPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function NotSignedIn() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 border border-dashed border-[#DFE1E6] rounded text-center gap-3">
|
||||
<svg width="36" height="36" fill="none" stroke="#97A0AF" strokeWidth="1.5" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#172B4D]">Sign in to see your repositories</p>
|
||||
<p className="text-xs text-[#5E6C84] mt-1">You need to be signed in to view and create repositories.</p>
|
||||
</div>
|
||||
<Link to="/login" className="px-4 py-2 rounded bg-[#0052CC] text-white text-sm font-medium hover:bg-[#0065FF] min-h-[44px] flex items-center">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateRepoForm({ onClose }: { onClose: () => void }) {
|
||||
const createRepo = useCreateRepo()
|
||||
const [name, setName] = useState('')
|
||||
|
||||
@@ -1,8 +1,45 @@
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
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 className="max-w-2xl mx-auto px-4 md:px-6 py-6 space-y-8">
|
||||
<h1 className="text-xl font-semibold text-[#172B4D]">Settings</h1>
|
||||
|
||||
<section className="border border-[#DFE1E6] rounded-lg overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[#DFE1E6] bg-[#FAFBFC]">
|
||||
<h2 className="text-sm font-semibold text-[#172B4D]">Account</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-[#5E6C84]">Username</p>
|
||||
<p className="text-sm font-medium text-[#172B4D]">{user?.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[#5E6C84]">Email</p>
|
||||
<p className="text-sm text-[#172B4D]">{user?.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-[#5E6C84]">Role</p>
|
||||
<p className="text-sm text-[#172B4D]">{user?.isAdmin ? 'Administrator' : 'Member'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border border-[#FFEBE6] rounded-lg overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-[#FFEBE6] bg-[#FFEBE6]/50">
|
||||
<h2 className="text-sm font-semibold text-[#BF2600]">Danger zone</h2>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-4 py-2 rounded border border-[#DE350B] text-[#DE350B] text-sm font-medium hover:bg-[#FFEBE6] min-h-[44px]"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user