Spaces:
Running
Running
<script lang="ts" setup> | |
import type { Brief } from '~/shared/types'; | |
// Constants (Consider moving to config or keeping here if page-specific) | |
const WORDS_PER_MINUTE = 300; | |
const HEADER_OFFSET = 80; // Used by TOC composable | |
const TOC_WIDTH_CLASS = 'w-64'; // Keep for template styling | |
// Config & Route | |
const config = useRuntimeConfig(); | |
const route = useRoute(); | |
const slug = route.path.split('/').pop()?.replaceAll('_', '/'); // Keep original logic for slug | |
if (!slug) { | |
throw createError({ statusCode: 404, statusMessage: 'Brief slug not found in path' }); | |
} | |
// Data Fetching | |
// Note: useFetch handles async data fetching correctly within <script setup> | |
const { data: briefRawData, error: briefError } = await useFetch<Brief>(`/api/briefs/${slug}`); | |
// Error Handling & Data Processing | |
if (briefError.value) { | |
console.error('Error fetching brief:', briefError.value); | |
// Consider more specific error mapping based on briefError.value.statusCode if available | |
throw createError({ statusCode: briefError.value.statusCode || 500, statusMessage: 'Error fetching brief data.' }); | |
} | |
if (!briefRawData.value) { | |
throw createError({ statusCode: 404, statusMessage: 'Brief not found.' }); | |
} | |
// Ensure reactivity and process date | |
const briefData = toRef({ | |
...briefRawData.value, | |
createdAt: new Date(briefRawData.value.createdAt), | |
// Add reading time calculation here, needs the 'content' field | |
readingTime: estimateReadingTime(briefRawData.value.content || ''), // Assuming content field exists | |
}); | |
// Reading Time Estimation Function (Keep here or move to a utils file) | |
function estimateReadingTime(content: string): number { | |
if (!content) return 0; | |
const wordCount = content.trim().split(/\s+/).length; | |
return Math.ceil(wordCount / WORDS_PER_MINUTE); | |
} | |
// --- Template Refs --- | |
const articleContentRef = ref<HTMLElement | null>(null); // For TOC and reading time content source | |
const headerRef = ref<HTMLElement | null>(null); // For sticky detection | |
const mobileTocRef = ref<HTMLElement | null>(null); // Potentially for mobile menu interaction/styling | |
const mobileMenuOpen = ref(false); // Mobile TOC menu state | |
// --- Instantiate Composables --- | |
const { readingProgress, showBackToTop, scrollToTop } = useReadingProgress(); | |
const { tocItems, activeHeadingId, currentSectionName, scrollToSection } = useTableOfContents({ | |
contentRef: articleContentRef, | |
headerOffset: HEADER_OFFSET, | |
// selectors: 'h2, h3, u > strong' // Default is usually fine | |
}); | |
const { isSticky } = useStickyElement(headerRef); // Observe the header element directly | |
// --- SEO Metadata --- | |
const formatDate = computed(() => { | |
const date = briefData.value?.date; // Assuming 'date' object exists as per original | |
return date?.month ? `${date.month.toLowerCase()} ${date.day}, ${date.year}` : 'Unknown Date'; | |
}); | |
// Use useSeoMeta - Ensure data exists and provide fallbacks | |
useSeoMeta({ | |
title: `${briefData.value?.title?.toLowerCase() ?? 'Brief'} | meridian`, | |
description: `Intelligence brief for ${formatDate.value}`, // Use computed date | |
ogTitle: `${briefData.value?.title ?? 'Intelligence Brief'}`, | |
ogDescription: `Intelligence brief for ${formatDate.value}`, // Use computed date | |
ogImage: briefData.value?.title // Check if title exists before constructing URL | |
? `${config.public.WORKER_API}/openGraph/brief?title=${encodeURIComponent(briefData.value.title)}&date=${encodeURIComponent(briefData.value.createdAt?.getTime() ?? Date.now())}&articles=${briefData.value.usedArticles ?? 0}&sources=${briefData.value.usedSources ?? 0}` | |
: '/default-og-image.png', // Fallback OG image | |
ogUrl: `https://news.iliane.xyz/briefs/${slug}`, // Ensure base URL is correct | |
twitterCard: 'summary_large_image', | |
}); | |
</script> | |
<template> | |
<div> | |
<!-- Reading progress bar --> | |
<div class="fixed top-0 left-0 w-full bg-white dark:bg-gray-900 h-1 z-50"> | |
<div | |
class="h-full bg-black dark:bg-white transition-all duration-150 ease-out" | |
:style="{ width: `${readingProgress}%` }" | |
/> | |
</div> | |
<!-- Override the default layout constraints for this page only --> | |
<!-- This wrapper pushes its content outside the default max-w-3xl container --> | |
<div class="mx-[-1.5rem] w-[calc(100%+3rem)] relative"> | |
<!-- Inner container to reapply the proper padding --> | |
<div class="max-w-3xl mx-auto px-6"> | |
<!-- Main Content Column --> | |
<div> | |
<!-- Header section that we'll observe --> | |
<header ref="headerRef" class="mb-8"> | |
<h1 class="text-3xl md:text-4xl font-bold mb-3 leading-tight"> | |
{{ briefData.title }} | |
</h1> | |
<div class="flex text-sm text-gray-600 dark:text-gray-400 items-center space-x-2"> | |
<time>{{ formatDate }}</time> | |
<span>•</span> | |
<p>{{ estimateReadingTime(briefData.content) }} min read</p> | |
</div> | |
</header> | |
<!-- Mobile TOC (Only visible on small screens) --> | |
<div v-if="tocItems.length > 0" class="xl:hidden mb-8"> | |
<div | |
ref="mobileTocRef" | |
:class="[ | |
'z-40 transition-all duration-200 ', // lower z-index to stay below reading indicator | |
isSticky ? 'fixed top-1 left-0 right-0' : 'relative', // top-1 to show reading indicator | |
]" | |
> | |
<button | |
class="w-full flex items-center justify-between px-6 py-3 text-sm font-semibold text-gray-700 dark:text-gray-300 bg-white/95 dark:bg-gray-900/95 backdrop-blur-md transition-colors duration-200" | |
@click="mobileMenuOpen = !mobileMenuOpen" | |
> | |
<span>{{ currentSectionName }}</span> | |
<svg | |
xmlns="http://www.w3.org/2000/svg" | |
viewBox="0 0 20 20" | |
fill="currentColor" | |
class="w-4 h-4 transform transition-transform duration-300 ease-in-out" | |
:class="{ 'rotate-180': mobileMenuOpen }" | |
> | |
<path | |
fill-rule="evenodd" | |
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" | |
clip-rule="evenodd" | |
/> | |
</svg> | |
</button> | |
<Transition | |
enter-active-class="transition-all duration-300 ease-out" | |
leave-active-class="transition-all duration-200 ease-in" | |
enter-from-class="opacity-0 -translate-y-4" | |
enter-to-class="opacity-100 translate-y-0" | |
leave-from-class="opacity-100 translate-y-0" | |
leave-to-class="opacity-0 -translate-y-4" | |
> | |
<div | |
v-if="mobileMenuOpen" | |
class="absolute left-0 right-0 bg-white dark:bg-gray-900 shadow-lg p-4 max-h-[60vh] overflow-y-auto" | |
> | |
<BriefTableOfContents | |
:items="tocItems" | |
:active-heading-id="activeHeadingId" | |
@navigate=" | |
(id: string) => { | |
scrollToSection(id); | |
mobileMenuOpen = false; | |
} | |
" | |
@active-heading-change="activeHeadingId = $event" | |
/> | |
</div> | |
</Transition> | |
</div> | |
</div> | |
<!-- Main article content --> | |
<article ref="articleContentRef" class="prose w-full max-w-none" v-html="$md.render(briefData.content)" /> | |
<!-- Stats and Subscription Form --> | |
<div class="mt-16 mb-8"> | |
<div class="h-px w-full bg-gray-300 dark:bg-gray-700 mb-8" /> | |
<div class="flex flex-col text-center gap-8"> | |
<!-- Stat divs remain the same --> | |
<div class="grid grid-cols-2 gap-y-6 gap-x-12 text-sm"> | |
<div> | |
<p class="text-gray-600 dark:text-gray-400 mb-1">total articles</p> | |
<p class="font-bold text-base">{{ briefData.totalArticles || '-' }}</p> | |
</div> | |
<div> | |
<p class="text-gray-600 dark:text-gray-400 mb-1">total sources</p> | |
<p class="font-bold text-base">{{ briefData.totalSources || '-' }}</p> | |
</div> | |
<div> | |
<p class="text-gray-600 dark:text-gray-400 mb-1">used articles</p> | |
<p class="font-bold text-base">{{ briefData.usedArticles || '-' }}</p> | |
</div> | |
<div> | |
<p class="text-gray-600 dark:text-gray-400 mb-1">used sources</p> | |
<p class="font-bold text-base">{{ briefData.usedSources || '-' }}</p> | |
</div> | |
</div> | |
<div class="text-sm"> | |
<p class="text-gray-600 dark:text-gray-400 mb-1">final brief generated by</p> | |
<p class="font-bold text-base">{{ briefData.model_author }}</p> | |
</div> | |
<!-- Subscription area --> | |
<div class="mt-4 pt-8 border-t border-gray-300 dark:border-gray-700"> | |
<SubscriptionForm /> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<!-- Desktop TOC - Positioned absolutely to the left of the content --> | |
<aside | |
v-if="tocItems.length > 0" | |
class="hidden xl:block fixed max-h-[calc(100vh-8rem)] overflow-y-auto pr-8" | |
:class="[ | |
TOC_WIDTH_CLASS, | |
// Fixed distance from the left edge at larger screens | |
'top-24 right-[78%]', | |
]" | |
> | |
<ClientOnly> | |
<BriefTableOfContents | |
:items="tocItems" | |
:active-heading-id="activeHeadingId" | |
@navigate="scrollToSection" | |
@active-heading-change="activeHeadingId = $event" | |
/> | |
</ClientOnly> | |
</aside> | |
<!-- Back to Top Button --> | |
<Transition | |
enter-active-class="transition-opacity duration-200" | |
leave-active-class="transition-opacity duration-200" | |
enter-from-class="opacity-0" | |
leave-to-class="opacity-0" | |
> | |
<button | |
v-show="showBackToTop" | |
class="fixed bottom-8 z-50 border border-gray-200 dark:border-gray-950 hover:cursor-pointer right-[max(2rem,calc((100%-68rem)/2))] p-2 rounded-full bg-gray-100 dark:bg-gray-900 text-black dark:text-gray-100 shadow-lg hover:bg-gray-200 dark:hover:bg-gray-800 transition-colors duration-200" | |
aria-label="Back to top" | |
@click="scrollToTop()" | |
> | |
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> | |
<path | |
fill-rule="evenodd" | |
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" | |
clip-rule="evenodd" | |
/> | |
</svg> | |
</button> | |
</Transition> | |
</div> | |
</div> | |
</template> | |
<style> | |
/* Global smooth scrolling */ | |
html { | |
scroll-behavior: smooth; | |
} | |
</style> | |
<!-- | |
/* Optional: Fine-tune prose dark mode if needed */ | |
.dark .prose { | |
/* Example: */ | |
/* color: theme('colors.slate.300'); */ | |
} | |
.dark .prose strong { | |
/* color: theme('colors.slate.100'); */ | |
} | |
... etc ... --> | |