feat(worker): scaffold Vite+React PWA with token storage and typed API client
This commit is contained in:
2
apps/worker/.gitignore
vendored
Normal file
2
apps/worker/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
15
apps/worker/index.html
Normal file
15
apps/worker/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#2563EB" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
<title>SoleLog</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
33
apps/worker/package.json
Normal file
33
apps/worker/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@solelog/worker",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solelog/shared": "workspace:*",
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.4.0",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
BIN
apps/worker/public/icon-192.png
Normal file
BIN
apps/worker/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
BIN
apps/worker/public/icon-512.png
Normal file
BIN
apps/worker/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
12
apps/worker/public/manifest.webmanifest
Normal file
12
apps/worker/public/manifest.webmanifest
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "SoleLog",
|
||||
"short_name": "SoleLog",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#2563EB",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
3
apps/worker/src/App.tsx
Normal file
3
apps/worker/src/App.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function App() {
|
||||
return <div>SoleLog</div>;
|
||||
}
|
||||
60
apps/worker/src/lib/api.test.ts
Normal file
60
apps/worker/src/lib/api.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { apiFetch, signIn, ApiError } from './api';
|
||||
import { getToken, setToken } from './auth-storage';
|
||||
|
||||
describe('api client', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('attaches the bearer token to requests', async () => {
|
||||
const mock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', mock);
|
||||
setToken('tok');
|
||||
|
||||
await apiFetch('/api/me');
|
||||
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mock.mock.calls[0];
|
||||
expect(url).toBe('http://localhost:3000/api/me');
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get('Authorization')).toBe('Bearer tok');
|
||||
});
|
||||
|
||||
it('throws ApiError on a non-2xx response', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(new Response(null, { status: 401 })),
|
||||
);
|
||||
|
||||
await expect(apiFetch('/api/me')).rejects.toMatchObject({ status: 401 });
|
||||
await expect(apiFetch('/api/me')).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
|
||||
it('signIn stores the token from the set-auth-token header', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(null, { status: 200, headers: { 'set-auth-token': 'xyz' } }),
|
||||
),
|
||||
);
|
||||
|
||||
await signIn('a@b.c', 'pw');
|
||||
|
||||
expect(getToken()).toBe('xyz');
|
||||
});
|
||||
|
||||
it('signIn throws when the header is missing', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 200 })));
|
||||
|
||||
await expect(signIn('a@b.c', 'pw')).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
});
|
||||
47
apps/worker/src/lib/api.ts
Normal file
47
apps/worker/src/lib/api.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getToken, setToken } from './auth-storage';
|
||||
|
||||
export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const token = getToken();
|
||||
const headers = new Headers(init.headers);
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
||||
const res = await fetch(`${API_URL}${path}`, { ...init, headers });
|
||||
if (!res.ok) throw new ApiError(res.status, `Request failed: ${res.status}`);
|
||||
const text = await res.text();
|
||||
return (text ? JSON.parse(text) : undefined) as T;
|
||||
}
|
||||
|
||||
// Sign in: POST /api/auth/sign-in/email, capture the bearer token from the response header.
|
||||
export async function signIn(email: string, password: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/api/auth/sign-in/email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) throw new ApiError(res.status, 'Inloggen mislukt');
|
||||
const token = res.headers.get('set-auth-token');
|
||||
if (!token) throw new ApiError(500, 'Geen token ontvangen');
|
||||
setToken(token);
|
||||
}
|
||||
|
||||
// Sign up affordance for testing: POST /api/auth/sign-up/email.
|
||||
export async function signUp(email: string, password: string): Promise<void> {
|
||||
const res = await fetch(`${API_URL}/api/auth/sign-up/email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, name: email.split('@')[0] || 'Worker' }),
|
||||
});
|
||||
if (!res.ok) throw new ApiError(res.status, 'Registreren mislukt');
|
||||
}
|
||||
15
apps/worker/src/lib/auth-storage.test.ts
Normal file
15
apps/worker/src/lib/auth-storage.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { getToken, setToken, clearToken } from './auth-storage';
|
||||
|
||||
describe('auth-storage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('stores, reads, and clears the token', () => {
|
||||
setToken('abc');
|
||||
expect(getToken()).toBe('abc');
|
||||
clearToken();
|
||||
expect(getToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
13
apps/worker/src/lib/auth-storage.ts
Normal file
13
apps/worker/src/lib/auth-storage.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const TOKEN_KEY = 'solelog.token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
15
apps/worker/src/main.tsx
Normal file
15
apps/worker/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
24
apps/worker/src/styles.css
Normal file
24
apps/worker/src/styles.css
Normal file
@@ -0,0 +1,24 @@
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-light: #eff6ff;
|
||||
--text: #111827;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--danger: #dc2626;
|
||||
--amber: #d97706;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
'Inter',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--text);
|
||||
background: #ffffff;
|
||||
}
|
||||
1
apps/worker/src/test/setup.ts
Normal file
1
apps/worker/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
9
apps/worker/src/vite-env.d.ts
vendored
Normal file
9
apps/worker/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
17
apps/worker/tsconfig.app.json
Normal file
17
apps/worker/tsconfig.app.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
apps/worker/tsconfig.json
Normal file
4
apps/worker/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
12
apps/worker/tsconfig.node.json
Normal file
12
apps/worker/tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"target": "ES2022",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
7
apps/worker/vite.config.ts
Normal file
7
apps/worker/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { host: true, port: 5173 }, // host:true → reachable from a phone on the LAN
|
||||
});
|
||||
7
apps/worker/vitest.config.ts
Normal file
7
apps/worker/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'] },
|
||||
});
|
||||
@@ -7,5 +7,8 @@
|
||||
"oxfmt": "^0.35.0",
|
||||
"oxlint": "^1.50.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"vite": "7.3.5"
|
||||
},
|
||||
"packageManager": "yarn@4.12.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user