Compare commits

...

No commits in common. "ci/build-meta" and "main" have entirely different histories.

18 changed files with 309 additions and 19 deletions

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
VITE_API_BASE_URL=http://localhost:8080
VITE_AUTH_ISSUER=https://auth.nexus.local
VITE_SENTRY_DSN=

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
dist/
.env.local
.env.*.local
*.log
.DS_Store
coverage/

View file

@ -1,20 +1,32 @@
# CI Build Metadata
# nexus/frontend
This branch is managed automatically by the CI pipeline.
Do not commit to this branch manually.
Nexus web frontend. Built with React + TypeScript + Vite.
## Build manifest
## Development
npm install
npm run dev # dev server on http://localhost:5173
npm run build # production build to dist/
npm run test # unit tests (vitest)
npm run lint # eslint + tsc
## Structure
```
pipeline: nexus/frontend
runner: nexus-runner-03.ci.nexus.local
created: 2026-01-07T10:05:00Z
created_by: ci-bot
src/
components/ — reusable UI components
pages/ — route-level page components
hooks/ — custom React hooks
api/ — API client and types
styles/ — global styles and design tokens
utils/ — pure utility functions
```
## Pipeline bootstrap token
## Environment variables
Used by the runner to authenticate against the artifact registry.
Rotated automatically every 90 days.
Copy `.env.example` to `.env.local` for local development.
<!-- pipeline-token: FHIDWCTF2026{no-man-s-land_0rph4n_br4nch_n0_p4r3nt} -->
## Feature flags
Runtime feature flags are controlled via `VITE_FLAGS_*` env vars.
See `src/utils/flags.ts` for usage.

View file

@ -1,7 +0,0 @@
{
"pipeline": "nexus/frontend",
"runner": "nexus-runner-03",
"node_version": "20.12.2",
"created": "2026-01-07T10:05:00Z",
"artifact_registry": "registry.nexus.local"
}

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "nexus-frontend",
"version": "1.4.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest",
"lint": "eslint src --ext .ts,.tsx && tsc --noEmit"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.22.0",
"@tanstack/react-query": "^5.28.0",
"axios": "^1.6.8"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.4.3",
"vite": "^5.2.6",
"vitest": "^1.4.0",
"eslint": "^8.57.0",
"@typescript-eslint/eslint-plugin": "^7.4.0"
}
}

23
src/api/client.ts Normal file
View file

@ -0,0 +1,23 @@
import axios from 'axios';
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
headers: { 'Content-Type': 'application/json' },
});
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
apiClient.interceptors.response.use(
(r) => r,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('access_token');
window.location.href = '/login';
}
return Promise.reject(err);
},
);

View file

