diff --git a/src/lib/previewApi.js b/src/lib/previewApi.js new file mode 100644 index 0000000..d93f435 --- /dev/null +++ b/src/lib/previewApi.js @@ -0,0 +1,56 @@ +import { account } from './appwrite' + +const PROJECT_ADMIN_URL = + import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com' + +// Wie kindsFetch in projectKindsApi.js: der Fehler trägt .status und .data +// (z. B. 409 { error: 'Preview-Runner ist nicht aktiv' }, 503 bei Überlast). +async function previewFetch(path, options = {}) { + const jwt = await account.createJWT() + const response = await fetch(`${PROJECT_ADMIN_URL}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${jwt.jwt}`, + 'Content-Type': 'application/json', + ...options.headers, + }, + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) { + const error = new Error(data.error || `API-Fehler ${response.status}`) + error.status = response.status + error.data = data + throw error + } + return data +} + +const base = (projectId) => `/api/admin/website-projects/${encodeURIComponent(projectId)}/preview` + +export function fetchPreview(projectId) { + return previewFetch(base(projectId)) +} + +export function setPreviewMode(projectId, mode) { + return previewFetch(base(projectId), { method: 'PATCH', body: JSON.stringify({ mode }) }) +} + +export function savePreviewEnv(projectId, { set = {}, remove = [] } = {}) { + return previewFetch(`${base(projectId)}/env`, { method: 'PUT', body: JSON.stringify({ set, remove }) }) +} + +export function rebuildPreview(projectId) { + return previewFetch(`${base(projectId)}/rebuild`, { method: 'POST' }) +} + +export function startPreviewDev(projectId) { + return previewFetch(`${base(projectId)}/dev/start`, { method: 'POST' }) +} + +export function stopPreviewDev(projectId) { + return previewFetch(`${base(projectId)}/dev/stop`, { method: 'POST' }) +} + +export function fetchPreviewLog(projectId, runFile) { + return previewFetch(`${base(projectId)}/logs/${encodeURIComponent(runFile)}`) +} diff --git a/src/lib/previewView.js b/src/lib/previewView.js new file mode 100644 index 0000000..13a25ed --- /dev/null +++ b/src/lib/previewView.js @@ -0,0 +1,66 @@ +// Anzeige-Logik für den Block "Preview" (Status, Erkennung, Modi) + +export const MODE_OPTIONS = [ + { value: 'auto', label: 'Automatisch' }, + { value: 'build', label: 'Build' }, + { value: 'dev', label: 'Dev-Server' }, + { value: 'off', label: 'Aus' }, +] + +const dateFormat = new Intl.DateTimeFormat('de-DE', { + timeZone: 'Europe/Berlin', + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', +}) + +// "TT.MM. HH:MM" in Europe/Berlin +function formatBerlin(value) { + const d = new Date(value) + if (!value || Number.isNaN(d.getTime())) return '' + const parts = Object.fromEntries(dateFormat.formatToParts(d).map((p) => [p.type, p.value])) + return `${parts.day}.${parts.month}. ${parts.hour}:${parts.minute}` +} + +export function previewStatus(p) { + const state = p?.state || 'off' + switch (state) { + case 'online': { + const at = formatBerlin(p.builtAt) + const sha = p.builtSha ? String(p.builtSha).slice(0, 7) : '' + if (!at) return { color: 'green', text: 'Online' } + return { color: 'green', text: `Online seit ${at}${sha ? ` (Build ${sha})` : ''}` } + } + case 'building': + return { color: 'yellow', text: 'Baut gerade …' } + case 'starting': + return { color: 'yellow', text: 'Dev-Server startet …' } + case 'sleeping': + return { color: 'yellow', text: 'Dev-Server schläft, startet beim nächsten Aufruf' } + case 'failed': + return { color: 'red', text: 'Build fehlgeschlagen – letzte funktionierende Version ist online' } + default: + return { color: 'grey', text: 'Keine Preview' } + } +} + +// 'static' | 'build:' | 'dev:' | 'build::' ⇒ Anzeigetext +export function detectedLabel(detected) { + if (!detected || typeof detected !== 'string') return '' + if (detected === 'static') return 'Statisch' + const [kind, ...rest] = detected.split(':') + const base = kind === 'build' ? 'Build' : kind === 'dev' ? 'Dev-Server' : '' + if (!base || !rest.length || rest.some((x) => !x)) return '' + const pm = rest[rest.length - 1] + const fw = rest.length > 1 ? rest[0] : '' + const fwLabel = fw ? fw.charAt(0).toUpperCase() + fw.slice(1) : '' + return `${base} (${fwLabel ? `${fwLabel}, ` : ''}${pm})` +} + +export function modeHint(mode, detected) { + if ((mode || 'auto') !== 'auto') return '' + const label = detectedLabel(detected) + return label ? `Automatisch → ${label}` : '' +} diff --git a/tests/unit/previewView.test.mjs b/tests/unit/previewView.test.mjs new file mode 100644 index 0000000..c6ede46 --- /dev/null +++ b/tests/unit/previewView.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { previewStatus, detectedLabel, modeHint, MODE_OPTIONS } = await loadModule('src/lib/previewView.js') + +test('previewStatus online: Datum in Europe/Berlin und sha7', () => { + const s = previewStatus({ state: 'online', builtAt: '2026-09-30T13:05:00Z', builtSha: 'abcdef1234567890' }) + assert.deepEqual(s, { color: 'green', text: 'Online seit 30.09. 15:05 (Build abcdef1)' }) +}) + +test('previewStatus online im Winter (MEZ)', () => { + const s = previewStatus({ state: 'online', builtAt: '2026-01-05T08:07:00Z', builtSha: 'abcdef1' }) + assert.equal(s.text, 'Online seit 05.01. 09:07 (Build abcdef1)') +}) + +test('previewStatus online ohne Datum/sha bleibt lesbar', () => { + assert.deepEqual(previewStatus({ state: 'online' }), { color: 'green', text: 'Online' }) +}) + +test('previewStatus weitere Zustände', () => { + assert.deepEqual(previewStatus({ state: 'building' }), { color: 'yellow', text: 'Baut gerade …' }) + assert.deepEqual(previewStatus({ state: 'starting' }), { color: 'yellow', text: 'Dev-Server startet …' }) + assert.deepEqual(previewStatus({ state: 'sleeping' }), { color: 'yellow', text: 'Dev-Server schläft, startet beim nächsten Aufruf' }) + assert.deepEqual(previewStatus({ state: 'failed' }), { color: 'red', text: 'Build fehlgeschlagen – letzte funktionierende Version ist online' }) + assert.deepEqual(previewStatus({ state: 'off' }), { color: 'grey', text: 'Keine Preview' }) +}) + +test('previewStatus leer/unbekannt/null ⇒ grau', () => { + const grey = { color: 'grey', text: 'Keine Preview' } + assert.deepEqual(previewStatus({ state: '' }), grey) + assert.deepEqual(previewStatus({}), grey) + assert.deepEqual(previewStatus(null), grey) + assert.deepEqual(previewStatus({ state: 'xyz' }), grey) +}) + +test('detectedLabel', () => { + assert.equal(detectedLabel('static'), 'Statisch') + assert.equal(detectedLabel('build:npm'), 'Build (npm)') + assert.equal(detectedLabel('dev:pnpm'), 'Dev-Server (pnpm)') + assert.equal(detectedLabel('build:vite:npm'), 'Build (Vite, npm)') + assert.equal(detectedLabel('dev:npm'), 'Dev-Server (npm)') + assert.equal(detectedLabel(''), '') + assert.equal(detectedLabel(null), '') + assert.equal(detectedLabel('foo:npm'), '') +}) + +test('modeHint', () => { + assert.equal(modeHint('auto', 'build:npm'), 'Automatisch → Build (npm)') + assert.equal(modeHint('auto', 'static'), 'Automatisch → Statisch') + assert.equal(modeHint('', 'dev:pnpm'), 'Automatisch → Dev-Server (pnpm)') + assert.equal(modeHint('auto', ''), '') + assert.equal(modeHint('build', 'build:npm'), '') +}) + +test('MODE_OPTIONS', () => { + assert.deepEqual(MODE_OPTIONS, [ + { value: 'auto', label: 'Automatisch' }, + { value: 'build', label: 'Build' }, + { value: 'dev', label: 'Dev-Server' }, + { value: 'off', label: 'Aus' }, + ]) +})