File size: 11,458 Bytes
1b44660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
<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 ... -->