first implementation

This commit is contained in:
Adrien
2026-03-31 20:58:47 +02:00
parent dc0bcab36e
commit 618e28b354
1878 changed files with 1381732 additions and 5 deletions
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="topics-view">
<h1 class="page-title">Topics</h1>
<p class="page-subtitle">Select a topic to 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">
<!-- Topic Grid -->
<div class="topic-grid">
<TopicCard
v-for="topic in topicStore.topics"
:key="topic.id"
:topic="topic"
:is-generating="topicStore.activeSummaryTopicId === topic.id"
@generate="handleGenerate"
/>
</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">
<div class="summary-header">
<h2 class="summary-topic-name">{{ topicStore.activeSummary.topicName }}</h2>
<span class="summary-timestamp">{{ formatDate(topicStore.activeSummary.generatedAt) }}</span>
</div>
<div class="summary-text">{{ topicStore.activeSummary.summary }}</div>
<div 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"
>
<span class="source-book">{{ source.bookTitle }}</span>
<span v-if="source.page" class="source-page">p. {{ source.page }}</span>
</div>
</div>
</div>
<div v-else class="no-sources">
No source citations available for this summary.
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, inject } from 'vue'
import { RouterLink } from 'vue-router'
import { useTopicStore } from '@/stores/topicStore'
import TopicCard from '@/components/TopicCard.vue'
const topicStore = useTopicStore()
const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('showToast')
const showSources = ref(true)
const summaryError = ref<string | null>(null)
const isNoBooks = ref(false)
onMounted(async () => {
await topicStore.fetchTopics()
})
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')
}
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
</script>
<style scoped>
.topics-layout {
display: flex;
flex-direction: column;
gap: 2rem;
}
.topic-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.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-timestamp {
font-size: 0.8rem;
color: #a0aec0;
}
.summary-text {
font-size: 0.95rem;
line-height: 1.7;
color: #2d3748;
white-space: pre-wrap;
margin-bottom: 1rem;
}
.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-book {
color: #2b6cb0;
font-weight: 500;
}
.source-page {
color: #718096;
}
.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;
}
</style>