This commit is contained in:
team3
2026-05-28 18:10:58 +02:00
parent cc1ea166c8
commit c1370a9f6a
2 changed files with 179 additions and 32 deletions

View File

@@ -10,8 +10,24 @@ const manualTopics = ref([])
const selectedTopic = ref(null) const selectedTopic = ref(null)
const previewGuide = ref(null) const previewGuide = ref(null)
const showBausteine = ref(false) const showBausteine = ref(false)
const sidebarPinned = ref(localStorage.getItem('sidebarPinned') !== 'false')
const sidebarSticky = ref(false)
let pollTimer = null let pollTimer = null
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(() => { const topics = computed(() => {
const topicDates = {} const topicDates = {}
for (const g of guides.value) { for (const g of guides.value) {
@@ -25,7 +41,19 @@ const topics = computed(() => {
return Object.keys(topicDates).sort((a, b) => topicDates[b].localeCompare(topicDates[a])) return Object.keys(topicDates).sort((a, b) => topicDates[b].localeCompare(topicDates[a]))
}) })
const guidesByFormat = computed(() => { 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 = {} const map = {}
for (const g of guides.value) { for (const g of guides.value) {
if (g.topic !== selectedTopic.value) continue if (g.topic !== selectedTopic.value) continue
@@ -51,9 +79,9 @@ async function loadGuides() {
const FORMAT_ORDER = ['OnePager', 'Cheatsheet', 'MiniGuide', 'BeginnerGuide', 'IntermediateGuide', 'ExtendedGuide'] const FORMAT_ORDER = ['OnePager', 'Cheatsheet', 'MiniGuide', 'BeginnerGuide', 'IntermediateGuide', 'ExtendedGuide']
function autoPreview() { function autoPreview() {
const map = guidesByFormat.value const map = doneByFormat.value
for (const f of FORMAT_ORDER) { for (const f of FORMAT_ORDER) {
if (map[f]?.status === 'done') { if (map[f]) {
previewGuide.value = map[f] previewGuide.value = map[f]
return return
} }
@@ -164,13 +192,16 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="layout"> <div class="layout" :class="{ 'sidebar-floating': !sidebarPinned, 'sidebar-open': sidebarSticky }">
<div v-if="!sidebarPinned" class="hover-zone" @click="clickHoverZone"></div>
<TopicSidebar <TopicSidebar
:topics="topics" :topics="topics"
:selectedTopic="selectedTopic" :selectedTopic="selectedTopic"
:guidesByFormat="guidesByFormat" :doneByFormat="doneByFormat"
:latestByFormat="latestByFormat"
:allGuides="guides" :allGuides="guides"
:bausteineActive="showBausteine" :bausteineActive="showBausteine"
:pinned="sidebarPinned"
@select="selectTopic" @select="selectTopic"
@create="createTopic" @create="createTopic"
@formatClick="handleFormatClick" @formatClick="handleFormatClick"
@@ -180,6 +211,8 @@ onUnmounted(() => {
@preview="handlePreview" @preview="handlePreview"
@rework="handleRework" @rework="handleRework"
@showBausteine="handleShowBausteine" @showBausteine="handleShowBausteine"
@togglePin="toggleSidebarPin"
@sidebarLeave="onSidebarLeave"
/> />
<BausteineView <BausteineView
v-if="selectedTopic && showBausteine" v-if="selectedTopic && showBausteine"
@@ -212,6 +245,34 @@ body {
display: flex; display: flex;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
position: relative;
}
.hover-zone {
position: fixed;
left: 0;
top: 0;
width: 50px;
height: 100vh;
z-index: 5;
cursor: pointer;
}
.layout.sidebar-floating > .sidebar {
position: fixed;
left: 0;
top: 0;
height: 100vh;
transform: translateX(-100%);
transition: transform 0.2s ease;
z-index: 10;
box-shadow: 0 0 16px rgba(0, 0, 0, 0.12);
}
.layout.sidebar-floating .hover-zone:hover ~ .sidebar,
.layout.sidebar-floating > .sidebar:hover,
.layout.sidebar-floating.sidebar-open > .sidebar {
transform: translateX(0);
} }
.empty-main { .empty-main {

View File

@@ -4,12 +4,14 @@ import { ref, computed } from 'vue'
const props = defineProps({ const props = defineProps({
topics: { type: Array, required: true }, topics: { type: Array, required: true },
selectedTopic: { type: String, default: null }, selectedTopic: { type: String, default: null },
guidesByFormat: { type: Object, default: () => ({}) }, doneByFormat: { type: Object, default: () => ({}) },
latestByFormat: { type: Object, default: () => ({}) },
allGuides: { type: Array, default: () => [] }, allGuides: { type: Array, default: () => [] },
bausteineActive: { type: Boolean, default: false }, bausteineActive: { type: Boolean, default: false },
pinned: { type: Boolean, default: true },
}) })
const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview', 'rework', 'showBausteine']) const emit = defineEmits(['select', 'create', 'formatClick', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'preview', 'rework', 'showBausteine', 'togglePin', 'sidebarLeave'])
const formats = [ const formats = [
{ key: 'OnePager', label: 'OnePager' }, { key: 'OnePager', label: 'OnePager' },
@@ -27,14 +29,22 @@ const activeGenerations = computed(() => {
}) })
function guideStatus(format) { function guideStatus(format) {
const guide = props.guidesByFormat[format] if (props.doneByFormat[format]) return 'done'
if (!guide) return 'none' const latest = props.latestByFormat[format]
return guide.status if (!latest) return 'none'
if (latest.status === 'error') return 'none'
return latest.status
}
function errorMsg(format) {
const latest = props.latestByFormat[format]
if (latest?.status === 'error') return latest.error_msg || 'Fehler bei der Generierung'
return ''
} }
function handleFormatClick(format) { function handleFormatClick(format) {
const guide = props.guidesByFormat[format] const guide = props.doneByFormat[format]
if (guide?.status === 'done') { if (guide) {
emit('preview', guide) emit('preview', guide)
} }
} }
@@ -60,16 +70,33 @@ function handlePlay(format) {
} }
function handleRefresh(format) { function handleRefresh(format) {
const guide = props.guidesByFormat[format] const guide = props.doneByFormat[format]
if (!guide) return if (!guide) return
const text = activeInput.value === format ? inputText.value.trim() : '' const text = activeInput.value === format ? inputText.value.trim() : ''
emit('rework', { guideId: guide.id, instructions: text || 'Überarbeite das Layout' }) if (!text) return
emit('rework', { guideId: guide.id, instructions: text })
activeInput.value = null activeInput.value = null
inputText.value = '' inputText.value = ''
} }
function handleInputEnter(format) {
const text = inputText.value.trim()
if (props.doneByFormat[format] && text) {
handleRefresh(format)
} else {
handlePlay(format)
}
}
function dismissError(format) {
const latest = props.latestByFormat[format]
if (latest?.status === 'error') {
emit('deleteGuide', latest.id)
}
}
function handleDelete(format) { function handleDelete(format) {
const guide = props.guidesByFormat[format] const guide = props.latestByFormat[format]
if (!guide) return if (!guide) return
if (guide.status === 'generating' || guide.status === 'queued') { if (guide.status === 'generating' || guide.status === 'queued') {
if (!confirm('Generierung abbrechen?')) return if (!confirm('Generierung abbrechen?')) return
@@ -91,8 +118,13 @@ function submit() {
</script> </script>
<template> <template>
<aside class="sidebar"> <aside class="sidebar" @mouseleave="emit('sidebarLeave')">
<div class="new-topic"> <div class="new-topic">
<button
class="pin-btn"
:title="pinned ? 'Sidebar ausblenden' : 'Sidebar fixieren'"
@click="emit('togglePin')"
>{{ pinned ? '⇤' : '⇥' }}</button>
<input <input
v-model="newTopic" v-model="newTopic"
placeholder="Neues Thema…" placeholder="Neues Thema…"
@@ -128,16 +160,7 @@ function submit() {
>&times;</span> >&times;</span>
</button> </button>
<div class="format-actions"> <div class="format-actions">
<template v-if="guideStatus(f.key) === 'done'"> <template v-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
<button class="action-btn refresh" title="Überarbeiten" @click="handleRefresh(f.key)"></button>
<button
class="action-btn pencil"
:class="{ active: activeInput === f.key }"
title="Anweisungen"
@click="toggleInput(f.key)"
></button>
</template>
<template v-else-if="guideStatus(f.key) !== 'generating' && guideStatus(f.key) !== 'queued'">
<button class="action-btn play" title="Generieren" @click="handlePlay(f.key)"></button> <button class="action-btn play" title="Generieren" @click="handlePlay(f.key)"></button>
<button <button
class="action-btn pencil" class="action-btn pencil"
@@ -148,12 +171,23 @@ function submit() {
</template> </template>
</div> </div>
</div> </div>
<div v-if="errorMsg(f.key)" class="format-error">
<span class="format-error-text">{{ errorMsg(f.key) }}</span>
<button class="format-error-x" title="Fehler entfernen" @click="dismissError(f.key)">&times;</button>
</div>
<div v-if="activeInput === f.key" class="format-input"> <div v-if="activeInput === f.key" class="format-input">
<input <input
v-model="inputText" v-model="inputText"
:placeholder="guideStatus(f.key) === 'done' ? 'Was soll überarbeitet werden?' : 'Anweisungen (optional)…'" :placeholder="doneByFormat[f.key] ? 'Was soll überarbeitet werden?' : 'Anweisungen (optional)…'"
@keyup.enter="guideStatus(f.key) === 'done' ? handleRefresh(f.key) : handlePlay(f.key)" @keyup.enter="handleInputEnter(f.key)"
/> />
<button
v-if="doneByFormat[f.key]"
class="action-btn refresh"
title="Überarbeiten"
:disabled="!inputText.trim()"
@click="handleRefresh(f.key)"
></button>
</div> </div>
</div> </div>
<div class="bausteine-btn-wrapper"> <div class="bausteine-btn-wrapper">
@@ -214,6 +248,20 @@ function submit() {
cursor: not-allowed; cursor: not-allowed;
} }
.new-topic .pin-btn {
background: #f8f9fb;
color: #4b5563;
border: 1px solid #d8dde3;
font-weight: 600;
padding: 6px 8px;
}
.new-topic .pin-btn:hover {
background: #ede9fe;
color: #4f46e5;
border-color: #a5b4fc;
}
.topic-list { .topic-list {
list-style: none; list-style: none;
overflow-y: auto; overflow-y: auto;
@@ -332,10 +380,35 @@ function submit() {
animation: pulse 1.5s ease-in-out infinite; animation: pulse 1.5s ease-in-out infinite;
} }
.fmt-error .format-name { .format-error {
display: flex;
align-items: flex-start;
gap: 4px;
padding: 2px 0.75rem 6px calc(0.75rem + 8px);
font-size: 0.72rem;
color: #991b1b; color: #991b1b;
background: #fee2e2; line-height: 1.3;
border: 1px solid #f87171; }
.format-error-text {
flex: 1;
word-break: break-word;
}
.format-error-x {
flex: 0 0 auto;
background: none;
border: none;
color: #991b1b;
font-size: 1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
opacity: 0.6;
}
.format-error-x:hover {
opacity: 1;
} }
.format-actions { .format-actions {
@@ -387,11 +460,14 @@ function submit() {
} }
.format-input { .format-input {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 0.75rem 8px; padding: 4px 0.75rem 8px;
} }
.format-input input { .format-input input {
width: 100%; flex: 1;
padding: 4px 8px; padding: 4px 8px;
border: 1px solid #d8dde3; border: 1px solid #d8dde3;
border-radius: 4px; border-radius: 4px;
@@ -403,6 +479,16 @@ function submit() {
border-color: #6366f1; border-color: #6366f1;
} }
.action-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.action-btn:disabled:hover {
background: none;
border-color: transparent;
}
@keyframes pulse { @keyframes pulse {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.65; } 50% { opacity: 0.65; }