diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..4fdd6cc --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,11 @@ + + + + + + + + Tech Library + +
+ diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..b7bd39e --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/public/icon.svg b/apps/web/public/icon.svg new file mode 100644 index 0000000..10a9f08 --- /dev/null +++ b/apps/web/public/icon.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/features/admin/AdminPage.tsx b/apps/web/src/features/admin/AdminPage.tsx new file mode 100644 index 0000000..d08d011 --- /dev/null +++ b/apps/web/src/features/admin/AdminPage.tsx @@ -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
Kontrollerer session…
; + if (session.isError) return session.refetch()}/>; + if (!session.data) return session.refetch()}/>; + return ; +} + +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

Redaktion

Velkommen tilbage

Log ind for at gennemgå kilder, redigere udkast og publicere verificeret viden.

{error &&

{error}

}
; +} + +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

Kontrolrum

Redaktion

Ingen ændringer bliver publiceret uden et menneskeligt review.

+
{findings.data?.filter((item) => item.status === 'pending').length ?? 0}Afventer review
ManuelPubliceringspolitik
24 timerScanningsinterval
+ KildefundNyt udkast +
{findings.isLoading &&

Henter findings…

}{findings.data?.length === 0 &&

Alt er gennemgået

Der er ingen kildeændringer i kø.

}{findings.data?.map((finding) =>
{finding.status}

{finding.title}

{finding.summary}

Åbn officiel kilde
{finding.status === 'pending' &&
}
)}
+ +
+
; +} + +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) => { + 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

Opret immutable revision

Felter med flere værdier bruger én linje per punkt.

{create.error &&

{create.error.message}

}{message &&

{message}

}; +} +const lines = (value: FormDataEntryValue | null) => String(value ?? '').split('\n').map((item) => item.trim()).filter(Boolean); +function Field(props: React.InputHTMLAttributes & { label: string }) { const { label, ...input } = props; return ; } +function Text(props: React.TextareaHTMLAttributes & { label: string }) { const { label, ...input } = props; return