feat(web): build public PWA and editorial workspace
This commit is contained in:
parent
c035fbfb9f
commit
44c7cdbf1f
21 changed files with 282 additions and 0 deletions
11
apps/web/index.html
Normal file
11
apps/web/index.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!doctype html>
|
||||
<html lang="da">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#111714" />
|
||||
<meta name="description" content="Et levende, kildebelagt opslagsværk om moderne softwareudvikling." />
|
||||
<title>Tech Library</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
|
||||
</html>
|
||||
30
apps/web/package.json
Normal file
30
apps/web/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@tech-library/web",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": { "dev": "vite", "build": "tsc -b && vite build", "typecheck": "tsc -b --pretty false", "test": "vitest run src" },
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.87.1",
|
||||
"@tech-library/contracts": "*",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"jsdom": "^27.0.0",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-pwa": "^1.0.3",
|
||||
"vitest": "^5.0.5"
|
||||
}
|
||||
}
|
||||
1
apps/web/public/icon.svg
Normal file
1
apps/web/public/icon.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#111714"/><path d="M116 132h280v58H286v222h-60V190H116z" fill="#d8ff58"/><circle cx="366" cy="382" r="38" fill="#f7734f"/></svg>
|
||||
|
After Width: | Height: | Size: 237 B |
47
apps/web/src/features/admin/AdminPage.tsx
Normal file
47
apps/web/src/features/admin/AdminPage.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Activity, Check, FileEdit, LogIn, RefreshCw, ShieldCheck, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { TopicRevisionInput, TopicType } from '@tech-library/contracts';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
export function AdminPage() {
|
||||
const session = useQuery({ queryKey: ['admin-session'], queryFn: api.session, retry: false });
|
||||
if (session.isLoading) return <div className="page-state">Kontrollerer session…</div>;
|
||||
if (session.isError) return <Login onSuccess={() => session.refetch()}/>;
|
||||
if (!session.data) return <Login onSuccess={() => session.refetch()}/>;
|
||||
return <AdminWorkspace csrf={session.data.csrfToken}/>;
|
||||
}
|
||||
|
||||
function Login({ onSuccess }: { onSuccess(): void }) {
|
||||
const [username, setUsername] = useState('admin'); const [password, setPassword] = useState(''); const [error, setError] = useState('');
|
||||
const submit = async (event: React.FormEvent) => { event.preventDefault(); setError(''); try { await api.login(username, password); onSuccess(); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Login mislykkedes'); } };
|
||||
return <section className="admin-login"><div className="login-panel"><span className="admin-icon"><ShieldCheck/></span><p className="kicker">Redaktion</p><h1>Velkommen tilbage</h1><p>Log ind for at gennemgå kilder, redigere udkast og publicere verificeret viden.</p><form onSubmit={submit}><label>Brugernavn<input autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)}/></label><label>Adgangskode<input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)}/></label>{error && <p className="form-error">{error}</p>}<button className="primary-button"><LogIn size={17}/>Log ind</button></form></div></section>;
|
||||
}
|
||||
|
||||
function AdminWorkspace({ csrf }: { csrf: string }) {
|
||||
const client = useQueryClient();
|
||||
const findings = useQuery({ queryKey: ['findings'], queryFn: api.findings });
|
||||
const poll = useMutation({ mutationFn: () => api.mutate('/source-poll', csrf) });
|
||||
const review = useMutation({ mutationFn: ({ id, action }: { id: string; action: string }) => api.mutate(`/findings/${id}/${action}`, csrf), onSuccess: () => client.invalidateQueries({ queryKey: ['findings'] }) });
|
||||
return <section className="admin-page"><header><div><p className="kicker">Kontrolrum</p><h1>Redaktion</h1><p>Ingen ændringer bliver publiceret uden et menneskeligt review.</p></div><button className="secondary-button" onClick={() => poll.mutate()} disabled={poll.isPending}><RefreshCw size={16}/>Scan officielle kilder</button></header>
|
||||
<div className="admin-stats"><div><Activity/><span><strong>{findings.data?.filter((item) => item.status === 'pending').length ?? 0}</strong>Afventer review</span></div><div><ShieldCheck/><span><strong>Manuel</strong>Publiceringspolitik</span></div><div><RefreshCw/><span><strong>24 timer</strong>Scanningsinterval</span></div></div>
|
||||
<Tabs.Root defaultValue="findings" className="admin-tabs"><Tabs.List><Tabs.Trigger value="findings">Kildefund</Tabs.Trigger><Tabs.Trigger value="editor">Nyt udkast</Tabs.Trigger></Tabs.List>
|
||||
<Tabs.Content value="findings"><div className="admin-list">{findings.isLoading && <p>Henter findings…</p>}{findings.data?.length === 0 && <div className="empty-state"><Check/><h2>Alt er gennemgået</h2><p>Der er ingen kildeændringer i kø.</p></div>}{findings.data?.map((finding) => <div className="finding" key={finding.id}><div><span className={`status ${finding.status}`}>{finding.status}</span><h3>{finding.title}</h3><p>{finding.summary}</p><a href={finding.source_url} target="_blank" rel="noreferrer">Åbn officiel kilde</a></div>{finding.status === 'pending' && <div className="finding-actions"><button onClick={() => review.mutate({ id: finding.id, action: 'reject' })}><X/>Afvis</button><button className="approve" onClick={() => review.mutate({ id: finding.id, action: 'approve' })}><Check/>Godkend fund</button></div>}</div>)}</div></Tabs.Content>
|
||||
<Tabs.Content value="editor"><DraftEditor csrf={csrf}/></Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function DraftEditor({ csrf }: { csrf: string }) {
|
||||
const [message, setMessage] = useState('');
|
||||
const create = useMutation({ mutationFn: (input: TopicRevisionInput) => api.createDraft(csrf, input), onSuccess: (result) => setMessage(`Udkast #${String(result.number)} blev oprettet. Revision-ID: ${String(result.id)}`) });
|
||||
const submit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault(); const form = new FormData(event.currentTarget); const title = String(form.get('title')); const summary = String(form.get('summary')); const domain = String(form.get('domain')); const type = String(form.get('type')) as TopicType;
|
||||
create.mutate({ title, slug: String(form.get('slug')), summary, audience: ['beginner', 'intermediate', 'advanced'], type, domains: [domain], tags: [domain.toLowerCase(), type], aliases: [], currentVersion: String(form.get('version')) || null, supportedVersions: [], content: { introduction: String(form.get('introduction')), concepts: lines(form.get('concepts')), useCases: lines(form.get('useCases')), benefits: lines(form.get('benefits')), limitations: lines(form.get('limitations')), avoidWhen: lines(form.get('avoidWhen')), compatibility: String(form.get('compatibility')), requirements: lines(form.get('requirements')), examples: [], migrationNotes: [], deprecations: [], securityNotes: lines(form.get('securityNotes')) }, changeNote: String(form.get('changeNote')) });
|
||||
};
|
||||
return <form className="editor-form" onSubmit={submit}><div className="editor-heading"><FileEdit/><div><h2>Opret immutable revision</h2><p>Felter med flere værdier bruger én linje per punkt.</p></div></div><div className="form-grid"><Field name="title" label="Titel" required/><Field name="slug" label="Slug" placeholder="web-technology" required/><Field name="domain" label="Fagområde" required/><label>Type<select name="type" defaultValue="technology"><option value="technology">Teknologi</option><option value="framework">Framework</option><option value="library">Bibliotek</option><option value="language">Sprog</option><option value="runtime">Runtime</option><option value="tool">Værktøj</option><option value="platform">Platform</option><option value="standard">Standard</option><option value="practice">Praksis</option><option value="architecture">Arkitektur</option></select></label><Field name="version" label="Aktuel version"/><Field name="changeNote" label="Ændringsnote" required/></div><Text name="summary" label="Resumé" required/><Text name="introduction" label="Introduktion" required/><div className="form-grid"><Text name="concepts" label="Centrale begreber" required/><Text name="useCases" label="Anvendelser" required/><Text name="benefits" label="Fordele" required/><Text name="limitations" label="Begrænsninger" required/><Text name="avoidWhen" label="Fravalg" required/><Text name="requirements" label="Krav" required/><Text name="securityNotes" label="Sikkerhedsnoter" required/></div><Text name="compatibility" label="Kompatibilitet" required/>{create.error && <p className="form-error">{create.error.message}</p>}{message && <p className="form-success">{message}</p>}<button className="primary-button" disabled={create.isPending}>Opret udkast</button></form>;
|
||||
}
|
||||
const lines = (value: FormDataEntryValue | null) => String(value ?? '').split('\n').map((item) => item.trim()).filter(Boolean);
|
||||
function Field(props: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) { const { label, ...input } = props; return <label>{label}<input {...input}/></label>; }
|
||||
function Text(props: React.TextareaHTMLAttributes<HTMLTextAreaElement> & { label: string }) { const { label, ...input } = props; return <label>{label}<textarea rows={3} {...input}/></label>; }
|
||||
2
apps/web/src/features/library/LibraryPage.test.tsx
Normal file
2
apps/web/src/features/library/LibraryPage.test.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
describe('library taxonomy', () => { it('keeps the public catalog route stable', () => { expect('/emne/react').toMatch(/^\/emne\//); }); });
|
||||
53
apps/web/src/features/library/LibraryPage.tsx
Normal file
53
apps/web/src/features/library/LibraryPage.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useDeferredValue, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowUpRight, Search, SlidersHorizontal, X } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../../lib/api';
|
||||
import { useUiStore } from '../../store/ui';
|
||||
|
||||
export function LibraryPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const search = useDeferredValue(query);
|
||||
const { domain, type, setDomain, setType, clear } = useUiStore();
|
||||
const params = new URLSearchParams({ pageSize: '50' });
|
||||
if (search) params.set('q', search);
|
||||
if (domain) params.set('domain', domain);
|
||||
if (type) params.set('type', type);
|
||||
const topics = useQuery({ queryKey: ['topics', search, domain, type], queryFn: () => api.topics(params) });
|
||||
const facets = useQuery({ queryKey: ['facets'], queryFn: api.facets });
|
||||
|
||||
return <>
|
||||
<section className="hero">
|
||||
<div className="eyebrow"><span></span>Et levende teknisk opslagsværk</div>
|
||||
<h1>Forstå teknologien.<br/><em>Se hele sammenhængen.</em></h1>
|
||||
<p>Kurateret viden om moderne web-, app- og systemudvikling — fra første begreb til arkitekturbeslutning. Hvert udsagn føres tilbage til sin kilde.</p>
|
||||
<label className="searchbox"><Search aria-hidden="true"/><span className="sr-only">Søg i biblioteket</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Søg efter React, databaser, sikkerhed…"/><kbd>⌘ K</kbd></label>
|
||||
<div className="hero-meta"><span>{topics.data?.total ?? '—'} kuraterede emner</span><span>•</span><span>Officielle primærkilder</span><span>•</span><span>Løbende verificeret</span></div>
|
||||
</section>
|
||||
|
||||
<section className="library-layout">
|
||||
<aside className="filters" aria-label="Filtre">
|
||||
<div className="filter-title"><span><SlidersHorizontal size={16}/>Filtrér</span>{(domain || type) && <button onClick={clear}><X size={14}/>Nulstil</button>}</div>
|
||||
<FilterGroup title="Fagområde" value={domain} items={facets.data?.domains ?? []} onChange={setDomain}/>
|
||||
<FilterGroup title="Type" value={type} items={facets.data?.types ?? []} onChange={setType}/>
|
||||
</aside>
|
||||
<div className="catalog">
|
||||
<div className="catalog-header"><div><p className="kicker">Biblioteket</p><h2>{domain || 'Alle emner'}</h2></div><span>{topics.data?.total ?? 0} resultater</span></div>
|
||||
{topics.isLoading && <div className="state-card">Henter biblioteket…</div>}
|
||||
{topics.isError && <div className="state-card error">Biblioteket kunne ikke hentes. Kontrollér forbindelsen og prøv igen.</div>}
|
||||
<div className="topic-grid">{topics.data?.items.map((topic, index) => <Link className="topic-card" to={`/emne/${topic.slug}`} key={topic.id} style={{ '--delay': `${Math.min(index, 12) * 25}ms` } as React.CSSProperties}>
|
||||
<div className="card-top"><span className="type-badge">{labelType(topic.type)}</span><ArrowUpRight size={18}/></div>
|
||||
<h3>{topic.title}</h3><p>{topic.summary}</p>
|
||||
<div className="tag-row">{topic.domains.map((value) => <span key={value}>{value}</span>)}</div>
|
||||
<div className="verified">Verificeret {new Date(topic.verifiedAt).toLocaleDateString('da-DK')}</div>
|
||||
</Link>)}</div>
|
||||
{topics.data?.items.length === 0 && <div className="state-card">Ingen emner matcher din søgning.</div>}
|
||||
</div>
|
||||
</section>
|
||||
</>;
|
||||
}
|
||||
|
||||
function FilterGroup({ title, items, value, onChange }: { title: string; items: Array<{ value: string; count: number }>; value: string; onChange(value: string): void }) {
|
||||
return <fieldset><legend>{title}</legend>{items.map((item) => <label key={item.value}><input type="radio" name={title} checked={value === item.value} onChange={() => onChange(value === item.value ? '' : item.value)}/><span>{labelType(item.value)}</span><small>{item.count}</small></label>)}</fieldset>;
|
||||
}
|
||||
const labelType = (value: string) => ({ technology: 'Teknologi', framework: 'Framework', library: 'Bibliotek', language: 'Sprog', runtime: 'Runtime', tool: 'Værktøj', platform: 'Platform', standard: 'Standard', practice: 'Praksis', architecture: 'Arkitektur' }[value] ?? value);
|
||||
32
apps/web/src/features/topic/TopicPage.tsx
Normal file
32
apps/web/src/features/topic/TopicPage.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeft, CheckCircle2, ExternalLink, GitBranch, Shield, Sparkles } from 'lucide-react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
export function TopicPage() {
|
||||
const { slug = '' } = useParams();
|
||||
const { data: topic, isLoading, isError } = useQuery({ queryKey: ['topic', slug], queryFn: () => api.topic(slug) });
|
||||
if (isLoading) return <div className="page-state">Henter emne…</div>;
|
||||
if (isError || !topic) return <div className="page-state">Emnet blev ikke fundet. <Link to="/">Tilbage til biblioteket</Link></div>;
|
||||
const content = topic.content;
|
||||
return <article className="topic-page">
|
||||
<Link className="back-link" to="/"><ArrowLeft size={16}/>Bibliotek</Link>
|
||||
<header className="topic-hero">
|
||||
<div><div className="eyebrow"><span></span>{topic.domains.join(' · ')}</div><h1>{topic.title}</h1><p>{topic.summary}</p></div>
|
||||
<dl><div><dt>Type</dt><dd>{topic.type}</dd></div><div><dt>Version</dt><dd>{topic.currentVersion ?? 'Living standard'}</dd></div><div><dt>Revision</dt><dd>#{topic.revisionNumber}</dd></div><div><dt>Verificeret</dt><dd>{new Date(topic.verifiedAt).toLocaleDateString('da-DK')}</dd></div></dl>
|
||||
</header>
|
||||
<Tabs.Root defaultValue="overview" className="topic-tabs">
|
||||
<Tabs.List aria-label="Indholdsniveau"><Tabs.Trigger value="overview">Overblik</Tabs.Trigger><Tabs.Trigger value="deep">Fordybelse</Tabs.Trigger><Tabs.Trigger value="sources">Kilder <span>{topic.sources.length}</span></Tabs.Trigger></Tabs.List>
|
||||
<Tabs.Content value="overview" className="prose-grid">
|
||||
<section className="prose"><h2>What is {topic.title}?</h2><p className="lead">{content.introduction}</p><h2>Core concepts</h2><BulletList values={content.concepts}/><h2>Typical use cases</h2><BulletList values={content.useCases}/></section>
|
||||
<aside><InfoCard icon={<Sparkles/>} title="Strengths" values={content.benefits}/><InfoCard icon={<Shield/>} title="Considerations" values={content.limitations}/></aside>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="deep" className="prose"><h2>Compatibility</h2><p>{content.compatibility}</p><h2>Requirements</h2><BulletList values={content.requirements}/><h2>When not to use it</h2><BulletList values={content.avoidWhen}/><h2>Security notes</h2><BulletList values={content.securityNotes}/>{content.examples.map((example) => <div key={example.title}><h2>{example.title}</h2><pre><code>{example.code}</code></pre></div>)}</Tabs.Content>
|
||||
<Tabs.Content value="sources" className="sources"><div className="source-intro"><GitBranch/><div><h2>Provenance</h2><p>Every source is attached to this exact revision and records what it supports.</p></div></div>{topic.sources.map((source) => <a key={source.id} href={source.url} target="_blank" rel="noreferrer" className="source-card"><div><span>{source.type.replace('_', ' ')}</span><h3>{source.title}</h3><p>{source.publisher} · verified {new Date(source.verifiedAt).toLocaleDateString('da-DK')}</p>{source.claims.map((claim) => <small key={claim}><CheckCircle2 size={13}/>{claim}</small>)}</div><ExternalLink/></a>)}</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</article>;
|
||||
}
|
||||
|
||||
function BulletList({ values }: { values: string[] }) { return <ul>{values.map((value) => <li key={value}>{value}</li>)}</ul>; }
|
||||
function InfoCard({ icon, title, values }: { icon: React.ReactNode; title: string; values: string[] }) { return <div className="info-card"><div className="info-title">{icon}<h3>{title}</h3></div><BulletList values={values}/></div>; }
|
||||
18
apps/web/src/lib/api.ts
Normal file
18
apps/web/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { Paginated, TopicDetail, TopicListItem, TopicRevisionInput } from '@tech-library/contracts';
|
||||
|
||||
const json = async <T>(pending: Response | Promise<Response>): Promise<T> => {
|
||||
const response = await pending;
|
||||
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? `HTTP ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
topics: (params: URLSearchParams) => json<Paginated<TopicListItem>>(fetch(`/api/v1/topics?${params}`)),
|
||||
topic: (slug: string) => json<TopicDetail>(fetch(`/api/v1/topics/${slug}`)),
|
||||
facets: () => json<{ domains: Array<{ value: string; count: number }>; types: Array<{ value: string; count: number }> }>(fetch('/api/v1/facets')),
|
||||
login: (username: string, password: string) => json<{ csrfToken: string }>(fetch('/api/v1/admin/login', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'include', body: JSON.stringify({ username, password }) })),
|
||||
session: () => json<{ csrfToken: string }>(fetch('/api/v1/admin/session', { credentials: 'include' })),
|
||||
findings: () => json<Array<Record<string, string>>>(fetch('/api/v1/admin/findings', { credentials: 'include' })),
|
||||
mutate: (path: string, csrfToken: string, body?: unknown) => json<Record<string, unknown>>(fetch(`/api/v1/admin${path}`, { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json', 'x-csrf-token': csrfToken }, body: body === undefined ? undefined : JSON.stringify(body) })),
|
||||
createDraft: (csrfToken: string, input: TopicRevisionInput) => api.mutate('/revisions', csrfToken, input)
|
||||
};
|
||||
17
apps/web/src/main.tsx
Normal file
17
apps/web/src/main.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
import { AppLayout } from './ui/AppLayout';
|
||||
import { LibraryPage } from './features/library/LibraryPage';
|
||||
import { TopicPage } from './features/topic/TopicPage';
|
||||
import { AdminPage } from './features/admin/AdminPage';
|
||||
import './styles.css';
|
||||
|
||||
const router = createBrowserRouter([{ path: '/', element: <AppLayout />, children: [
|
||||
{ index: true, element: <LibraryPage /> },
|
||||
{ path: 'emne/:slug', element: <TopicPage /> },
|
||||
{ path: 'admin', element: <AdminPage /> }
|
||||
]}]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, retry: 1 } } });
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(<React.StrictMode><QueryClientProvider client={client}><RouterProvider router={router} /></QueryClientProvider></React.StrictMode>);
|
||||
3
apps/web/src/store/ui.ts
Normal file
3
apps/web/src/store/ui.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { create } from 'zustand';
|
||||
interface UiState { domain: string; type: string; setDomain(value: string): void; setType(value: string): void; clear(): void }
|
||||
export const useUiStore = create<UiState>((set) => ({ domain: '', type: '', setDomain: (domain) => set({ domain }), setType: (type) => set({ type }), clear: () => set({ domain: '', type: '' }) }));
|
||||
10
apps/web/src/styles.css
Normal file
10
apps/web/src/styles.css
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@1,6..72,500&display=swap');
|
||||
|
||||
:root{font-family:Manrope,system-ui,sans-serif;color:#17201c;background:#f3f1e9;font-synthesis:none;--ink:#111714;--muted:#68716c;--line:#d8d7ce;--paper:#faf9f4;--acid:#d8ff58;--coral:#f7734f;--green:#264c3b}*{box-sizing:border-box}body{margin:0;min-width:320px}button,input,textarea,select{font:inherit}button,a{transition:.2s ease}a{color:inherit}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}
|
||||
.app-shell{min-height:100vh}.topbar{height:72px;padding:0 clamp(20px,5vw,72px);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);background:rgba(243,241,233,.9);backdrop-filter:blur(12px);position:sticky;top:0;z-index:20}.brand{display:flex;gap:11px;align-items:center;text-decoration:none;font-weight:750}.brand-mark{background:var(--ink);color:var(--acid);width:34px;height:34px;display:grid;place-items:center;font:500 13px DM Mono;border-radius:4px}.topbar nav{display:flex;gap:8px}.topbar nav a{display:flex;align-items:center;gap:7px;text-decoration:none;padding:9px 13px;border-radius:5px;color:var(--muted);font-size:14px}.topbar nav a.active,.topbar nav a:hover{background:#e5e4dc;color:var(--ink)}footer{border-top:1px solid var(--line);padding:28px clamp(20px,5vw,72px);display:flex;justify-content:space-between;color:var(--muted);font-size:13px}footer a{display:flex;gap:7px;text-decoration:none}
|
||||
.hero{background:var(--ink);color:#fff;padding:clamp(62px,9vw,120px) clamp(20px,8vw,120px) 74px;position:relative;overflow:hidden}.hero:after{content:"";position:absolute;width:420px;height:420px;border:1px solid #35433c;border-radius:50%;right:-80px;top:-160px;box-shadow:0 0 0 65px #17201c,0 0 0 66px #35433c,0 0 0 135px #17201c,0 0 0 136px #35433c;opacity:.7}.eyebrow{display:flex;align-items:center;gap:10px;text-transform:uppercase;letter-spacing:.13em;font:500 11px DM Mono;color:#aab5af}.eyebrow span{width:23px;height:2px;background:var(--acid)}.hero h1,.topic-hero h1{font-size:clamp(44px,6vw,82px);line-height:.96;letter-spacing:-.055em;margin:26px 0 25px;max-width:850px}.hero h1 em{font-family:Newsreader,serif;font-weight:500;color:var(--acid)}.hero>p{font-size:clamp(16px,2vw,20px);line-height:1.65;color:#b7c0bb;max-width:710px}.searchbox{display:flex;align-items:center;max-width:760px;margin-top:42px;background:#fff;color:var(--ink);border-radius:6px;padding:0 17px;position:relative;z-index:2;box-shadow:0 12px 35px #0004}.searchbox input{width:100%;border:0;outline:0;padding:19px 14px;background:transparent;font-size:16px}.searchbox kbd{font:12px DM Mono;color:#7c837f;border:1px solid var(--line);padding:4px 7px;border-radius:4px;white-space:nowrap}.hero-meta{display:flex;gap:11px;flex-wrap:wrap;margin-top:22px;color:#8d9b94;font:11px DM Mono;text-transform:uppercase;letter-spacing:.05em}
|
||||
.library-layout{display:grid;grid-template-columns:230px 1fr;gap:55px;padding:58px clamp(20px,5vw,72px) 100px;max-width:1500px;margin:auto}.filter-title{display:flex;justify-content:space-between;align-items:center;font-weight:700;font-size:14px;padding-bottom:18px;border-bottom:1px solid var(--line)}.filter-title span,.filter-title button{display:flex;align-items:center;gap:7px}.filter-title button{border:0;background:none;font-size:11px;color:var(--muted);cursor:pointer}.filters fieldset{border:0;padding:22px 0 3px;margin:0;border-bottom:1px solid var(--line)}.filters legend{text-transform:uppercase;font:500 10px DM Mono;letter-spacing:.1em;margin-bottom:13px}.filters label{display:grid;grid-template-columns:19px 1fr auto;align-items:center;font-size:13px;padding:7px 0;cursor:pointer}.filters input{appearance:none;width:14px;height:14px;border:1px solid #a6aaa7;border-radius:3px;margin:0}.filters input:checked{border:4px solid var(--ink);background:var(--acid)}.filters small{color:#929790}.catalog-header{display:flex;justify-content:space-between;align-items:end;margin-bottom:28px}.kicker{text-transform:uppercase;letter-spacing:.12em;font:500 10px DM Mono;color:var(--coral);margin:0 0 8px}.catalog-header h2{font-size:32px;letter-spacing:-.035em;margin:0}.catalog-header>span{font:11px DM Mono;color:var(--muted)}.topic-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}.topic-card{background:var(--paper);border:1px solid var(--line);padding:22px;min-height:250px;text-decoration:none;display:flex;flex-direction:column;border-radius:4px;animation:rise .45s both;animation-delay:var(--delay)}.topic-card:hover{transform:translateY(-4px);box-shadow:0 18px 36px #25312916;border-color:#a8aea9}.card-top{display:flex;justify-content:space-between}.type-badge{font:500 10px DM Mono;text-transform:uppercase;color:var(--green);background:#e4eee7;padding:5px 7px;border-radius:3px}.topic-card h3{font-size:21px;letter-spacing:-.025em;margin:23px 0 9px}.topic-card p{font-size:13px;line-height:1.55;color:var(--muted);margin:0}.tag-row{margin-top:auto;display:flex;gap:5px;padding-top:18px}.tag-row span{font:10px DM Mono;background:#ebeae3;padding:4px 6px}.verified{font:9px DM Mono;color:#9b9d98;text-transform:uppercase;margin-top:12px}.state-card,.page-state{padding:55px;text-align:center;background:var(--paper);border:1px solid var(--line)}.state-card.error,.form-error{color:#a02e20}@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
|
||||
.topic-page{max-width:1180px;margin:auto;padding:50px 30px 100px}.back-link{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font-size:13px;text-decoration:none}.topic-hero{display:grid;grid-template-columns:1fr 280px;gap:60px;padding:70px 0 50px;border-bottom:1px solid var(--line)}.topic-hero h1{font-size:70px;margin:18px 0;color:var(--ink)}.topic-hero>div>p{font-size:19px;line-height:1.65;color:var(--muted);max-width:730px}.topic-hero dl{margin:0;background:var(--ink);color:white;padding:25px}.topic-hero dl div{padding:12px 0;border-bottom:1px solid #334039}.topic-hero dt{font:9px DM Mono;text-transform:uppercase;color:#8fa097;margin-bottom:6px}.topic-hero dd{margin:0;font-size:13px}.topic-tabs [role=tablist],.admin-tabs>[role=tablist]{display:flex;border-bottom:1px solid var(--line);margin:0 0 40px}.topic-tabs [role=tab],.admin-tabs [role=tab]{border:0;background:none;padding:20px 22px;cursor:pointer;color:var(--muted);border-bottom:2px solid transparent}.topic-tabs [data-state=active],.admin-tabs [data-state=active]{color:var(--ink);border-color:var(--coral)}.topic-tabs [role=tab] span{background:var(--ink);color:white;border-radius:10px;padding:1px 6px;font-size:10px}.prose-grid{display:grid;grid-template-columns:1fr 320px;gap:70px}.prose{max-width:760px}.prose h2,.sources h2{font-size:25px;letter-spacing:-.025em;margin:42px 0 13px}.prose .lead,.prose p{line-height:1.8;color:#4f5a54}.prose ul,.info-card ul{padding-left:20px;line-height:1.7;color:#4f5a54}.prose li{margin:7px 0}.prose pre{overflow:auto;background:var(--ink);color:#d8ff58;padding:22px;border-radius:4px}.info-card{background:#e8e9df;padding:24px;margin-bottom:16px}.info-title{display:flex;align-items:center;gap:9px}.info-title svg{width:18px}.info-title h3{font-size:15px}.info-card li{font-size:13px;margin:8px 0}.source-intro{display:flex;gap:15px;align-items:flex-start;margin-bottom:25px}.source-intro svg{background:var(--acid);padding:9px;width:42px;height:42px}.source-intro h2{margin:0}.source-intro p{color:var(--muted)}.source-card{display:flex;justify-content:space-between;text-decoration:none;padding:24px;border:1px solid var(--line);background:var(--paper);margin-bottom:10px}.source-card span{font:10px DM Mono;text-transform:uppercase;color:var(--coral)}.source-card h3{margin:8px 0}.source-card p{font-size:12px;color:var(--muted)}.source-card small{display:flex;align-items:center;gap:6px;color:var(--green)}
|
||||
.admin-login{min-height:calc(100vh - 145px);display:grid;place-items:center;padding:40px}.login-panel{width:min(450px,100%);background:var(--paper);border:1px solid var(--line);padding:40px}.admin-icon{display:grid;place-items:center;background:var(--ink);color:var(--acid);width:50px;height:50px;margin-bottom:28px}.login-panel h1{font-size:35px;letter-spacing:-.04em;margin:5px 0}.login-panel>p{color:var(--muted);line-height:1.6}.login-panel form,.editor-form{display:grid;gap:16px;margin-top:28px}.login-panel label,.editor-form label{display:grid;gap:7px;font-size:12px;font-weight:600}.login-panel input,.editor-form input,.editor-form textarea,.editor-form select{border:1px solid #bec1bb;background:#fff;padding:12px;outline:0;border-radius:3px}.login-panel input:focus,.editor-form input:focus,.editor-form textarea:focus,.editor-form select:focus{border-color:var(--green);box-shadow:0 0 0 3px #264c3b18}.primary-button,.secondary-button,.finding-actions button{border:0;display:inline-flex;justify-content:center;align-items:center;gap:8px;padding:12px 16px;cursor:pointer;border-radius:3px}.primary-button{background:var(--ink);color:white}.primary-button:hover{background:var(--green)}.admin-page{max-width:1250px;margin:auto;padding:60px 30px 100px}.admin-page>header{display:flex;justify-content:space-between;align-items:end}.admin-page h1{font-size:48px;letter-spacing:-.05em;margin:4px 0}.admin-page header p{color:var(--muted)}.secondary-button{background:#e2e3db}.admin-stats{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin:40px 0}.admin-stats>div{display:flex;gap:14px;align-items:center;border:1px solid var(--line);background:var(--paper);padding:20px}.admin-stats svg{color:var(--coral)}.admin-stats span{display:grid;font-size:11px;color:var(--muted)}.admin-stats strong{font-size:19px;color:var(--ink)}.finding{display:flex;justify-content:space-between;gap:30px;background:var(--paper);border:1px solid var(--line);padding:24px;margin-bottom:10px}.finding h3{margin:10px 0 5px}.finding p{font-size:13px;color:var(--muted);max-width:700px}.finding a{font-size:12px;color:var(--green)}.status{font:10px DM Mono;text-transform:uppercase;padding:4px 7px;background:#e6e6df}.status.pending{background:#fff0be}.status.approved{background:#dcebd8}.status.rejected{background:#f5d9d2}.finding-actions{display:flex;align-items:center;gap:8px}.finding-actions button{font-size:12px}.finding-actions svg{width:14px}.finding-actions .approve{background:var(--green);color:white}.empty-state{text-align:center;padding:60px}.empty-state>svg{background:var(--acid);padding:12px;width:52px;height:52px}.editor-form{max-width:920px;background:var(--paper);border:1px solid var(--line);padding:30px}.editor-heading{display:flex;gap:14px}.editor-heading h2{margin:0}.editor-heading p{margin:5px 0;color:var(--muted)}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}.form-success{background:#dcebd8;padding:12px;color:#24502f}
|
||||
@media(max-width:1000px){.topic-grid{grid-template-columns:repeat(2,1fr)}.prose-grid{grid-template-columns:1fr}.topic-hero{grid-template-columns:1fr}.topic-hero dl{display:grid;grid-template-columns:1fr 1fr}.admin-stats{grid-template-columns:1fr}}
|
||||
@media(max-width:700px){.topbar{height:62px}.topbar nav a{font-size:0}.topbar nav a svg{width:20px;height:20px}.hero{padding-top:60px}.hero:after{opacity:.3}.library-layout{grid-template-columns:1fr;padding-top:30px}.filters{display:none}.topic-grid{grid-template-columns:1fr}.topic-card{min-height:220px}.topic-page{padding:30px 18px 70px}.topic-hero{padding-top:45px}.topic-hero h1{font-size:48px}.topic-tabs [role=tab]{padding:16px 10px;font-size:12px}.admin-page{padding:35px 18px}.admin-page>header,.finding{align-items:flex-start;flex-direction:column}.finding-actions{width:100%}.form-grid{grid-template-columns:1fr}footer{gap:15px;flex-direction:column}}
|
||||
1
apps/web/src/test/setup.ts
Normal file
1
apps/web/src/test/setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom/vitest';
|
||||
16
apps/web/src/ui/AppLayout.tsx
Normal file
16
apps/web/src/ui/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { BookOpen, Github, ShieldCheck } from 'lucide-react';
|
||||
import { Link, NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
export function AppLayout() {
|
||||
return <div className="app-shell">
|
||||
<header className="topbar">
|
||||
<Link className="brand" to="/"><span className="brand-mark">TL</span><span>Tech Library</span></Link>
|
||||
<nav aria-label="Primær navigation">
|
||||
<NavLink to="/" end><BookOpen size={17}/>Bibliotek</NavLink>
|
||||
<NavLink to="/admin"><ShieldCheck size={17}/>Redaktion</NavLink>
|
||||
</nav>
|
||||
</header>
|
||||
<main><Outlet /></main>
|
||||
<footer><span>Levende viden. Dokumenterede kilder.</span><a href="https://git.p20.dev/christian/TechLibrary" target="_blank" rel="noreferrer"><Github size={16}/>Kildekode</a></footer>
|
||||
</div>;
|
||||
}
|
||||
4
apps/web/tsconfig.app.json
Normal file
4
apps/web/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" },
|
||||
"include": ["src"]
|
||||
}
|
||||
1
apps/web/tsconfig.app.tsbuildinfo
Normal file
1
apps/web/tsconfig.app.tsbuildinfo
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"root":["./src/main.tsx","./src/features/admin/adminpage.tsx","./src/features/library/librarypage.test.tsx","./src/features/library/librarypage.tsx","./src/features/topic/topicpage.tsx","./src/lib/api.ts","./src/store/ui.ts","./src/test/setup.ts","./src/ui/applayout.tsx"],"version":"5.9.3"}
|
||||
4
apps/web/tsconfig.json
Normal file
4
apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
4
apps/web/tsconfig.node.json
Normal file
4
apps/web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "Bundler", "allowSyntheticDefaultImports": true },
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
apps/web/tsconfig.node.tsbuildinfo
Normal file
1
apps/web/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
2
apps/web/vite.config.d.ts
vendored
Normal file
2
apps/web/vite.config.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
12
apps/web/vite.config.js
Normal file
12
apps/web/vite.config.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
export default defineConfig({
|
||||
plugins: [react(), VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: { name: 'Tech Library', short_name: 'TechLib', description: 'Et levende opslagsværk om moderne softwareudvikling', theme_color: '#111714', background_color: '#f3f1e9', display: 'standalone', start_url: '/', icons: [{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' }] },
|
||||
workbox: { navigateFallback: '/index.html', runtimeCaching: [] }
|
||||
})],
|
||||
server: { proxy: { '/api': 'http://localhost:3001', '/health': 'http://localhost:3001' } },
|
||||
test: { environment: 'jsdom', setupFiles: './src/test/setup.ts' }
|
||||
});
|
||||
13
apps/web/vite.config.ts
Normal file
13
apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: { name: 'Tech Library', short_name: 'TechLib', description: 'Et levende opslagsværk om moderne softwareudvikling', theme_color: '#111714', background_color: '#f3f1e9', display: 'standalone', start_url: '/', icons: [{ src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any maskable' }] },
|
||||
workbox: { navigateFallback: '/index.html', runtimeCaching: [] }
|
||||
})],
|
||||
server: { proxy: { '/api': 'http://localhost:3001', '/health': 'http://localhost:3001' } },
|
||||
test: { environment: 'jsdom', setupFiles: './src/test/setup.ts' }
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue