Files
tickte-system/src/components/PreviewBlock.jsx
T
ClaudeandClaude Sonnet 5.5 154aea44a8 Projekte: Preview-Block ohne Kleinknoepfe, Statuspunkt als Badge, Log auf hellem Grund
Markup auf Bausteine (Button, Badge) und die Klassen pv-*, input, select, msg, link umgestellt; keine Inline-Stile, kein btn-sm, kein Farbliteral. Fehlertexte des Runners behalten ihren Zeilenumbruch ueber .pv-block .msg.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:34:29 +00:00

287 lines
10 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from 'react'
import { modeOptionsFor, previewStatus, modeHint, isDevUnavailableError, NO_DEV_HINT } from '../lib/previewView'
import { isValidEnvName, buildEnvPayload, isDevPreview, envValueTooLong, MAX_ENV_VARS } from '../lib/previewEnv'
import {
fetchPreview,
setPreviewMode,
savePreviewEnv,
rebuildPreview,
startPreviewDev,
stopPreviewDev,
fetchPreviewLog,
} from '../lib/previewApi'
import { Button, Badge } from './ui'
import { previewColorTone } from './projectTones'
const NOT_ACTIVE = 'Preview-Runner ist nicht aktiv'
const logDate = (at) => {
const d = new Date(at)
if (!at || Number.isNaN(d.getTime())) return String(at || '')
return d.toLocaleString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
export default function PreviewBlock({ projectId }) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [inactive, setInactive] = useState(false)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const [logText, setLogText] = useState('')
const [logFile, setLogFile] = useState('')
const [showLogs, setShowLogs] = useState(false)
// Variablen: Änderungen werden gesammelt und mit "Variablen speichern" übertragen
const [pendingSet, setPendingSet] = useState({})
const [pendingRemove, setPendingRemove] = useState([])
const [newName, setNewName] = useState('')
const [newValue, setNewValue] = useState('')
const [editName, setEditName] = useState('')
const [editValue, setEditValue] = useState('')
const [envError, setEnvError] = useState('')
const alive = useRef(true)
useEffect(() => {
alive.current = true
return () => { alive.current = false }
}, [])
const handleError = useCallback((err) => {
if (!alive.current) return
if (err?.status === 409 && err.message === NOT_ACTIVE) {
setInactive(true)
return
}
setError(err?.message || 'Unbekannter Fehler')
}, [])
const load = useCallback(async () => {
try {
const r = await fetchPreview(projectId)
if (!alive.current) return
setData(r)
setInactive(false)
setError('')
} catch (err) {
handleError(err)
} finally {
if (alive.current) setLoading(false)
}
}, [projectId, handleError])
useEffect(() => { load() }, [load])
// Solange gebaut/gestartet wird: alle 5 s nachladen
const state = data?.state
useEffect(() => {
if (state !== 'building' && state !== 'starting') return undefined
const t = setInterval(load, 5000)
return () => clearInterval(t)
}, [state, load])
const run = async (fn) => {
setBusy(true)
setError('')
try {
await fn()
await load()
} catch (err) {
handleError(err)
} finally {
if (alive.current) setBusy(false)
}
}
const openLog = (runFile) => {
setLogFile(runFile)
setLogText('')
if (!runFile) return
setBusy(true)
setError('')
fetchPreviewLog(projectId, runFile)
.then((r) => {
if (alive.current) setLogText(Array.isArray(r.lines) ? r.lines.join('\n') : String(r.log ?? r.text ?? ''))
})
.catch((err) => {
handleError(err)
if (alive.current) setLogText(`Fehler: ${err?.message || 'Protokoll konnte nicht geladen werden'}`)
})
.finally(() => { if (alive.current) setBusy(false) })
}
const addPending = (name, value) => {
setPendingSet((p) => ({ ...p, [name]: value }))
setPendingRemove((r) => r.filter((x) => x !== name))
}
const handleAdd = () => {
if (!isValidEnvName(newName)) {
setEnvError('Name nur mit A–Z, 0–9 und _ (nicht mit einer Ziffer beginnen).')
return
}
if (!newValue) {
setEnvError('Bitte einen Wert eingeben.')
return
}
if (envValueTooLong(newValue)) {
setEnvError('Der Wert ist zu lang (max. 4096 Byte).')
return
}
if (!names.includes(newName) && names.length >= MAX_ENV_VARS) {
setEnvError(`Maximal ${MAX_ENV_VARS} Variablen möglich.`)
return
}
setEnvError('')
addPending(newName, newValue)
setNewName('')
setNewValue('')
}
const handleRemove = (name) => {
setPendingSet((p) => {
const { [name]: _drop, ...rest } = p
return rest
})
if ((data?.envNames || []).includes(name)) setPendingRemove((r) => [...new Set([...r, name])])
}
const handleChange = () => {
if (!editValue) {
setEnvError('Bitte einen neuen Wert eingeben.')
return
}
if (envValueTooLong(editValue)) {
setEnvError('Der Wert ist zu lang (max. 4096 Byte).')
return
}
setEnvError('')
addPending(editName, editValue)
setEditName('')
setEditValue('')
}
const handleSaveEnv = () => run(async () => {
await savePreviewEnv(projectId, buildEnvPayload(pendingSet, pendingRemove))
setPendingSet({})
setPendingRemove([])
})
const enter = (fn) => (e) => {
if (e.key === 'Enter') {
e.preventDefault()
fn()
}
}
if (loading) return <p className="muted xs">Lädt …</p>
const view = data ? { ...data } : { state: 'off' }
const status = previewStatus(view)
const mode = data?.mode || 'auto'
const hint = modeHint(mode, data?.detected)
const names = [...new Set([...(data?.envNames || []), ...Object.keys(pendingSet)])].filter((n) => !pendingRemove.includes(n))
const dirty = Object.keys(pendingSet).length > 0 || pendingRemove.length > 0
const dev = isDevPreview(data)
const logs = [...(data?.logs || [])].sort((a, b) => new Date(b.at) - new Date(a.at)).slice(0, 5)
return (
<div className="pv-block">
{data && (
<>
<div className="pv-row">
<label className="muted xs" htmlFor={`pv-mode-${projectId}`}>Modus</label>
<select
id={`pv-mode-${projectId}`}
className="select pv-mode"
value={mode}
disabled={busy || inactive}
onChange={(e) => run(() => setPreviewMode(projectId, e.target.value))}
>
{modeOptionsFor(data.devAvailable).map((o) => <option key={o.value} value={o.value} disabled={o.disabled}>{o.label}</option>)}
</select>
</div>
{data.devAvailable === false && <p className="muted xs">{NO_DEV_HINT}</p>}
{hint && <p className="muted xs">{hint}</p>}
<div className="pv-row">
<Badge tone={previewColorTone(status.color)} className="wrap">{status.text}</Badge>
</div>
{data.state === 'failed' && data.error && !isDevUnavailableError(data.error) && (
<p className="msg err">{data.error}</p>
)}
</>
)}
{inactive && <p className="muted xs">{NOT_ACTIVE}.</p>}
{error && <p className="msg err" role="alert">{error}</p>}
{data && !inactive && (
<>
<div className="pv-row">
<Button type="button" disabled={busy || state === 'building'} onClick={() => run(() => rebuildPreview(projectId))}>
Neu bauen
</Button>
<Button type="button" disabled={busy || logs.length === 0} onClick={() => setShowLogs((v) => !v)}>
Protokoll ansehen
</Button>
{dev && (
<>
<Button type="button" disabled={busy} onClick={() => run(() => startPreviewDev(projectId))}>
Jetzt starten
</Button>
<Button type="button" disabled={busy} onClick={() => run(() => stopPreviewDev(projectId))}>
Stoppen
</Button>
</>
)}
</div>
{showLogs && (
<>
<select
className="select"
aria-label="Lauf wählen"
value={logFile}
disabled={busy}
onChange={(e) => openLog(e.target.value)}
>
<option value="">Lauf wählen …</option>
{logs.map((l) => <option key={l.runFile} value={l.runFile}>{logDate(l.at)}</option>)}
</select>
{logFile && <pre className="pv-log">{logText || '…'}</pre>}
</>
)}
<h4 className="pv-title">Variablen</h4>
{names.length === 0 && <p className="muted xs">Keine Variablen.</p>}
{names.map((n) => (
<div key={n} className="pv-var">
<code>{n}</code>
<span className="muted">•••</span>
{editName === n ? (
<>
<input className="input" type="password" autoComplete="off" placeholder="Neuer Wert" value={editValue} onKeyDown={enter(handleChange)} onChange={(e) => setEditValue(e.target.value)} />
<button type="button" className="link" onClick={handleChange}>Übernehmen</button>
</>
) : (
<button type="button" className="link" disabled={busy} onClick={() => { setEditName(n); setEditValue(''); setEnvError('') }}>Ändern</button>
)}
<button type="button" className="link" disabled={busy} onClick={() => handleRemove(n)}>Entfernen</button>
</div>
))}
<div className="pv-add">
<input type="text" className="input" placeholder="NAME" onKeyDown={enter(handleAdd)} value={newName} onChange={(e) => setNewName(e.target.value)} />
<input type="password" autoComplete="off" className="input" placeholder="Wert" onKeyDown={enter(handleAdd)} value={newValue} onChange={(e) => setNewValue(e.target.value)} />
<Button type="button" disabled={busy} onClick={handleAdd}>Hinzufügen</Button>
</div>
{envError && <p className="msg err">{envError}</p>}
{dirty && (
<div className="pv-row">
<Button type="button" disabled={busy} onClick={handleSaveEnv}>Variablen speichern</Button>
</div>
)}
<p className="muted xs">Alles mit VITE_ / NEXT_PUBLIC_ ist später im Browser sichtbar.</p>
</>
)}
</div>
)
}