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>
287 lines
10 KiB
React
287 lines
10 KiB
React
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>
|
||
)
|
||
}
|