meridian / frontend /src /views /BlogPost.vue
GitHub Actions
Deploy to HuggingFace Spaces
8a428e5
Raw
History Blame Contribute Delete
36.8 kB
<script setup>
import { onMounted, onUnmounted, ref, nextTick, computed, watch } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import { useBlogStore } from '../stores/blog'
import { useLayoutStore } from '../stores/layout'
import Navbar from '../components/Navbar.vue'
import Footer from '../components/Footer.vue'
import PostCard from '../components/PostCard.vue'
import api from '../api'
import { format } from 'date-fns'
import { useWakeLock } from '../composables/useWakeLock'
// highlight.js is loaded lazily only when code blocks are present in the post
const blog = useBlogStore()
const layout = useLayoutStore()
const ttsTrackColor = computed(() => layout.variant === 'b' ? '#7c3aed' : '#3b82f6')
const ttsThumbColor = computed(() => layout.variant === 'b' ? '#7c3aed' : '#2563eb')
const route = useRoute()
const post = ref(null)
const relatedPosts = ref([])
const comments = ref([])
const commentForm = ref({ authorName: '', authorEmail: '', content: '' })
const commentSubmitted = ref(false)
const galleryOpen = ref(false)
const galleryIndex = ref(0)
// ── TTS player ────────────────────────────────────────────────────────────────
const ttsState = ref('idle') // idle | loading | playing | paused | error
const ttsProgress = ref(0) // 0–1 across all chunks
const ttsChunkIdx = ref(0) // current chunk index (0-based)
const ttsTotalChunks = ref(0)
const ttsError = ref('')
const playerOpen = ref(false)
const { acquireWakeLock, releaseWakeLock } = useWakeLock()
watch(ttsState, v => v === 'playing' ? acquireWakeLock() : releaseWakeLock())
// Non-reactive internal state — AudioContext-based for gapless playback
let sessionId = 0
let audioCtx = null // Web AudioContext — schedules audio with zero gaps
let nextStartAt = 0 // ctx.currentTime when next chunk should begin
let highlightTimers = [] // setTimeout IDs for text-highlight scheduling
let rafId = null // requestAnimationFrame ID for progress bar
let chunkStartTimes = [] // scheduled startAt per chunk index
let chunkDurations = [] // audioBuf.duration per chunk index
let chunkFetches = []
let chunkTexts = []
let chunkElements = []
let lastHighlightedEl = null
let speculativeTitleFetch = null // pre-warmed chunk 0 (title) started on page load
function buildChunksWithDOM(contentSelector, title, maxLen = 300) {
const chunks = []
const elements = []
if (title) { chunks.push(title); elements.push(null) }
const contentEl = document.querySelector(contentSelector)
if (!contentEl) return { chunks, elements }
const nodes = contentEl.querySelectorAll('p, h2, h3, h4, blockquote, li')
let accText = ''
let accEl = null
function flush() {
if (accText) { chunks.push(accText); elements.push(accEl); accText = ''; accEl = null }
}
function addSplit(text, el) {
let rem = text
while (rem.length > 0) {
if (rem.length <= maxLen) { chunks.push(rem); elements.push(el); break }
let cut = rem.lastIndexOf('. ', maxLen)
if (cut < maxLen * 0.4) cut = rem.lastIndexOf(' ', maxLen)
if (cut < 0) cut = maxLen; else cut += 1
chunks.push(rem.slice(0, cut).trim()); elements.push(el)
rem = rem.slice(cut).trim()
}
}
for (const node of nodes) {
const text = (node.textContent || '').trim()
if (!text) continue
if (!accText) {
if (text.length > maxLen) { addSplit(text, node) }
else { accText = text; accEl = node }
} else if (accText.length + 1 + text.length <= maxLen) {
accText += ' ' + text
} else {
flush()
if (text.length > maxLen) { addSplit(text, node) }
else { accText = text; accEl = node }
}
}
flush()
return { chunks, elements }
}
function clearHighlight() {
if (lastHighlightedEl) {
lastHighlightedEl.style.removeProperty('background-color')
lastHighlightedEl.style.removeProperty('border-radius')
lastHighlightedEl.style.removeProperty('transition')
lastHighlightedEl = null
}
}
function highlightChunk(i) {
clearHighlight()
const el = chunkElements[i]
if (!el) return
el.style.backgroundColor = 'rgba(99, 102, 241, 0.10)'
el.style.borderRadius = '4px'
el.style.transition = 'background-color 0.35s ease'
lastHighlightedEl = el
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
function fetchOneChunk(text) {
return api.post('/tts', { text, style: 'blog' }, { responseType: 'blob', timeout: 90_000 })
.then(r => r.data)
.catch(e => ({ _ttsError: e?.response?.data?.message || e?.message || 'TTS unavailable' }))
}
// Creates (or re-uses) the AudioContext — must be called synchronously in the
// click handler so Safari's autoplay policy is satisfied.
function ensureAudioCtx() {
if (!audioCtx || audioCtx.state === 'closed') {
audioCtx = new AudioContext()
nextStartAt = audioCtx.currentTime
}
return audioCtx
}
function _clearTimers() {
highlightTimers.forEach(id => clearTimeout(id))
highlightTimers = []
}
function _stopRaf() {
if (rafId) { cancelAnimationFrame(rafId); rafId = null }
}
function _startRaf() {
_stopRaf()
function tick() {
const ctx = audioCtx
if (!ctx || ctx.state === 'closed') return
const now = ctx.currentTime
for (let j = 0; j < chunkStartTimes.length; j++) {
const st = chunkStartTimes[j]
const dur = chunkDurations[j]
if (st !== undefined && dur && now >= st && now < st + dur) {
ttsProgress.value = (j + (now - st) / dur) / ttsTotalChunks.value
break
}
}
rafId = requestAnimationFrame(tick)
}
rafId = requestAnimationFrame(tick)
}
// Decodes a WAV blob and schedules it to play back-to-back with the previous chunk.
// Returns the AudioBufferSourceNode (listen to .onended for completion).
async function _decodeAndSchedule(blob, i) {
const ctx = audioCtx
try {
const arrayBuf = await blob.arrayBuffer()
const audioBuf = await ctx.decodeAudioData(arrayBuf)
const source = ctx.createBufferSource()
source.buffer = audioBuf
source.connect(ctx.destination)
const startAt = Math.max(ctx.currentTime + 0.02, nextStartAt)
source.start(startAt)
nextStartAt = startAt + audioBuf.duration
chunkStartTimes[i] = startAt
chunkDurations[i] = audioBuf.duration
// Delay the text highlight until this chunk actually starts playing
const ms = Math.max(0, (startAt - ctx.currentTime) * 1000)
highlightTimers.push(setTimeout(() => highlightChunk(i), ms))
return source
} catch { return null }
}
async function runFrom(startIdx, session) {
for (let i = startIdx; i < ttsTotalChunks.value; i++) {
if (session !== sessionId) return
ttsChunkIdx.value = i
if (i === startIdx) ttsState.value = 'loading'
if (!chunkFetches[i]) chunkFetches[i] = fetchOneChunk(chunkTexts[i])
// 4-ahead prefetch — synthesis results sit in chunkFetches[] ready for decode
for (let p = 1; p <= 4; p++) {
const ahead = i + p
if (ahead < ttsTotalChunks.value && !chunkFetches[ahead])
chunkFetches[ahead] = fetchOneChunk(chunkTexts[ahead])
}
const result = await chunkFetches[i]
if (session !== sessionId) return
if (!result || result._ttsError) {
clearHighlight()
ttsState.value = 'error'
ttsError.value = result?._ttsError || 'TTS unavailable'
return
}
// Decode + schedule immediately; AudioContext queues it right after the previous chunk
const source = await _decodeAndSchedule(result, i)
if (session !== sessionId) return // player was closed/seeked while decoding
if (!source) {
clearHighlight()
ttsState.value = 'error'
ttsError.value = 'Audio decode failed'
return
}
if (i === startIdx) { ttsState.value = 'playing'; _startRaf() }
// Wait for chunk to finish; watchdog resolves if context is closed so we never hang
await new Promise(resolve => {
source.onended = resolve
const watchdog = setInterval(() => {
if (session !== sessionId || !audioCtx || audioCtx.state === 'closed') {
clearInterval(watchdog)
resolve(null)
}
}, 200)
source.addEventListener('ended', () => clearInterval(watchdog), { once: true })
})
if (session !== sessionId) return
}
clearHighlight()
_stopRaf()
_clearTimers()
ttsState.value = 'idle'
ttsProgress.value = 0
ttsChunkIdx.value = 0
}
async function openPlayer() {
playerOpen.value = true
ttsError.value = ''
if (ttsState.value !== 'idle' && ttsState.value !== 'error') return
ttsState.value = 'idle'
// Create AudioContext synchronously inside click handler — satisfies Safari autoplay policy
ensureAudioCtx()
await nextTick()
const { chunks, elements } = buildChunksWithDOM('.post-content', post.value.title, 200)
if (!chunks.length) return
chunkTexts = chunks
chunkElements = elements
ttsTotalChunks.value = chunks.length
ttsProgress.value = 0
ttsChunkIdx.value = 0
chunkFetches = new Array(chunks.length).fill(null)
nextStartAt = audioCtx.currentTime + 0.05
chunkStartTimes = []
chunkDurations = []
_clearTimers()
// Use speculative title fetch (started on page load) as chunk 0 if text matches
if (speculativeTitleFetch && chunks[0] === post.value.title) {
chunkFetches[0] = speculativeTitleFetch
}
speculativeTitleFetch = null
// Warm up remaining chunks in parallel
for (let k = 0; k < Math.min(4, chunks.length); k++)
if (!chunkFetches[k]) chunkFetches[k] = fetchOneChunk(chunkTexts[k])
ttsState.value = 'loading'
const session = ++sessionId
await runFrom(0, session)
}
function togglePlayPause() {
if (!audioCtx) return
if (ttsState.value === 'playing') { audioCtx.suspend(); ttsState.value = 'paused' }
else if (ttsState.value === 'paused') { audioCtx.resume(); ttsState.value = 'playing' }
}
function stopPlayback() {
sessionId++
_clearTimers()
_stopRaf()
clearHighlight()
if (audioCtx) { audioCtx.close(); audioCtx = null }
nextStartAt = 0; chunkStartTimes = []; chunkDurations = []
ttsState.value = 'idle'
ttsProgress.value = 0
ttsChunkIdx.value = 0
}
async function seekTo(fraction) {
if (!ttsTotalChunks.value || !chunkTexts.length) return
const target = Math.max(0, Math.min(Math.floor(fraction * ttsTotalChunks.value), ttsTotalChunks.value - 1))
_clearTimers()
_stopRaf()
clearHighlight()
if (audioCtx) { audioCtx.close(); audioCtx = null }
nextStartAt = 0; chunkStartTimes = []; chunkDurations = []
ttsProgress.value = target / ttsTotalChunks.value
ttsChunkIdx.value = target
ttsState.value = 'loading'
ensureAudioCtx()
const session = ++sessionId
await new Promise(r => setTimeout(r, 0))
if (session !== sessionId) return
await runFrom(target, session)
}
function closePlayer() {
sessionId++
_clearTimers()
_stopRaf()
clearHighlight()
if (audioCtx) { audioCtx.close(); audioCtx = null }
nextStartAt = 0
chunkFetches = []; chunkTexts = []; chunkElements = []
chunkStartTimes = []; chunkDurations = []
ttsTotalChunks.value = 0
ttsState.value = 'idle'
ttsProgress.value = 0
ttsChunkIdx.value = 0
playerOpen.value = false
}
async function applyHighlighting() {
await nextTick()
const codeBlocks = document.querySelectorAll('.post-content pre code')
const barePre = document.querySelectorAll('.post-content pre:not(:has(code))')
if (!codeBlocks.length && !barePre.length) return
const { default: hljs } = await import('highlight.js')
codeBlocks.forEach(block => hljs.highlightElement(block))
barePre.forEach(block => {
const code = document.createElement('code')
code.innerHTML = block.innerHTML
block.innerHTML = ''
block.appendChild(code)
hljs.highlightElement(code)
})
}
onUnmounted(() => {
sessionId++
_clearTimers()
_stopRaf()
if (audioCtx) { audioCtx.close(); audioCtx = null }
speculativeTitleFetch = null
})
onMounted(async () => {
try {
post.value = await blog.fetchPost(route.params.slug)
applyHighlighting()
// Pre-warm chunk 0 (the post title) so it's synthesised before the user clicks Listen.
// The title is short (~5-15 words) so synthesis finishes in ~1-3s in the background.
if (post.value?.title) speculativeTitleFetch = fetchOneChunk(post.value.title)
if (post.value.category) {
const res = await blog.fetchPosts({ category: post.value.category.slug, limit: 3 })
relatedPosts.value = res.posts.filter(p => p.id !== post.value.id).slice(0, 3)
}
const commRes = await api.get(`/comments/post/${post.value.id}`)
comments.value = commRes.data
} catch (e) { console.error(e) }
})
async function submitComment() {
await api.post(`/comments/post/${post.value.id}`, commentForm.value)
commentForm.value = { authorName: '', authorEmail: '', content: '' }
commentSubmitted.value = true
}
function getGallery() {
if (!post.value?.gallery) return []
try { return JSON.parse(post.value.gallery) } catch { return [] }
}
function formatDate(d) { return format(new Date(d), 'MMMM d, yyyy') }
// Inline template expressions compile to `_ctx.navigator`/`_ctx.window` (Vue's
// GLOBALS_ALLOWED list doesn't cover them), which is undefined on the
// component instance — calling this from a bound method instead resolves
// navigator/window as real globals.
function copyLink() {
navigator.clipboard.writeText(window.location.href)
}
</script>
<template>
<div :class="layout.variant === 'b' ? 'min-h-screen bg-[#0f172a]' : 'min-h-screen bg-white'">
<Navbar />
<main id="main-content" tabindex="-1" class="outline-none" :class="playerOpen ? 'pb-28 sm:pb-0' : ''">
<div v-if="blog.loading" class="max-w-4xl mx-auto px-4 py-12 animate-pulse">
<div class="h-8 rounded mb-4 w-3/4" :class="layout.variant === 'b' ? 'bg-slate-800' : 'bg-gray-200'"></div>
<div class="rounded-2xl aspect-[16/7] mb-8" :class="layout.variant === 'b' ? 'bg-slate-800' : 'bg-gray-200'"></div>
</div>
<article v-else-if="post" class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<!-- Category & meta -->
<div class="mb-6">
<RouterLink v-if="post.category" :to="`/category/${post.category.slug}`" class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm font-semibold text-white mb-4" :style="{ background: post.category.color || '#3B82F6' }">
{{ post.category.icon }} {{ post.category.name }}
</RouterLink>
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold leading-tight mb-6" :class="layout.variant === 'b' ? 'text-slate-100' : 'text-gray-900'" style="font-family:'Playfair Display',serif">{{ post.title }}</h1>
<div class="flex flex-wrap items-center gap-4 text-sm" :class="layout.variant === 'b' ? 'text-slate-400' : 'text-gray-500'">
<div class="flex items-center gap-2">
<div class="w-9 h-9 rounded-full flex items-center justify-center" :class="layout.variant === 'b' ? 'bg-violet-700' : 'bg-primary-600'"><span class="text-white font-bold">{{ (post.authorName||'A').charAt(0) }}</span></div>
<div><div class="font-semibold" :class="layout.variant === 'b' ? 'text-slate-200' : 'text-gray-800'">{{ post.authorName }}</div></div>
</div>
<span>·</span>
<span>{{ formatDate(post.createdAt) }}</span>
<span>·</span>
<span>{{ post.readTime }} min read</span>
<span>·</span>
<span class="flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
{{ post.views }} views
</span>
</div>
</div>
<!-- Mobile TTS trigger button — above hero image, sm:hidden -->
<div v-if="!playerOpen" class="sm:hidden mb-6">
<button @click="openPlayer"
class="flex items-center gap-2 px-4 py-2.5 text-white rounded-xl text-sm font-semibold transition-colors w-full justify-center"
:class="layout.variant === 'b' ? 'bg-violet-600 hover:bg-violet-700' : 'bg-primary-600 hover:bg-primary-700'">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0014 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>
Listen to this article
</button>
</div>
<!-- Featured Image -->
<div v-if="post.featuredImage" class="rounded-3xl overflow-hidden aspect-[16/7] mb-10">
<img :src="post.featuredImage" :alt="post.title" class="w-full h-full object-cover" />
</div>
<!-- Content -->
<div class="prose prose-lg max-w-none prose-headings:font-bold prose-img:rounded-2xl post-content"
:class="layout.variant === 'b' ? 'prose-invert prose-a:text-violet-400' : 'prose-gray prose-headings:text-gray-900 prose-a:text-primary-600'"
v-html="post.content" style="font-family:'Inter',sans-serif"></div>
<!-- Tags -->
<div v-if="post.tags?.length" class="flex flex-wrap gap-2 mt-10 pt-8" :class="layout.variant === 'b' ? 'border-t border-[#2d3f5f]' : 'border-t border-gray-100'">
<span v-for="tag in post.tags" :key="tag.id" class="px-3 py-1 rounded-full text-sm font-medium transition-colors cursor-pointer"
:class="layout.variant === 'b' ? 'bg-slate-800 text-slate-300 hover:bg-violet-900 hover:text-violet-300' : 'bg-gray-100 text-gray-600 hover:bg-primary-100 hover:text-primary-700'"
>#{{ tag.name }}</span>
</div>
<!-- Photo Gallery -->
<div v-if="getGallery().length" class="mt-12">
<h2 class="text-xl font-bold mb-4" :class="layout.variant === 'b' ? 'text-slate-100' : 'text-gray-900'" style="font-family:'Playfair Display',serif">Photo Gallery</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div v-for="(img, idx) in getGallery()" :key="idx" class="aspect-square rounded-xl overflow-hidden cursor-pointer group" @click="galleryOpen = true; galleryIndex = idx">
<img :src="img" :alt="`Gallery image ${idx + 1} of ${getGallery().length}`" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
</div>
</div>
</div>
<!-- Share -->
<div class="mt-12 p-6 rounded-2xl" :class="layout.variant === 'b' ? 'bg-[#162236] border border-[#2d3f5f]' : 'bg-gray-50'">
<p class="text-sm font-semibold mb-3" :class="layout.variant === 'b' ? 'text-slate-300' : 'text-gray-700'">Share this article</p>
<div class="flex gap-3">
<button class="flex items-center gap-2 px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors" :class="layout.variant === 'b' ? 'bg-slate-800 hover:bg-slate-700' : 'bg-gray-900 hover:bg-gray-700'">𝕏 Twitter</button>
<button class="flex items-center gap-2 px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors" :class="layout.variant === 'b' ? 'bg-violet-700 hover:bg-violet-800' : 'bg-primary-700 hover:bg-primary-800'">in LinkedIn</button>
<button @click="copyLink" class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors" :class="layout.variant === 'b' ? 'bg-slate-800 text-slate-300 hover:bg-slate-700' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'">🔗 Copy Link</button>
</div>
</div>
</article>
<!-- Related Posts -->
<section v-if="relatedPosts.length" class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-12">
<h2 class="text-2xl font-bold mb-6" :class="layout.variant === 'b' ? 'text-slate-100' : 'text-gray-900'" style="font-family:'Playfair Display',serif">Related Posts</h2>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
<PostCard v-for="p in relatedPosts" :key="p.id" :post="p" />
</div>
</section>
<!-- Comments -->
<section class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
<h2 class="text-2xl font-bold mb-6" :class="layout.variant === 'b' ? 'text-slate-100' : 'text-gray-900'" style="font-family:'Playfair Display',serif">{{ comments.length }} Comments</h2>
<div class="space-y-4 mb-10">
<div v-for="c in comments" :key="c.id" class="rounded-2xl p-5" :class="layout.variant === 'b' ? 'bg-[#162236] border border-[#2d3f5f]' : 'bg-gray-50'">
<div class="flex items-center gap-3 mb-3">
<div class="w-8 h-8 rounded-full flex items-center justify-center" :class="layout.variant === 'b' ? 'bg-violet-900' : 'bg-primary-100'">
<span class="font-bold text-sm" :class="layout.variant === 'b' ? 'text-violet-400' : 'text-primary-600'">{{ c.authorName.charAt(0) }}</span>
</div>
<div>
<div class="font-semibold text-sm" :class="layout.variant === 'b' ? 'text-slate-200' : 'text-gray-800'">{{ c.authorName }}</div>
<div class="text-xs" :class="layout.variant === 'b' ? 'text-slate-400' : 'text-gray-500'">{{ format(new Date(c.createdAt), 'MMM d, yyyy') }}</div>
</div>
</div>
<p class="text-sm" :class="layout.variant === 'b' ? 'text-slate-400' : 'text-gray-600'">{{ c.content }}</p>
</div>
</div>
<div v-if="commentSubmitted" class="bg-green-50 border border-green-200 rounded-2xl p-5 text-green-700 text-sm">
Your comment has been submitted and is awaiting approval. Thank you!
</div>
<form v-else @submit.prevent="submitComment" class="rounded-2xl p-6" :class="layout.variant === 'b' ? 'bg-[#162236] border border-[#2d3f5f]' : 'bg-gray-50'">
<h3 class="font-bold mb-4" :class="layout.variant === 'b' ? 'text-slate-100' : 'text-gray-900'">Leave a Comment</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<input v-model="commentForm.authorName" type="text" placeholder="Your Name *" required class="px-4 py-3 rounded-xl border text-sm focus:outline-none"
:class="layout.variant === 'b' ? 'bg-[#1c2d44] border-[#2d3f5f] text-slate-200 placeholder-slate-500 focus:border-violet-500' : 'bg-white border-gray-200 focus:border-primary-400'" />
<input v-model="commentForm.authorEmail" type="email" placeholder="Your Email" class="px-4 py-3 rounded-xl border text-sm focus:outline-none"
:class="layout.variant === 'b' ? 'bg-[#1c2d44] border-[#2d3f5f] text-slate-200 placeholder-slate-500 focus:border-violet-500' : 'bg-white border-gray-200 focus:border-primary-400'" />
</div>
<textarea v-model="commentForm.content" rows="4" placeholder="Write your comment..." required class="w-full px-4 py-3 rounded-xl border text-sm focus:outline-none resize-none mb-4"
:class="layout.variant === 'b' ? 'bg-[#1c2d44] border-[#2d3f5f] text-slate-200 placeholder-slate-500 focus:border-violet-500' : 'bg-white border-gray-200 focus:border-primary-400'"></textarea>
<button type="submit" class="text-white px-6 py-3 rounded-xl text-sm font-semibold transition-colors"
:class="layout.variant === 'b' ? 'bg-violet-600 hover:bg-violet-700' : 'bg-primary-600 hover:bg-primary-700'">Post Comment</button>
</form>
</section>
<!-- Gallery Lightbox -->
<div v-if="galleryOpen" class="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4" @click.self="galleryOpen=false">
<button @click="galleryOpen=false" aria-label="Close gallery" class="absolute top-4 right-4 text-white text-3xl leading-none w-10 h-10 flex items-center justify-center rounded-full hover:bg-white/20 transition-colors">&times;</button>
<button @click="galleryIndex = (galleryIndex - 1 + getGallery().length) % getGallery().length" aria-label="Previous image" class="absolute left-4 text-white text-3xl p-2 rounded-full hover:bg-white/20 transition-colors">&#8249;</button>
<img :src="getGallery()[galleryIndex]" :alt="`Gallery image ${galleryIndex + 1} of ${getGallery().length}`" class="max-h-[90vh] max-w-full rounded-2xl object-contain" />
<button @click="galleryIndex = (galleryIndex + 1) % getGallery().length" aria-label="Next image" class="absolute right-4 text-white text-3xl p-2 rounded-full hover:bg-white/20 transition-colors">&#8250;</button>
</div>
<!-- Mobile TTS fixed bottom bar — Teleported so scrollIntoView can't push it off screen -->
<Teleport to="body">
<div v-if="playerOpen && post" class="sm:hidden fixed bottom-0 left-0 right-0 z-50 shadow-2xl"
:class="layout.variant === 'b' ? 'bg-[#162236] border-t border-[#2d3f5f]' : 'bg-white border-t border-gray-200'">
<div class="px-4 pt-2 pb-4">
<!-- Title row -->
<div class="flex items-center gap-2 mb-2">
<div class="flex items-end gap-0.5 h-4 flex-shrink-0" aria-hidden="true">
<span v-for="i in 4" :key="i" class="w-1 rounded-full"
:class="[ttsState === 'playing' ? 'tts-bar' : 'h-1 opacity-40', layout.variant === 'b' ? 'bg-violet-500' : 'bg-primary-500']"
:style="ttsState === 'playing' ? `animation-delay:${i * 80}ms` : ''"></span>
</div>
<p class="text-xs font-semibold truncate flex-1" :class="layout.variant === 'b' ? 'text-slate-200' : 'text-gray-800'">{{ post.title }}</p>
<span v-if="ttsTotalChunks" class="text-xs flex-shrink-0" :class="layout.variant === 'b' ? 'text-slate-400' : 'text-gray-500'">{{ ttsChunkIdx + 1 }}/{{ ttsTotalChunks }}</span>
<button @click="closePlayer" class="flex-shrink-0 ml-2" :class="layout.variant === 'b' ? 'text-slate-500 hover:text-slate-300' : 'text-gray-400 hover:text-gray-600'" title="Close">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- Error state -->
<div v-if="ttsState === 'error'" class="flex items-center gap-2 mt-1">
<span class="text-xs flex-1" :class="layout.variant === 'b' ? 'text-red-400' : 'text-red-600'">Audio unavailable — {{ ttsError }}</span>
<button @click="openPlayer" class="text-xs underline flex-shrink-0" :class="layout.variant === 'b' ? 'text-violet-400' : 'text-primary-600'">Retry</button>
</div>
<template v-else>
<!-- Seek slider -->
<input type="range" min="0" max="100"
:value="Math.round(ttsProgress * 100)"
:disabled="ttsState === 'loading'"
class="tts-slider w-full mb-2"
@change="seekTo($event.target.value / 100)" />
<!-- Controls -->
<div class="flex items-center gap-2">
<button @click="stopPlayback" :disabled="ttsState === 'idle' || ttsState === 'loading'"
class="flex items-center justify-center w-8 h-8 rounded-lg transition-colors"
:class="ttsState === 'idle' || ttsState === 'loading'
? (layout.variant === 'b' ? 'text-slate-700 cursor-not-allowed' : 'text-gray-300 cursor-not-allowed')
: (layout.variant === 'b' ? 'text-slate-400 hover:text-slate-200 hover:bg-slate-800' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100')"
title="Stop">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>
</button>
<button @click="ttsState === 'loading' ? null : togglePlayPause()"
class="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-semibold transition-colors flex-1 justify-center"
:class="ttsState === 'loading'
? (layout.variant === 'b' ? 'bg-slate-800 text-slate-500 cursor-wait' : 'bg-gray-100 text-gray-400 cursor-wait')
: (layout.variant === 'b' ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-primary-600 text-white hover:bg-primary-700')">
<svg v-if="ttsState === 'loading'" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/></svg>
<svg v-else-if="ttsState === 'playing'" class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{{ ttsState === 'loading' ? 'Loading…' : ttsState === 'playing' ? 'Pause' : 'Resume' }}
</button>
</div>
</template>
</div>
</div>
</Teleport>
<!-- Desktop TTS sliding panel — fixed right side, hidden on mobile -->
<Teleport to="body">
<div v-if="post" class="hidden sm:flex fixed right-0 top-1/2 -translate-y-1/2 z-50 items-stretch drop-shadow-2xl">
<!-- Always-visible tab -->
<button @click="playerOpen ? closePlayer() : openPlayer()"
class="flex flex-col items-center justify-center gap-2 w-10 rounded-l-2xl py-5 transition-colors text-white"
:class="layout.variant === 'b'
? (playerOpen ? 'bg-violet-700' : 'bg-violet-600 hover:bg-violet-700')
: (playerOpen ? 'bg-primary-700' : 'bg-primary-600 hover:bg-primary-700')">
<svg class="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0014 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
<span class="text-[10px] font-bold tracking-wider" style="writing-mode:vertical-lr;transform:rotate(180deg)">
{{ playerOpen ? 'CLOSE' : 'LISTEN' }}
</span>
</button>
<!-- Sliding player panel -->
<div class="overflow-hidden transition-all duration-300 ease-in-out"
:style="playerOpen ? 'width:288px' : 'width:0'">
<div class="w-[288px] h-full flex flex-col p-5 gap-4"
:class="layout.variant === 'b' ? 'bg-[#162236] border-l border-[#2d3f5f]' : 'bg-white border-l border-gray-100'">
<!-- Header -->
<div class="flex items-start justify-between gap-2">
<div>
<p class="text-xs font-semibold uppercase tracking-wider mb-0.5" :class="layout.variant === 'b' ? 'text-violet-400' : 'text-primary-600'">Now reading</p>
<p class="text-sm font-bold leading-snug line-clamp-2" :class="layout.variant === 'b' ? 'text-slate-100' : 'text-gray-900'">{{ post.title }}</p>
</div>
<button @click="closePlayer" class="flex-shrink-0 mt-0.5" :class="layout.variant === 'b' ? 'text-slate-600 hover:text-slate-300' : 'text-gray-300 hover:text-gray-600'" title="Close">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- Waveform animation -->
<div class="flex items-end justify-center gap-1 h-10" aria-hidden="true">
<span v-for="i in 12" :key="i" class="w-1.5 rounded-full"
:class="[ttsState === 'playing' ? 'tts-bar' : 'h-1.5 opacity-30', layout.variant === 'b' ? 'bg-violet-400' : 'bg-primary-400']"
:style="ttsState === 'playing' ? `animation-delay:${i * 60}ms` : ''"></span>
</div>
<!-- Seek slider -->
<div>
<input type="range" min="0" max="100"
:value="Math.round(ttsProgress * 100)"
:disabled="ttsState === 'loading' || ttsState === 'idle'"
class="tts-slider w-full"
@change="seekTo($event.target.value / 100)" />
<div class="flex justify-between mt-1.5 text-xs" :class="layout.variant === 'b' ? 'text-slate-400' : 'text-gray-500'">
<span v-if="ttsTotalChunks">Segment {{ ttsChunkIdx + 1 }} / {{ ttsTotalChunks }}</span>
<span v-else></span>
<span>{{ Math.round(ttsProgress * 100) }}%</span>
</div>
</div>
<!-- Controls: Stop + Play/Pause -->
<div class="flex items-center justify-center gap-3">
<!-- Stop button -->
<button @click="stopPlayback"
:disabled="ttsState === 'idle' || ttsState === 'loading'"
class="flex items-center justify-center w-10 h-10 rounded-full border-2 transition-all"
:class="ttsState === 'idle' || ttsState === 'loading'
? (layout.variant === 'b' ? 'border-slate-800 text-slate-700 cursor-not-allowed' : 'border-gray-200 text-gray-300 cursor-not-allowed')
: (layout.variant === 'b' ? 'border-slate-600 text-slate-400 hover:border-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-600 hover:border-gray-500 hover:text-gray-800')"
title="Stop (return to beginning)">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
</button>
<!-- Play / Pause button -->
<button @click="ttsState === 'loading' ? null : togglePlayPause()"
class="flex items-center justify-center w-14 h-14 rounded-full transition-all"
:class="ttsState === 'loading'
? (layout.variant === 'b' ? 'bg-slate-800 text-slate-500 cursor-wait' : 'bg-gray-100 text-gray-400 cursor-wait')
: (layout.variant === 'b' ? 'bg-violet-600 text-white hover:bg-violet-700 hover:scale-105 active:scale-95' : 'bg-primary-600 text-white hover:bg-primary-700 hover:scale-105 active:scale-95')">
<svg v-if="ttsState === 'loading'" class="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
</svg>
<svg v-else-if="ttsState === 'playing'" class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
<svg v-else class="w-6 h-6 ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
</div>
<p class="text-xs text-center" :class="layout.variant === 'b' ? 'text-slate-400' : 'text-gray-500'">Powered by local AI</p>
</div>
</div>
</div>
</Teleport>
</main>
<Footer />
</div>
</template>
<style scoped>
@keyframes tts-wave {
0%, 100% { height: 4px; }
50% { height: 24px; }
}
.tts-bar {
animation: tts-wave 0.8s ease-in-out infinite;
}
/* Range slider — cross-browser consistent styling */
.tts-slider {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 9999px;
background: #e5e7eb;
outline: none;
cursor: pointer;
background-image: v-bind("'linear-gradient(' + ttsTrackColor + ', ' + ttsTrackColor + ')'");
background-size: v-bind("Math.round(ttsProgress * 100) + '% 100%'");
background-repeat: no-repeat;
}
.tts-slider:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.tts-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: v-bind(ttsThumbColor);
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0,0,0,.3);
transition: transform 0.1s;
}
.tts-slider:not(:disabled)::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.tts-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: v-bind(ttsThumbColor);
cursor: pointer;
border: 2px solid white;
box-shadow: 0 1px 3px rgba(0,0,0,.3);
}
.tts-slider::-moz-range-progress {
background: v-bind(ttsTrackColor);
height: 6px;
border-radius: 9999px;
}
</style>