680 lines
20 KiB
Vue
680 lines
20 KiB
Vue
<script setup>
|
|
import { ref, computed, watch, onMounted } from 'vue'
|
|
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
|
|
import { usePolling } from './composables/usePolling.js'
|
|
import TopicSidebar from './components/TopicSidebar.vue'
|
|
import TopicDetail from './components/TopicDetail.vue'
|
|
import BlocksOverview from './components/BlocksOverview.vue'
|
|
import GenerationView from './components/GenerationView.vue'
|
|
import GeneralExamPanel from './components/GeneralExamPanel.vue'
|
|
import PracticePanel from './components/PracticePanel.vue'
|
|
|
|
const guides = ref([])
|
|
const backendTopics = ref([])
|
|
const selectedTopic = ref(null)
|
|
const previewGuide = ref(null)
|
|
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
|
|
const sidebarSticky = ref(false)
|
|
const focusOpen = ref(false) // block fullscreen active → lift sidebar as overlay above the focus overlay
|
|
const darkMode = ref(
|
|
localStorage.getItem('darkMode') === null
|
|
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
: localStorage.getItem('darkMode') === 'true',
|
|
)
|
|
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false }
|
|
const blocks = ref({ ...EMPTY_BLOCKS })
|
|
const activeBlocks = ref([])
|
|
const provider = ref(localStorage.getItem('provider') || 'claude')
|
|
const providers = ref([])
|
|
const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker
|
|
const mainView = ref('blocks') // blocks | generation | general | practice | detail — exclusive main-area view
|
|
const guideBoardFormat = ref('Guide')
|
|
const viewMode = ref('compact') // compact | erklärend — per topic, default compact
|
|
const _storedLevel = localStorage.getItem('level')
|
|
const levelView = ref(_storedLevel === 'auto' || !_storedLevel ? 'auto' : Number(_storedLevel) || 'auto') // 'auto' | 1-4
|
|
const stats = ref(null)
|
|
const progress = ref({})
|
|
const uiError = ref(null) // surface rejected actions (409/400)
|
|
|
|
// Run a loader, log + swallow its error (a failed background load must not break the UI).
|
|
async function guard(label, fn) {
|
|
try { return await fn() } catch (e) { console.error(label, e) }
|
|
}
|
|
|
|
// Aktion ausführen und einen Backend-Fehler (409/400/500) in der Sidebar-Fehlerzeile
|
|
// zeigen statt als unhandled rejection zu verschlucken. → true bei Erfolg.
|
|
async function withUiError(fn) {
|
|
uiError.value = null
|
|
try { await fn(); return true } catch (e) { uiError.value = e.message; return false }
|
|
}
|
|
|
|
async function loadStats() {
|
|
await guard('Failed to load stats:', async () => { stats.value = await fetchStats() })
|
|
}
|
|
|
|
function setProvider(id) {
|
|
provider.value = id
|
|
localStorage.setItem('provider', id)
|
|
}
|
|
|
|
async function loadFolders() {
|
|
await guard('Failed to load folders:', async () => {
|
|
const [projekt, uni] = await Promise.all([fetchFolders('projekt'), fetchFolders('uni')])
|
|
folders.value = { projekt, uni }
|
|
})
|
|
}
|
|
|
|
async function loadProviders() {
|
|
await guard('Failed to load providers:', async () => {
|
|
providers.value = await fetchProviders()
|
|
const current = providers.value.find((p) => p.id === provider.value)
|
|
if (current && !current.available) {
|
|
const fallback = providers.value.find((p) => p.available)
|
|
if (fallback) setProvider(fallback.id)
|
|
}
|
|
})
|
|
}
|
|
|
|
function applyTheme() {
|
|
document.documentElement.classList.toggle('dark', darkMode.value)
|
|
}
|
|
|
|
function toggleDark() {
|
|
darkMode.value = !darkMode.value
|
|
localStorage.setItem('darkMode', darkMode.value ? 'true' : 'false')
|
|
applyTheme()
|
|
}
|
|
|
|
applyTheme()
|
|
|
|
function toggleSidebarPin() {
|
|
sidebarPinned.value = !sidebarPinned.value
|
|
localStorage.setItem('sidebarPinned', sidebarPinned.value ? 'true' : 'false')
|
|
if (sidebarPinned.value) sidebarSticky.value = false
|
|
}
|
|
|
|
function clickHoverZone() {
|
|
sidebarSticky.value = !sidebarSticky.value
|
|
}
|
|
|
|
function onSidebarLeave() {
|
|
if (!sidebarPinned.value) sidebarSticky.value = false
|
|
}
|
|
|
|
const topics = computed(() => backendTopics.value)
|
|
|
|
const doneByFormat = computed(() => {
|
|
const map = {}
|
|
for (const g of guides.value) {
|
|
if (g.topic !== selectedTopic.value) continue
|
|
if (g.status !== 'done') continue
|
|
if (!map[g.format] || g.created_at > map[g.format].created_at) {
|
|
map[g.format] = g
|
|
}
|
|
}
|
|
return map
|
|
})
|
|
|
|
const latestByFormat = computed(() => {
|
|
const map = {}
|
|
for (const g of guides.value) {
|
|
if (g.topic !== selectedTopic.value) continue
|
|
if (!map[g.format] || g.created_at > map[g.format].created_at) {
|
|
map[g.format] = g
|
|
}
|
|
}
|
|
return map
|
|
})
|
|
|
|
const hasActiveGuides = computed(() =>
|
|
guides.value.some((g) => g.status === 'queued' || g.status === 'generating'),
|
|
)
|
|
|
|
async function loadTopics() {
|
|
try {
|
|
backendTopics.value = await fetchTopics()
|
|
} catch (e) {
|
|
console.error('Failed to load topics:', e)
|
|
}
|
|
}
|
|
|
|
// Dismissed errors stay dismissed — even across reloads (localStorage).
|
|
// Errors that aren't dismissed stay visible until the user closes them.
|
|
const dismissedErrors = ref(new Set(JSON.parse(localStorage.getItem('dismissedErrors') || '[]')))
|
|
|
|
function persistDismissed() {
|
|
localStorage.setItem('dismissedErrors', JSON.stringify([...dismissedErrors.value]))
|
|
}
|
|
|
|
function handleDismissError(guideId) {
|
|
dismissedErrors.value = new Set([...dismissedErrors.value, guideId])
|
|
persistDismissed()
|
|
}
|
|
|
|
async function loadGuides() {
|
|
try {
|
|
guides.value = await fetchGuides()
|
|
// Prune IDs whose guide no longer exists as an error
|
|
const errorIds = new Set(guides.value.filter((g) => g.status === 'error').map((g) => g.id))
|
|
if ([...dismissedErrors.value].some((id) => !errorIds.has(id))) {
|
|
dismissedErrors.value = new Set([...dismissedErrors.value].filter((id) => errorIds.has(id)))
|
|
persistDismissed()
|
|
}
|
|
loadStats()
|
|
} catch (e) {
|
|
console.error('Failed to load:', e)
|
|
}
|
|
}
|
|
|
|
async function loadBlocks() {
|
|
try {
|
|
activeBlocks.value = await fetchActiveBlocks()
|
|
if (selectedTopic.value) {
|
|
blocks.value = await fetchBlocksStatus(selectedTopic.value)
|
|
progress.value = await fetchTopicProgress(selectedTopic.value)
|
|
} else {
|
|
blocks.value = { ...EMPTY_BLOCKS }
|
|
progress.value = {}
|
|
}
|
|
if (activeBlocks.value.length && !polling.running()) startPolling()
|
|
} catch (e) {
|
|
console.error('Failed to load blocks:', e)
|
|
}
|
|
}
|
|
|
|
// „Auto"-Ebenen-Flags pro Topic (localStorage): nach der QA „Befunde beheben" bis 100 %.
|
|
const autoInventory = ref(true)
|
|
const autoArtefacts = ref(true)
|
|
const autoGuide = ref(true)
|
|
function ladeAuto(topic) {
|
|
const g = (e) => { const v = localStorage.getItem(`auto_${topic}_${e}`); return v === null ? true : v === 'true' }
|
|
autoInventory.value = g('inventory')
|
|
autoArtefacts.value = g('artefacts')
|
|
autoGuide.value = g('guide')
|
|
}
|
|
function handleSetAuto({ ebene, value }) {
|
|
const r = { inventory: autoInventory, artefacts: autoArtefacts, guide: autoGuide }[ebene]
|
|
if (!r) return
|
|
r.value = value
|
|
if (selectedTopic.value) localStorage.setItem(`auto_${selectedTopic.value}_${ebene}`, value ? 'true' : 'false')
|
|
}
|
|
const autoObj = () => ({ inventory: autoInventory.value, artefacts: autoArtefacts.value, guide: autoGuide.value })
|
|
|
|
function selectTopic(topic) {
|
|
selectedTopic.value = topic
|
|
previewGuide.value = null
|
|
sidebarSticky.value = false
|
|
mainView.value = 'generation' // topic click → generation board (guide only on pill click)
|
|
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
|
|
ladeAuto(topic)
|
|
localStorage.setItem('lastTopic', topic)
|
|
loadBlocks()
|
|
}
|
|
|
|
// On reload, land where you were before (topic + format)
|
|
watch(previewGuide, (g) => {
|
|
if (g) localStorage.setItem('lastFormat', g.format)
|
|
})
|
|
|
|
async function handleCancelBlocks() {
|
|
if (!selectedTopic.value) return
|
|
if (await withUiError(() => apiCancelBausteine(selectedTopic.value))) await loadBlocks()
|
|
}
|
|
|
|
async function handleResetBlocks() {
|
|
if (!selectedTopic.value) return
|
|
if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks()
|
|
}
|
|
|
|
async function handleAddResearch() {
|
|
if (!selectedTopic.value) return
|
|
uiError.value = null
|
|
try {
|
|
await apiAddResearch(selectedTopic.value, provider.value)
|
|
} catch (e) {
|
|
uiError.value = e.message
|
|
return
|
|
}
|
|
await loadBlocks()
|
|
startPolling()
|
|
}
|
|
|
|
async function handleRequeueDead() {
|
|
if (!selectedTopic.value) return
|
|
const ok = await withUiError(async () => {
|
|
await apiRequeueDead(selectedTopic.value)
|
|
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false, false, autoObj())
|
|
})
|
|
if (!ok) return
|
|
await loadBlocks()
|
|
startPolling()
|
|
}
|
|
|
|
async function handleBlocksClick({ instructions = '', research = true, qaForce = false, onlyArtefacts = false }) {
|
|
if (!selectedTopic.value) return
|
|
uiError.value = null
|
|
try {
|
|
// research=true = Start/mehr Research anhängen; false = Continue (Queue abarbeiten).
|
|
// onlyArtefacts=true = nur Board 2 auf dem fertigen Inventar (kein Board 1/QA-Gate).
|
|
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce, autoObj(), onlyArtefacts)
|
|
} catch (e) {
|
|
uiError.value = e.message
|
|
return
|
|
}
|
|
await loadBlocks()
|
|
startPolling()
|
|
}
|
|
|
|
async function handleCreateTopic({ topic, instructions, sourceType, sourceOrt }) {
|
|
uiError.value = null
|
|
try {
|
|
await apiCreateBausteine(topic, instructions, provider.value, sourceType, sourceOrt)
|
|
} catch (e) {
|
|
uiError.value = e.message
|
|
return
|
|
}
|
|
await loadTopics()
|
|
selectTopic(topic)
|
|
mainView.value = 'generation' // frisches Topic: die Boards sind das Einzige, was passiert
|
|
startPolling()
|
|
}
|
|
|
|
async function handleUpdateSource({ topic, type, ort, spec }) {
|
|
uiError.value = null
|
|
try {
|
|
await apiUpdateQuelle(topic, { type, ort, spec })
|
|
} catch (e) {
|
|
uiError.value = e.message
|
|
return
|
|
}
|
|
await loadBlocks() // the step display may follow the new source (e.g. link → "load source")
|
|
}
|
|
|
|
function setView(modus) {
|
|
viewMode.value = modus
|
|
if (selectedTopic.value) localStorage.setItem('ansicht_' + selectedTopic.value, modus)
|
|
}
|
|
|
|
function setLevel(k) {
|
|
levelView.value = k
|
|
localStorage.setItem('level', String(k))
|
|
}
|
|
|
|
function handleOpenBlocksView() {
|
|
if (!selectedTopic.value) return
|
|
mainView.value = 'blocks'
|
|
previewGuide.value = null
|
|
}
|
|
|
|
async function handleFormatClick({ format, instructions = '' }) {
|
|
if (!selectedTopic.value) return
|
|
// No duplicate start: if a generation is already running for topic+format, ignore
|
|
const running = guides.value.some(
|
|
(g) => g.topic === selectedTopic.value && g.format === format
|
|
&& (g.status === 'generating' || g.status === 'queued'),
|
|
)
|
|
if (running) { handleOpenGuideBoard(format); return }
|
|
uiError.value = null
|
|
try {
|
|
await apiCreate(selectedTopic.value, format, instructions, provider.value)
|
|
} catch (e) {
|
|
uiError.value = e.message
|
|
return
|
|
}
|
|
handleOpenGuideBoard(format) // Start → direkt aufs Live-Board
|
|
await loadGuides()
|
|
startPolling()
|
|
}
|
|
|
|
function handleOpenGuideBoard(format = 'Guide') {
|
|
if (!selectedTopic.value) return
|
|
guideBoardFormat.value = format
|
|
mainView.value = 'generation'
|
|
previewGuide.value = null
|
|
}
|
|
|
|
function handleOpenGeneration() {
|
|
if (!selectedTopic.value) return
|
|
mainView.value = 'generation'
|
|
previewGuide.value = null
|
|
}
|
|
|
|
async function handleRemoveGuideFormat(format) {
|
|
uiError.value = null
|
|
try {
|
|
await apiRemoveGuideFormat(selectedTopic.value, format)
|
|
} catch (e) {
|
|
uiError.value = e.message
|
|
return
|
|
}
|
|
await loadGuides()
|
|
}
|
|
|
|
function handleGuideBoardPreview() {
|
|
const g = doneByFormat.value[guideBoardFormat.value]
|
|
if (g) handlePreview(g)
|
|
}
|
|
|
|
function handlePreview(guide) {
|
|
previewGuide.value = guide
|
|
mainView.value = 'detail'
|
|
}
|
|
|
|
function handleGeneralExam() {
|
|
if (!selectedTopic.value) return
|
|
mainView.value = 'general'
|
|
previewGuide.value = null
|
|
}
|
|
|
|
function handlePractice() {
|
|
if (!selectedTopic.value) return
|
|
mainView.value = 'practice'
|
|
previewGuide.value = null
|
|
}
|
|
|
|
async function handleDeleteGuide(guideId, slots = false) {
|
|
uiError.value = null
|
|
try {
|
|
await deleteGuide(guideId, slots)
|
|
} catch (e) {
|
|
uiError.value = e.message
|
|
return
|
|
}
|
|
if (previewGuide.value?.id === guideId) {
|
|
previewGuide.value = null
|
|
}
|
|
await loadGuides()
|
|
}
|
|
|
|
const polling = usePolling(
|
|
() => Promise.all([loadGuides(), loadBlocks(), loadTopics()]),
|
|
() => hasActiveGuides.value || activeBlocks.value.length > 0,
|
|
)
|
|
const startPolling = polling.start
|
|
|
|
async function handleCancel(guideId) {
|
|
if (await withUiError(() => apiCancel(guideId))) await loadGuides()
|
|
}
|
|
|
|
async function handleDeleteTopic(topic) {
|
|
// Das Backend löscht Guides/Board/Kanban selbst und wehrt laufende Generierungen ab (409).
|
|
if (!await withUiError(() => apiDeleteTopic(topic))) return
|
|
await loadTopics()
|
|
if (selectedTopic.value === topic) {
|
|
selectedTopic.value = null
|
|
previewGuide.value = null
|
|
}
|
|
await loadGuides()
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([loadGuides(), loadTopics(), loadProviders(), loadFolders()])
|
|
const savedTopic = localStorage.getItem('lastTopic')
|
|
if (savedTopic && topics.value.includes(savedTopic)) {
|
|
selectTopic(savedTopic) // lands on the blocks overview
|
|
} else if (!selectedTopic.value && topics.value.length) {
|
|
selectTopic(topics.value[0])
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="layout" :class="{ 'sidebar-floating': !sidebarPinned, 'sidebar-open': sidebarSticky, 'sidebar-over-fokus': focusOpen && sidebarSticky }">
|
|
<div v-if="!sidebarPinned" class="hover-zone" @click="clickHoverZone"></div>
|
|
<div v-if="(!sidebarPinned && sidebarSticky) || (focusOpen && sidebarSticky)" class="sidebar-backdrop" @click="sidebarSticky = false"></div>
|
|
<TopicSidebar
|
|
:topics="topics"
|
|
:selectedTopic="selectedTopic"
|
|
:stats="stats"
|
|
:fortschritt="progress"
|
|
:uiError="uiError"
|
|
:doneByFormat="doneByFormat"
|
|
:latestByFormat="latestByFormat"
|
|
:allGuides="guides"
|
|
:dismissedErrors="dismissedErrors"
|
|
:blocks="blocks"
|
|
:activeBausteine="activeBlocks"
|
|
:pinned="sidebarPinned"
|
|
:dark="darkMode"
|
|
:provider="provider"
|
|
:providers="providers"
|
|
:folders="folders"
|
|
:ansichtModus="viewMode"
|
|
:stufeAnsicht="levelView"
|
|
@setProvider="setProvider"
|
|
@toggleDark="toggleDark"
|
|
@setAnsicht="setView"
|
|
@setStufe="setLevel"
|
|
@generalExam="handleGeneralExam"
|
|
@practice="handlePractice"
|
|
@select="selectTopic"
|
|
@createThema="handleCreateTopic"
|
|
@updateSource="handleUpdateSource"
|
|
@openBausteineView="handleOpenBlocksView"
|
|
@openGuideBoard="handleOpenGuideBoard"
|
|
@bausteineClick="handleBlocksClick"
|
|
@deleteTopic="handleDeleteTopic"
|
|
@dismissError="handleDismissError"
|
|
@dismissUiError="uiError = null"
|
|
@preview="handlePreview"
|
|
@openGeneration="handleOpenGeneration"
|
|
@togglePin="toggleSidebarPin"
|
|
@sidebarLeave="onSidebarLeave"
|
|
/>
|
|
<BlocksOverview
|
|
v-if="selectedTopic && mainView === 'blocks'"
|
|
:topic="selectedTopic"
|
|
:generating="blocks.generating"
|
|
:progress="blocks.progress"
|
|
:ready="blocks.ready"
|
|
:partial="blocks.partial"
|
|
@close="mainView = 'detail'"
|
|
@openGeneration="handleOpenGeneration"
|
|
/>
|
|
<GenerationView
|
|
v-else-if="selectedTopic && mainView === 'generation'"
|
|
:topic="selectedTopic"
|
|
:generating="blocks.generating"
|
|
:progress="blocks.progress"
|
|
:ready="blocks.ready"
|
|
:partial="blocks.partial"
|
|
:error="blocks.error"
|
|
:guideFormat="guideBoardFormat"
|
|
:autoInventory="autoInventory"
|
|
:autoArtefacts="autoArtefacts"
|
|
:autoGuide="autoGuide"
|
|
@close="mainView = 'blocks'"
|
|
@restartAll="() => handleBlocksClick({ research: true })"
|
|
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce), onlyArtefacts: !!(opts && opts.onlyArtefacts) })"
|
|
@addResearch="handleAddResearch"
|
|
@requeueDead="handleRequeueDead"
|
|
@removeAll="handleResetBlocks"
|
|
@cancel="handleCancelBlocks"
|
|
@cancelGuide="handleCancel"
|
|
@startGuide="handleFormatClick"
|
|
@preview="handleGuideBoardPreview"
|
|
@removeFormat="handleRemoveGuideFormat"
|
|
@setAuto="handleSetAuto"
|
|
/>
|
|
<GeneralExamPanel
|
|
v-else-if="selectedTopic && mainView === 'general'"
|
|
:topic="selectedTopic"
|
|
:provider="provider"
|
|
@progressChanged="loadStats(); loadBlocks()"
|
|
@fokus-active="focusOpen = $event"
|
|
/>
|
|
<PracticePanel
|
|
v-else-if="selectedTopic && mainView === 'practice'"
|
|
:key="selectedTopic"
|
|
:topic="selectedTopic"
|
|
/>
|
|
<TopicDetail
|
|
v-else-if="selectedTopic"
|
|
:previewGuide="previewGuide"
|
|
:provider="provider"
|
|
:themaAbgeschlossen="!!progress.completed"
|
|
:ansichtModus="viewMode"
|
|
:stufeAnsicht="levelView"
|
|
@progressChanged="loadStats(); loadBlocks()"
|
|
@setAnsicht="setView"
|
|
@open-sidebar="sidebarSticky = true"
|
|
@fokus-active="focusOpen = $event"
|
|
/>
|
|
<div v-else class="empty-main">
|
|
<p>Create or select a topic in the sidebar.</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
:root {
|
|
--bg: #f8f9fb;
|
|
--bg-preview: #f0f1f4;
|
|
--panel: #ffffff;
|
|
--panel-soft: #f4f5f7;
|
|
--border: #e2e5e9;
|
|
--border-strong: #d8dde3;
|
|
--text: #1a1a1a;
|
|
--text-muted: #4b5563;
|
|
--text-faint: #9ca3af;
|
|
--accent: #6366f1;
|
|
--accent-hover: #4f46e5;
|
|
--accent-soft: #ede9fe;
|
|
--accent-border: #a5b4fc;
|
|
--on-accent: #ffffff;
|
|
--success: #065f46;
|
|
--success-soft: #d1fae5;
|
|
--success-soft-hover: #a7f3d0;
|
|
--success-border: #34d399;
|
|
/* 4 learning levels: green/blue/purple/gold = Beginner/Advanced/Expert/Master */
|
|
--level-beginner: #22c55e;
|
|
--level-advanced: #3b82f6;
|
|
--level-expert: #8b5cf6;
|
|
--level-master: #d4af37;
|
|
--warning: #92400e;
|
|
--warning-soft: #fef3c7;
|
|
--warning-border: #fbbf24;
|
|
--danger: #991b1b;
|
|
--code-bg: #1e2a3a;
|
|
--code-fg: #e6e6e6;
|
|
--shadow: rgba(0, 0, 0, 0.12);
|
|
}
|
|
|
|
html.dark {
|
|
--bg: #15171c;
|
|
--bg-preview: #0e1014;
|
|
--panel: #1c1f26;
|
|
--panel-soft: #23262e;
|
|
--border: #2c3038;
|
|
--border-strong: #3a3f4a;
|
|
--text: #e6e8ee;
|
|
--text-muted: #9aa3b2;
|
|
--text-faint: #6b7280;
|
|
--accent: #6366f1;
|
|
--accent-hover: #818cf8;
|
|
--accent-soft: #2a2350;
|
|
--accent-border: #4f46e5;
|
|
--on-accent: #ffffff;
|
|
--success: #34d399;
|
|
--success-soft: #0f3a2e;
|
|
--success-soft-hover: #155e45;
|
|
--success-border: #0f805e;
|
|
--warning: #fbbf24;
|
|
--warning-soft: #3a2c0a;
|
|
--warning-border: #a06a12;
|
|
--danger: #f87171;
|
|
--shadow: rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
|
|
input,
|
|
textarea {
|
|
background: var(--panel);
|
|
color: var(--text);
|
|
}
|
|
|
|
input::placeholder,
|
|
textarea::placeholder {
|
|
color: var(--text-faint);
|
|
}
|
|
|
|
.layout {
|
|
display: flex;
|
|
height: 100dvh;
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.hover-zone {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
width: 50px;
|
|
height: 100dvh;
|
|
z-index: 5;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Invisible surface behind the open floating sidebar.
|
|
A tap/click next to it closes it — without it there's no way out on touch. */
|
|
.sidebar-backdrop {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 9;
|
|
background: transparent;
|
|
}
|
|
|
|
.layout.sidebar-floating > .sidebar {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
height: 100dvh;
|
|
transform: translateX(-100%);
|
|
transition: transform 0.2s ease;
|
|
z-index: 10;
|
|
box-shadow: 0 0 16px var(--shadow);
|
|
}
|
|
|
|
.layout.sidebar-floating .hover-zone:hover ~ .sidebar,
|
|
.layout.sidebar-floating > .sidebar:hover,
|
|
.layout.sidebar-floating.sidebar-open > .sidebar {
|
|
transform: translateX(0);
|
|
}
|
|
|
|
/* Block fullscreen: lift the sidebar as an overlay ABOVE the focus overlay (z-index 40).
|
|
Applies for pinned AND floating. */
|
|
.layout.sidebar-over-fokus > .sidebar {
|
|
position: fixed;
|
|
left: 0;
|
|
top: 0;
|
|
height: 100dvh;
|
|
transform: translateX(0);
|
|
z-index: 50;
|
|
box-shadow: 0 0 16px var(--shadow);
|
|
}
|
|
.layout.sidebar-over-fokus .sidebar-backdrop {
|
|
z-index: 49;
|
|
}
|
|
|
|
.empty-main {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-muted);
|
|
font-size: 1rem;
|
|
}
|
|
|
|
</style>
|