diff --git a/src/components/PreviewBlock.jsx b/src/components/PreviewBlock.jsx
new file mode 100644
index 0000000..44db794
--- /dev/null
+++ b/src/components/PreviewBlock.jsx
@@ -0,0 +1,270 @@
+import { useState, useEffect, useCallback, useRef } from 'react'
+import { MODE_OPTIONS, previewStatus, modeHint } from '../lib/previewView'
+import { isValidEnvName, buildEnvPayload, isDevPreview } from '../lib/previewEnv'
+import {
+ fetchPreview,
+ setPreviewMode,
+ savePreviewEnv,
+ rebuildPreview,
+ startPreviewDev,
+ stopPreviewDev,
+ fetchPreviewLog,
+} from '../lib/previewApi'
+
+const DOT = { green: '#10b981', yellow: '#f59e0b', red: '#ef4444', grey: '#a0aec0' }
+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)
+ } 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
+ run(async () => {
+ const r = await fetchPreviewLog(projectId, runFile)
+ setLogText(Array.isArray(r.lines) ? r.lines.join('\n') : String(r.log ?? r.text ?? ''))
+ })
+ }
+
+ 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
+ }
+ 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
+ }
+ setEnvError('')
+ addPending(editName, editValue)
+ setEditName('')
+ setEditValue('')
+ }
+
+ const handleSaveEnv = () => run(async () => {
+ await savePreviewEnv(projectId, buildEnvPayload(pendingSet, pendingRemove))
+ setPendingSet({})
+ setPendingRemove([])
+ })
+
+ if (loading) return
Lädt …
+
+ 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 || []).slice(0, 5)
+
+ const small = { fontSize: 12 }
+
+ return (
+
+ {data && (
+ <>
+
+
+
+
+ {hint &&
{hint}
}
+
+
+ {status.text}
+
+ {data.state === 'failed' && data.error && (
+
{data.error}
+ )}
+ >
+ )}
+
+ {inactive &&
{NOT_ACTIVE}.
}
+ {error &&
{error}
}
+
+ {data && !inactive && (
+ <>
+
+
+
+ {dev && (
+ <>
+
+
+ >
+ )}
+
+
+ {showLogs && (
+
+
+ {logFile && (
+
+ {logText || '…'}
+
+ )}
+
+ )}
+
+
+
Variablen
+
+ {names.length === 0 &&
Keine Variablen.
}
+ {names.map((n) => (
+
+ {n}
+ •••
+ {editName === n ? (
+ <>
+ setEditValue(e.target.value)} />
+
+ >
+ ) : (
+
+ )}
+
+
+ ))}
+
+ setNewName(e.target.value)} />
+ setNewValue(e.target.value)} />
+
+
+ {envError &&
{envError}
}
+ {dirty && (
+
+
+
+ )}
+
Alles mit VITE_ / NEXT_PUBLIC_ ist später im Browser sichtbar.
+
+
+ >
+ )}
+
+ )
+}
diff --git a/src/lib/previewEnv.js b/src/lib/previewEnv.js
new file mode 100644
index 0000000..11d8a02
--- /dev/null
+++ b/src/lib/previewEnv.js
@@ -0,0 +1,24 @@
+// Hilfen für den Preview-Block (Variablen, Status-Punkt, Dev-Erkennung)
+
+export function isValidEnvName(name) {
+ return /^[A-Z_][A-Z0-9_]*$/.test(String(name ?? ''))
+}
+
+// Änderungen ⇒ Body für PUT /preview/env; entfernte Namen werden nicht zugleich gesetzt
+export function buildEnvPayload(setMap = {}, removeList = []) {
+ const remove = [...new Set(removeList)]
+ const set = {}
+ for (const [k, v] of Object.entries(setMap)) if (!remove.includes(k)) set[k] = v
+ return { set, remove }
+}
+
+const DOT_COLORS = { online: '#10b981', building: '#f59e0b', sleeping: '#f59e0b', starting: '#f59e0b', failed: '#ef4444' }
+export function dotColor(state) {
+ return DOT_COLORS[state] || '#a0aec0'
+}
+
+export function isDevPreview(p) {
+ if (!p) return false
+ if (p.mode === 'dev') return true
+ return (p.mode || 'auto') === 'auto' && typeof p.detected === 'string' && p.detected.startsWith('dev:')
+}
diff --git a/src/pages/ProjectsPage.jsx b/src/pages/ProjectsPage.jsx
index 506722a..01e5b86 100644
--- a/src/pages/ProjectsPage.jsx
+++ b/src/pages/ProjectsPage.jsx
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
+import PreviewBlock from '../components/PreviewBlock'
import {
FaFolder,
FaPlus,
@@ -734,6 +735,13 @@ export default function ProjectsPage() {
)}
+ {editForm.projectType !== 'project' && (
+