first implementation
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user