feat(renderer): full article reader with TOC, font controls & print (M10.1)

- ArticleReader.vue: sticky TOC sidebar (desktop), mobile dropdown
- Auto-generated heading anchors with IntersectionObserver tracking
- Reading time estimate, font size controls (persisted localStorage)
- Print mode opens clean window with serif typography
- Long-form detection (>800 words + headings) shows "Read as article"
- Removed isDark conditionals from ArticleDetail (dark-only app)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:48:00 +00:00
co-authored by Claude Opus 4.6
parent aeb184b930
commit edb4bbadcd
5 changed files with 297 additions and 12 deletions
@@ -32,14 +32,13 @@
<div class="p-4 space-y-4">
<article
v-if="article.content"
class="[&_p]:mb-3 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:ml-4 [&_a]:underline [&_a]:underline-offset-2 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic"
:class="isDark ? 'text-white/90' : 'text-gray-900'"
class="text-white/90 [&_p]:mb-3 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:ml-4 [&_a]:underline [&_a]:underline-offset-2 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic"
>
<div v-html="sanitizedContent" />
</article>
<div v-else class="py-4">
<p class="text-sm" :class="isDark ? 'text-white/50' : 'text-gray-500'">
<p class="text-sm text-white/50">
Full article content is not available. Open the link below to read on the source site.
</p>
</div>
@@ -49,10 +48,7 @@
:href="article.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 p-3 rounded-xl transition-colors"
:class="isDark
? 'bg-white/10 hover:bg-white/15 text-white/90'
: 'bg-black/5 hover:bg-black/10 text-gray-800'"
class="inline-flex items-center gap-2 p-3 rounded-xl transition-colors bg-white/10 hover:bg-white/15 text-white/90"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
@@ -66,14 +62,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { WebSearchResult } from '@aiui/core/types/message'
import { useTheme } from '@/composables/useTheme'
import { isSafeImgSrc, sanitizeHtml, escapeHtml } from '@/utils/html'
const props = defineProps<{ article: WebSearchResult }>()
defineEmits<{ back: [] }>()
const { isDark } = useTheme()
const articleDomain = computed(() => {
const url = props.article?.url
if (!url || typeof url !== 'string') return ''
@@ -87,6 +87,12 @@
:article="selectedArticle"
@back="closeArticleDetail"
/>
<ArticleReader
v-else-if="longFormArticle"
:content="longFormArticle.content"
:title="longFormArticle.title"
@back="closeLongFormArticle"
/>
<!-- Grid views by active tab -->
<component
@@ -170,6 +176,7 @@ import PodcastGrid from './PodcastGrid.vue'
import PodcastDetail from './PodcastDetail.vue'
import NewsGrid from './NewsGrid.vue'
import ArticleDetail from './ArticleDetail.vue'
import ArticleReader from '@/components/renderers/ArticleReader.vue'
import MagazineGrid from './MagazineGrid.vue'
import ProjectGrid from './ProjectGrid.vue'
import NostrGrid from './NostrGrid.vue'
@@ -218,11 +225,13 @@ const {
openPodcastDetail,
closePodcastDetail,
closeArticleDetail,
longFormArticle,
closeLongFormArticle,
closePanel,
} = useContentPanel()
const hasDetailOpen = computed(() =>
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedDesignSystemItem.value)
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedDesignSystemItem.value || longFormArticle.value)
)
const windowWidth = ref(window.innerWidth)