Compare commits
No commits in common. "main" and "ci/build-meta" have entirely different histories.
main
...
ci/build-m
18 changed files with 19 additions and 309 deletions
|
|
@ -1,3 +0,0 @@
|
||||||
VITE_API_BASE_URL=http://localhost:8080
|
|
||||||
VITE_AUTH_ISSUER=https://auth.nexus.local
|
|
||||||
VITE_SENTRY_DSN=
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
coverage/
|
|
||||||
36
README.md
36
README.md
|
|
@ -1,32 +1,20 @@
|
||||||
# nexus/frontend
|
# CI Build Metadata
|
||||||
|
|
||||||
Nexus web frontend. Built with React + TypeScript + Vite.
|
This branch is managed automatically by the CI pipeline.
|
||||||
|
Do not commit to this branch manually.
|
||||||
|
|
||||||
## Development
|
## Build manifest
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
pipeline: nexus/frontend
|
||||||
components/ — reusable UI components
|
runner: nexus-runner-03.ci.nexus.local
|
||||||
pages/ — route-level page components
|
created: 2026-01-07T10:05:00Z
|
||||||
hooks/ — custom React hooks
|
created_by: ci-bot
|
||||||
api/ — API client and types
|
|
||||||
styles/ — global styles and design tokens
|
|
||||||
utils/ — pure utility functions
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment variables
|
## Pipeline bootstrap token
|
||||||
|
|
||||||
Copy `.env.example` to `.env.local` for local development.
|
Used by the runner to authenticate against the artifact registry.
|
||||||
|
Rotated automatically every 90 days.
|
||||||
|
|
||||||
## Feature flags
|
<!-- pipeline-token: FHIDWCTF2026{no-man-s-land_0rph4n_br4nch_n0_p4r3nt} -->
|
||||||
|
|
||||||
Runtime feature flags are controlled via `VITE_FLAGS_*` env vars.
|
|
||||||
See `src/utils/flags.ts` for usage.
|
|
||||||
|
|
|
||||||
7
build-manifest.json
Normal file
7
build-manifest.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"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
28
package.json
|
|
@ -1,28 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
.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); } }
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
: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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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]}`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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'));
|
|
||||||
});
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"lib": ["ES2020", "DOM"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
'/api': 'http://localhost:8080',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Loading…
Reference in a new issue