Add frontend CI workflow, error boundary, and VITE_API_URL support

- .github/workflows/frontend.yml: type-check + build on push/PR
- ErrorBoundary component wraps App with catch + retry UI
- API client reads VITE_API_URL env var for non-same-origin deploys
- vite-env.d.ts declares ImportMetaEnv type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jon Saad-Falcon
2026-02-28 18:02:09 +00:00
co-authored by Claude Opus 4.6
parent 1c44f51ef5
commit 759efb9fa8
5 changed files with 137 additions and 2 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Frontend CI
on:
push:
branches: [main]
paths:
- 'frontend/**'
- '.github/workflows/frontend.yml'
pull_request:
branches: [main]
paths:
- 'frontend/**'
- '.github/workflows/frontend.yml'
workflow_dispatch:
concurrency:
group: frontend-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npx tsc --noEmit
- run: npm run build
+1 -1
View File
@@ -1,6 +1,6 @@
import type { ModelInfo, SavingsData, ServerInfo } from '../types';
const BASE = ''; // relative to same origin
const BASE = import.meta.env.VITE_API_URL || ''; // relative to same origin by default
export async function fetchModels(): Promise<ModelInfo[]> {
const res = await fetch(`${BASE}/v1/models`);
+86
View File
@@ -0,0 +1,86 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
}
render() {
if (this.state.hasError) {
return (
<div style={styles.container}>
<div style={styles.card}>
<h2 style={styles.heading}>Something went wrong</h2>
<p style={styles.message}>
{this.state.error?.message || 'An unexpected error occurred.'}
</p>
<button
style={styles.button}
onClick={() => this.setState({ hasError: false, error: null })}
>
Try again
</button>
</div>
</div>
);
}
return this.props.children;
}
}
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
backgroundColor: '#1a1a1e',
color: '#e2e8f0',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
},
card: {
textAlign: 'center' as const,
padding: 32,
maxWidth: 420,
},
heading: {
fontSize: 20,
fontWeight: 600,
marginBottom: 12,
},
message: {
fontSize: 14,
color: '#94a3b8',
marginBottom: 24,
lineHeight: 1.5,
},
button: {
padding: '10px 24px',
borderRadius: 8,
border: 'none',
backgroundColor: '#2563eb',
color: 'white',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
},
};
+4 -1
View File
@@ -1,10 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ErrorBoundary } from './components/ErrorBoundary';
import App from './App';
import './App.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}