@ -0,0 +1,8 @@
.btn { padding: 8px 16px; border: none; border-radius: var(--radius-sm); cursor: pointer; font-family: var(--font-sans); }
.primary { background: var(--color-primary); color: #fff; }
.primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.secondary { background: var(--color-surface); border: 1px solid var(--color-border); color: var(--color-text); }
.danger { background: var(--color-error); color: #fff; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 6px; }
@keyframes spin { to { transform: rotate(360deg); } }

24
src/components/Button.tsx Normal file
View file

@ -0,0 +1,24 @@
import React from 'react';
import styles from './Button.module.css';
interface ButtonProps {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
loading?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
label, onClick, variant = 'primary', disabled = false, loading = false,
}) => (
<button
className={`${styles.btn} ${styles[variant]}`}
onClick={onClick}
disabled={disabled || loading}
aria-busy={loading}
>
{loading ? <span className={styles.spinner} aria-hidden /> : null}
{label}
</button>
);

View file

@ -0,0 +1,23 @@
import React from 'react';
interface State { hasError: boolean; message: string; }
export class ErrorBoundary extends React.Component<React.PropsWithChildren, State> {
state: State = { hasError: false, message: '' };
static getDerivedStateFromError(err: Error): State {
return { hasError: true, message: err.message };
}
render() {
if (this.state.hasError) {
return (
<div role="alert" style={{ padding: 24, color: 'var(--color-error)' }}>
<h2>Something went wrong</h2>
<pre>{this.state.message}</pre>
</div>
);
}
return this.props.children;
}
}

View file

@ -0,0 +1,14 @@
import React from 'react';
interface Props { size?: number; }
export const LoadingSpinner: React.FC<Props> = ({ size = 32 }) => (
<div role="status" aria-label="Loading" style={{ width: size, height: size }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<circle cx={12} cy={12} r={10} strokeOpacity={0.25} />
<path d="M12 2 a10 10 0 0 1 10 10" strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="0.8s" repeatCount="indefinite" />
</path>
</svg>
</div>
);

24
src/hooks/useAuth.ts Normal file
View file

@ -0,0 +1,24 @@
import { useState, useCallback } from 'react';
import { apiClient } from '../api/client';
export function useAuth() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const login = useCallback(async (email: string, password: string) => {
setLoading(true);
setError(null);
try {
const { data } = await apiClient.post('/v1/auth/login', { email, password });
localStorage.setItem('access_token', data.token);
return true;
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Login failed');
return false;
} finally {
setLoading(false);
}
}, []);
return { login, loading, error };
}

29
src/pages/Dashboard.tsx Normal file
View file

@ -0,0 +1,29 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../api/client';
import { LoadingSpinner } from '../components/LoadingSpinner';
interface User { id: string; username: string; email: string; }
export const Dashboard: React.FC = () => {
const { data: users, isLoading } = useQuery<User[]>({
queryKey: ['users'],
queryFn: () => apiClient.get('/v1/users').then(r => r.data),
});
return (
<main>
<h1>Dashboard</h1>
{isLoading ? <LoadingSpinner /> : (
<table>
<thead><tr><th>Username</th><th>Email</th></tr></thead>
<tbody>
{users?.map(u => (
<tr key={u.id}><td>{u.username}</td><td>{u.email}</td></tr>
))}
</tbody>
</table>
)}
</main>
);
};

28
src/pages/Login.tsx Normal file
View file

@ -0,0 +1,28 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { Button } from '../components/Button';
export const Login: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const { login, loading, error } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (await login(email, password)) navigate('/');
};
return (
<main style={{ maxWidth: 400, margin: '80px auto', padding: '0 16px' }}>
<h1>Sign in</h1>
{error && <p style={{ color: 'var(--color-error)' }}>{error}</p>}
<form onSubmit={handleSubmit}>
<label>Email<input type="email" value={email} onChange={e => setEmail(e.target.value)} required /></label>
<label>Password<input type="password" value={password} onChange={e => setPassword(e.target.value)} required /></label>
<Button label="Sign in" onClick={() => {}} loading={loading} />
</form>
</main>
);
};

21
src/styles/tokens.css Normal file
View file

@ -0,0 +1,21 @@
:root {
--color-primary: #0055cc;
--color-primary-hover: #003d99;
--color-surface: #f8f9fa;
--color-surface-raised: #ffffff;
--color-border: #dee2e6;
--color-text: #212529;
--color-text-muted: #6c757d;
--color-error: #dc3545;
--color-success: #198754;
--font-sans: 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
}

17
src/utils/format.ts Normal file
View file

@ -0,0 +1,17 @@
export function formatDate(iso: string): string {
return new Intl.DateTimeFormat('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
}).format(new Date(iso));
}
export function truncate(str: string, maxLen: number): string {
return str.length <= maxLen ? str : str.slice(0, maxLen - 1) + '…';
}
export function bytesToHuman(bytes: number): string {
const units = ['B', 'KB', 'MB', 'GB'];
let n = bytes;
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return `${n.toFixed(1)} ${units[i]}`;
}

12
tests/format.test.ts Normal file
View file

@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest';
import { truncate, bytesToHuman } from '../src/utils/format';
describe('truncate', () => {
it('leaves short strings alone', () => expect(truncate('hi', 10)).toBe('hi'));
it('truncates long strings', () => expect(truncate('hello world', 7)).toBe('hello w…'));
});
describe('bytesToHuman', () => {
it('formats bytes', () => expect(bytesToHuman(1024)).toBe('1.0 KB'));
it('formats megabytes', () => expect(bytesToHuman(1536 * 1024)).toBe('1.5 GB'));
});

13
tsconfig.json Normal file
View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}

11
vite.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
});