Files
ai-teacher/frontend/src/views/TopicsView.vue
T
2026-04-07 22:39:28 +02:00

579 lines
15 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="topics-view">
<h1 class="page-title">Topics</h1>
<p class="page-subtitle">Select a topic to view or generate an AI-powered summary from uploaded books.</p>
<!-- Loading state -->
<div v-if="topicStore.loading" class="empty-state">
<div class="spinner spinner-dark" style="width:32px;height:32px;margin:0 auto 1rem;"></div>
<p class="empty-state-text">Loading topics...</p>
</div>
<!-- Error state -->
<div v-else-if="topicStore.error && topicStore.topics.length === 0" class="empty-state">
<div class="empty-state-icon"></div>
<p class="empty-state-text">Failed to load topics</p>
<p class="empty-state-hint">{{ topicStore.error }}</p>
<button class="btn btn-primary" style="margin-top:1rem;" @click="topicStore.fetchTopics()">Retry</button>
</div>
<div v-else class="topics-layout">
<div class="topics-main">
<!-- Summary history list -->
<div v-if="selectedTopicId" class="history-panel card">
<div class="history-header">
<span class="history-title">Saved summaries</span>
<button class="btn btn-primary btn-sm" :disabled="topicStore.summaryLoading" @click="handleGenerate(selectedTopicId!)">
<span v-if="topicStore.summaryLoading" class="spinner" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-right:4px;"></span>
Generate New
</button>
</div>
<div v-if="topicStore.summaryListLoading" class="history-loading">
<div class="spinner spinner-dark" style="width:20px;height:20px;margin-right:8px;display:inline-block;vertical-align:middle;"></div>
Loading...
</div>
<div v-else-if="topicStore.summaryList.length === 0" class="history-empty">
No summaries yet. Click "Generate New" to create one.
</div>
<div v-else class="history-list">
<button
v-for="item in topicStore.summaryList"
:key="item.id"
class="history-chip"
:class="{ 'history-chip--active': topicStore.activeSummary?.id === item.id }"
@click="handleLoadSummary(item)"
>
Summary #{{ item.summaryNumber }}
<span class="history-chip-date">· {{ formatDateShort(item.generatedAt) }}</span>
</button>
</div>
</div>
<!-- Summary Panel -->
<div v-if="topicStore.summaryLoading" class="summary-panel card">
<div class="summary-loading">
<div class="spinner spinner-dark" style="width:36px;height:36px;margin:0 auto 1rem;"></div>
<p class="summary-loading-text">Generating summary from uploaded books...</p>
<p class="summary-loading-hint">This may take up to 30 seconds.</p>
</div>
</div>
<div v-else-if="summaryError" class="summary-panel card summary-error">
<h2 class="summary-topic-name">Summary Error</h2>
<p class="error-text">{{ summaryError }}</p>
<p v-if="isNoBooks" class="no-books-hint">
Please
<RouterLink to="/">upload and process at least one book</RouterLink>
first.
</p>
</div>
<div v-else-if="!topicStore.activeSummary" class="summary-panel card summary-placeholder">
<p class="summary-placeholder-text">
{{ selectedTopicId ? 'Select a saved summary or generate a new one.' : 'Select a topic to get started.' }}
</p>
</div>
<div v-else class="summary-panel card">
<div class="summary-header">
<h2 class="summary-topic-name">{{ topicStore.activeSummary.topicName }}</h2>
<div class="summary-meta">
<span v-if="topicStore.activeSummary.summaryNumber" class="summary-number">
Summary #{{ topicStore.activeSummary.summaryNumber }}
</span>
<span class="summary-timestamp">{{ formatDate(topicStore.activeSummary.generatedAt) }}</span>
</div>
</div>
<div class="summary-text summary-text--markdown" v-html="renderedSummary" @click="handleSummaryClick"></div>
<div ref="sourcesSection" v-if="topicStore.activeSummary.sources.length > 0" class="sources-section">
<button class="sources-toggle" @click="showSources = !showSources">
Sources ({{ topicStore.activeSummary.sources.length }})
<span>{{ showSources ? '▲' : '▼' }}</span>
</button>
<div v-if="showSources" class="sources-list">
<div
v-for="(source, idx) in topicStore.activeSummary.sources"
:key="idx"
class="source-chip"
:class="{ 'source-chip--clickable': source.bookId && source.page }"
@click="source.bookId && source.page ? handleOpenSource(source.bookId, source.page) : undefined"
>
<span class="source-icon">📖</span>
<span class="source-book">{{ source.bookTitle }}</span>
<span v-if="source.page" class="source-page">p.&nbsp;{{ source.page }}</span>
<span v-if="source.bookId && source.page" class="source-open-hint"></span>
</div>
</div>
<BookPagePanel
v-if="readerPanel"
:book-id="readerPanel.bookId"
:page="readerPanel.page"
:book-title="readerPanel.bookTitle"
class="reader-panel"
@close="readerPanel = null"
@navigate="(p) => readerPanel && (readerPanel.page = p)"
/>
</div>
<div v-else class="no-sources">
No source citations available for this summary.
</div>
</div>
<!-- Topic Grid -->
<div class="topic-grid">
<TopicCard
v-for="topic in summaryTopics"
:key="topic.id"
:topic="topic"
:is-generating="topicStore.activeSummaryTopicId === topic.id"
:is-selected="selectedTopicId === topic.id"
@generate="handleTopicClick"
/>
</div>
</div><!-- end topics-main -->
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, inject } from 'vue'
import { marked } from 'marked'
import { RouterLink } from 'vue-router'
import { useTopicStore, type SavedSummaryItem } from '@/stores/topicStore'
import { useBookStore } from '@/stores/bookStore'
import TopicCard from '@/components/TopicCard.vue'
import BookPagePanel from '@/components/BookPagePanel.vue'
const topicStore = useTopicStore()
const bookStore = useBookStore()
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
const showSources = ref(true)
const summaryError = ref<string | null>(null)
const isNoBooks = ref(false)
const sourcesSection = ref<HTMLElement | null>(null)
const selectedTopicId = ref<string | null>(null)
interface ReaderPanel { bookId: string; page: number; bookTitle?: string }
const readerPanel = ref<ReaderPanel | null>(null)
const summaryTopics = computed(() => topicStore.topics.filter(t => t.id !== 'free-form'))
const renderedSummary = computed(() => {
if (!topicStore.activeSummary) return ''
const html = marked.parse(topicStore.activeSummary.summary) as string
return html.replace(/\[S(\d+)\]/g, '<span class="source-ref">[S$1]</span>')
})
function handleSummaryClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('source-ref')) {
showSources.value = true
sourcesSection.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
function handleOpenSource(bookId: string, page: number) {
const book = bookStore.books.find(b => b.id === bookId)
readerPanel.value = { bookId, page, bookTitle: book?.title }
showSources.value = true
}
async function handleTopicClick(topicId: string) {
if (selectedTopicId.value !== topicId) {
selectedTopicId.value = topicId
topicStore.activeSummary = null
summaryError.value = null
await topicStore.fetchSummaries(topicId)
// Auto-load the latest summary if any exist
const list = topicStore.summaryList
if (list.length > 0) {
await topicStore.fetchSummaryDetail(topicId, list[list.length - 1].id)
}
}
}
async function handleLoadSummary(item: SavedSummaryItem) {
if (!selectedTopicId.value) return
summaryError.value = null
await topicStore.fetchSummaryDetail(selectedTopicId.value, item.id)
}
onMounted(async () => {
await topicStore.fetchTopics()
if (bookStore.books.length === 0) {
await bookStore.fetchBooks()
}
})
async function handleGenerate(topicId: string) {
summaryError.value = null
isNoBooks.value = false
showSources.value = true
const result = await topicStore.generateSummary(topicId)
if (!result) {
summaryError.value = topicStore.error ?? 'Failed to generate summary.'
isNoBooks.value =
summaryError.value.toLowerCase().includes('no books') ||
summaryError.value.toLowerCase().includes('knowledge source')
showToast?.(summaryError.value, 'error')
} else {
// Refresh the history list to include the newly saved summary
await topicStore.fetchSummaries(topicId)
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
function formatDateShort(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
</script>
<style scoped>
.topics-layout {
display: flex;
gap: 2rem;
}
.topics-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2rem;
}
.reader-panel {
margin-top: 1rem;
height: 600px;
min-height: 400px;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
}
.topic-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
/* History panel */
.history-panel {
border-top: 3px solid #805ad5;
padding: 1rem;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.history-title {
font-size: 0.875rem;
font-weight: 600;
color: #553c9a;
}
.btn-sm {
font-size: 0.8rem;
padding: 0.3rem 0.75rem;
}
.history-loading {
font-size: 0.85rem;
color: #718096;
display: flex;
align-items: center;
}
.history-empty {
font-size: 0.85rem;
color: #a0aec0;
font-style: italic;
}
.history-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.history-chip {
background: #faf5ff;
border: 1px solid #d6bcfa;
border-radius: 6px;
padding: 0.3rem 0.75rem;
font-size: 0.8rem;
font-weight: 500;
color: #553c9a;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.history-chip:hover {
background: #e9d8fd;
border-color: #b794f4;
}
.history-chip--active {
background: #805ad5;
border-color: #805ad5;
color: #fff;
}
.history-chip-date {
font-weight: 400;
color: inherit;
opacity: 0.75;
}
/* Summary panel */
.summary-panel {
border-top: 3px solid #3182ce;
}
.summary-loading {
text-align: center;
padding: 2rem;
}
.summary-loading-text {
font-size: 1rem;
font-weight: 500;
color: #2d3748;
margin-bottom: 0.25rem;
}
.summary-loading-hint {
font-size: 0.85rem;
color: #718096;
}
.summary-error {
border-top-color: #e53e3e;
}
.summary-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.summary-topic-name {
font-size: 1.2rem;
font-weight: 700;
color: #1a365d;
}
.summary-meta {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.summary-number {
font-size: 0.8rem;
font-weight: 600;
color: #805ad5;
background: #faf5ff;
border: 1px solid #d6bcfa;
border-radius: 4px;
padding: 0.1rem 0.4rem;
}
.summary-timestamp {
font-size: 0.8rem;
color: #a0aec0;
}
.summary-text {
font-size: 0.95rem;
line-height: 1.7;
color: #2d3748;
margin-bottom: 1rem;
}
.summary-text--markdown {
white-space: normal;
}
.summary-text--markdown :deep(h1),
.summary-text--markdown :deep(h2),
.summary-text--markdown :deep(h3),
.summary-text--markdown :deep(h4) {
font-weight: 700;
margin: 0.75rem 0 0.35rem;
line-height: 1.3;
color: #1a202c;
}
.summary-text--markdown :deep(h1) { font-size: 1.15rem; }
.summary-text--markdown :deep(h2) { font-size: 1.05rem; }
.summary-text--markdown :deep(h3) { font-size: 0.975rem; }
.summary-text--markdown :deep(h4) { font-size: 0.925rem; }
.summary-text--markdown :deep(p) { margin: 0.4rem 0; }
.summary-text--markdown :deep(ul),
.summary-text--markdown :deep(ol) {
padding-left: 1.4rem;
margin: 0.4rem 0;
}
.summary-text--markdown :deep(li) { margin: 0.2rem 0; }
.summary-text--markdown :deep(strong) {
font-weight: 700;
color: #1a202c;
}
.summary-text--markdown :deep(em) { font-style: italic; }
.summary-text--markdown :deep(code) {
background: #edf2f7;
border-radius: 3px;
padding: 0.1em 0.35em;
font-size: 0.87em;
font-family: monospace;
}
.summary-text--markdown :deep(blockquote) {
border-left: 3px solid #bee3f8;
margin: 0.5rem 0;
padding: 0.25rem 0.75rem;
color: #4a5568;
}
.sources-section {
border-top: 1px solid #e2e8f0;
padding-top: 0.75rem;
}
.sources-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
color: #3182ce;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0;
margin-bottom: 0.75rem;
}
.sources-toggle:hover {
color: #2b6cb0;
}
.sources-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.source-chip {
display: flex;
align-items: center;
gap: 0.4rem;
background: #ebf8ff;
border: 1px solid #bee3f8;
border-radius: 6px;
padding: 0.3rem 0.7rem;
font-size: 0.8rem;
}
.source-chip--clickable {
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.source-chip--clickable:hover {
background: #bee3f8;
border-color: #90cdf4;
}
.source-icon {
font-size: 0.8rem;
}
.source-book {
color: #2b6cb0;
font-weight: 500;
}
.source-page {
color: #718096;
}
.source-open-hint {
font-size: 0.75rem;
color: #3182ce;
margin-left: 0.1rem;
}
.no-sources {
font-size: 0.85rem;
color: #a0aec0;
font-style: italic;
}
.error-text {
color: #742a2a;
margin-bottom: 0.5rem;
}
.no-books-hint {
font-size: 0.875rem;
color: #718096;
}
.no-books-hint a {
color: #3182ce;
text-decoration: underline;
}
.summary-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 6rem;
border-top-color: #cbd5e0;
}
.summary-placeholder-text {
font-size: 0.95rem;
color: #a0aec0;
font-style: italic;
}
.summary-text--markdown :deep(.source-ref) {
color: #3182ce;
font-weight: 600;
cursor: pointer;
border-radius: 3px;
padding: 0 0.15em;
}
.summary-text--markdown :deep(.source-ref:hover) {
background: #ebf8ff;
text-decoration: underline;
}
</style>