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",
|
"oxfmt": "^0.35.0",
|
||||||
"oxlint": "^1.50.0"
|
"oxlint": "^1.50.0"
|
||||||
},
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"vite": "7.3.5"
|
||||||
|
},
|
||||||
"packageManager": "yarn@4.12.0"
|
"packageManager": "yarn@4.12.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user