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
|
||||
|
||||
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
|
||||
## Build manifest
|
||||
|
||||
```
|
||||
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: nexus/frontend
|
||||
runner: nexus-runner-03.ci.nexus.local
|
||||
created: 2026-01-07T10:05:00Z
|
||||
created_by: ci-bot
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
Runtime feature flags are controlled via `VITE_FLAGS_*` env vars.
|
||||
See `src/utils/flags.ts` for usage.
|
||||
<!-- pipeline-token: FHIDWCTF2026{no-man-s-land_0rph4n_br4nch_n0_p4r3nt} -->
|
||||
|
|
|
|||
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