From 5f28eaa179bfd9e99e6aec8075cc3deb38b66dd1 Mon Sep 17 00:00:00 2001 From: mahdiarghyani Date: Tue, 11 Nov 2025 16:52:54 +0330 Subject: [PATCH] feat(blog): Enhance blog content styling and readability - Add comprehensive blog content CSS with RTL support - Implement responsive typography and layout improvements - Create dedicated CSS for blog prose and content styling - Optimize code blocks, headings, and typography for better readability - Add scroll padding and responsive design considerations - Improve dark mode color contrast and styling - Enhance code and blockquote styling with better visual hierarchy --- app/assets/css/blog-content.css | 342 ++++++++++++++++ app/assets/css/main.css | 9 +- app/assets/css/prose.css | 388 +++++++++++++++++++ app/components/LanguageSwitcher.vue | 34 +- app/components/blog/BlogTableOfContents.vue | 117 +++--- app/components/blog/BlogTagFilter.vue | 29 +- app/components/content/Alert.vue | 5 +- app/components/content/ProseCode.vue | 32 +- app/pages/blog/[...slug].example.vue | 195 ++++++++++ app/pages/blog/[...slug].vue | 76 +--- content/fa/blog/nuxt-content-introduction.md | 4 + 11 files changed, 1077 insertions(+), 154 deletions(-) create mode 100644 app/assets/css/blog-content.css create mode 100644 app/assets/css/prose.css create mode 100644 app/pages/blog/[...slug].example.vue diff --git a/app/assets/css/blog-content.css b/app/assets/css/blog-content.css new file mode 100644 index 0000000..2901e62 --- /dev/null +++ b/app/assets/css/blog-content.css @@ -0,0 +1,342 @@ +/* Blog Content Styles - Optimized for Persian/RTL */ + +/* Base typography */ +.blog-content { + font-size: 1.125rem; /* 18px */ + line-height: 2; /* خط‌فاصله بیشتر */ + letter-spacing: 0.01em; + color: rgb(55, 65, 81); + max-width: 75ch; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; +} + +.dark .blog-content { + color: rgb(229, 231, 235); +} + +/* RTL-specific improvements */ +.blog-content-rtl { + line-height: 2.2 !important; /* فارسی نیاز به فاصله بیشتری داره */ + letter-spacing: 0.02em !important; +} + +/* Force LTR for code blocks in RTL */ +.blog-content-rtl pre, +.blog-content-rtl code { + direction: ltr; + text-align: left; +} + +/* Headings */ +.blog-content h1, +.blog-content h2, +.blog-content h3, +.blog-content h4 { + scroll-margin-top: 2.5rem; +} + +.blog-content h1 { + margin-top: 3rem; + margin-bottom: 2rem; + line-height: 1.4; + font-weight: 700; + font-size: 2.25em; + color: rgb(17, 24, 39); +} + +.dark .blog-content h1 { + color: rgb(243, 244, 246); +} + +.blog-content h2 { + margin-top: 3rem; + margin-bottom: 1.75rem; + line-height: 1.5; + font-weight: 700; + font-size: 1.875em; + color: rgb(17, 24, 39); +} + +.dark .blog-content h2 { + color: rgb(243, 244, 246); +} + +.blog-content h3 { + margin-top: 2.5rem; + margin-bottom: 1.5rem; + line-height: 1.6; + font-weight: 600; + font-size: 1.5em; + color: rgb(17, 24, 39); +} + +.dark .blog-content h3 { + color: rgb(243, 244, 246); +} + +.blog-content h4 { + margin-top: 2rem; + margin-bottom: 1.25rem; + line-height: 1.6; + font-weight: 600; + font-size: 1.25em; + color: rgb(17, 24, 39); +} + +.dark .blog-content h4 { + color: rgb(243, 244, 246); +} + +/* Paragraphs - فاصله زیاد برای خوانایی بهتر */ +.blog-content p { + margin-top: 2rem; + margin-bottom: 2rem; + line-height: inherit; +} + +/* First paragraph after heading */ +.blog-content h1 + p, +.blog-content h2 + p, +.blog-content h3 + p, +.blog-content h4 + p { + margin-top: 1.25rem; +} + +/* Lists */ +.blog-content ul, +.blog-content ol { + margin-top: 2.25rem; + margin-bottom: 2.25rem; + padding-left: 2rem; +} + +.blog-content-rtl ul, +.blog-content-rtl ol { + padding-left: 0; + padding-right: 2rem; +} + +.blog-content li { + margin-top: 1rem; + margin-bottom: 1rem; + line-height: 2; +} + +.blog-content-rtl li { + line-height: 2.2; +} + +.blog-content li p { + margin-top: 0.75rem; + margin-bottom: 0.75rem; +} + +/* Nested lists */ +.blog-content ul ul, +.blog-content ol ol, +.blog-content ul ol, +.blog-content ol ul { + margin-top: 1rem; + margin-bottom: 1rem; +} + +/* Strong text */ +.blog-content strong { + font-weight: 700; + color: rgb(17, 24, 39); +} + +.dark .blog-content strong { + color: rgb(243, 244, 246); +} + +/* Code blocks */ +.blog-content pre { + margin-top: 3rem; + margin-bottom: 3rem; + line-height: 1.7; + font-size: 0.9375rem; + overflow-x: auto; + max-width: 100%; +} + +@media (max-width: 1024px) { + .blog-content pre { + font-size: 0.875rem; + } +} + +/* Inline code */ +.blog-content code:not(pre code) { + padding: 0.25em 0.5em; + background-color: rgba(99, 102, 241, 0.1); + border-radius: 0.375rem; + font-size: 0.9em; + font-weight: 500; + color: rgb(79, 70, 229); +} + +.dark .blog-content code:not(pre code) { + background-color: rgba(99, 102, 241, 0.2); + color: rgb(165, 180, 252); +} + +/* Blockquotes */ +.blog-content blockquote { + margin-top: 3rem; + margin-bottom: 3rem; + padding: 1.75rem; + border-left: 4px solid rgb(99, 102, 241); + background-color: rgba(99, 102, 241, 0.05); + border-radius: 0.75rem; + font-style: italic; + line-height: 2; +} + +.blog-content-rtl blockquote { + border-left: none; + border-right: 4px solid rgb(99, 102, 241); +} + +.dark .blog-content blockquote { + background-color: rgba(99, 102, 241, 0.1); + border-left-color: rgb(129, 140, 248); +} + +.dark .blog-content-rtl blockquote { + border-right-color: rgb(129, 140, 248); +} + +.blog-content blockquote p { + margin-top: 1rem; + margin-bottom: 1rem; +} + +/* Links */ +.blog-content a { + color: rgb(99, 102, 241); + text-decoration: underline; + text-decoration-color: rgba(99, 102, 241, 0.3); + text-underline-offset: 0.25em; + font-weight: 500; + transition: all 0.2s ease; +} + +.blog-content a:hover { + color: rgb(79, 70, 229); + text-decoration-color: rgba(99, 102, 241, 0.8); +} + +.dark .blog-content a { + color: rgb(129, 140, 248); +} + +.dark .blog-content a:hover { + color: rgb(165, 180, 252); +} + +/* Horizontal rule */ +.blog-content hr { + margin-top: 3.5rem; + margin-bottom: 3.5rem; + border-color: rgba(148, 163, 184, 0.3); +} + +.dark .blog-content hr { + border-color: rgba(71, 85, 105, 0.5); +} + +/* Images */ +.blog-content img { + margin-top: 3rem; + margin-bottom: 3rem; + border-radius: 0.75rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); +} + +/* Tables */ +.blog-content table { + margin-top: 3rem; + margin-bottom: 3rem; + width: 100%; + border-collapse: collapse; + font-size: 0.9375rem; + display: block; + overflow-x: auto; + max-width: 100%; +} + +@media (max-width: 1024px) { + .blog-content table { + font-size: 0.875rem; + } +} + +.blog-content th, +.blog-content td { + padding: 0.875rem 1rem; + border: 1px solid rgba(148, 163, 184, 0.3); + line-height: 1.8; +} + +.blog-content th { + background-color: rgba(99, 102, 241, 0.1); + font-weight: 600; + color: rgb(17, 24, 39); +} + +.dark .blog-content th { + background-color: rgba(99, 102, 241, 0.15); + color: rgb(243, 244, 246); +} + +.dark .blog-content td { + border-color: rgba(71, 85, 105, 0.4); +} + +/* Images - prevent overflow */ +.blog-content img { + max-width: 100%; + height: auto; +} + +/* Prevent horizontal scroll on all elements */ +.blog-content * { + max-width: 100%; +} + +.blog-content pre, +.blog-content code, +.blog-content table { + max-width: 100%; +} + +/* Responsive */ +@media (max-width: 640px) { + .blog-content { + font-size: 1rem; + } + + .blog-content h1 { + font-size: 2em; + } + + .blog-content h2 { + font-size: 1.625em; + } + + .blog-content h3 { + font-size: 1.375em; + } + + .blog-content pre { + font-size: 0.8125rem; + margin-left: -1rem; + margin-right: -1rem; + padding-left: 1rem; + padding-right: 1rem; + border-radius: 0; + } +} diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 83017b6..69fd4b7 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -3,6 +3,8 @@ @import "tailwindcss"; @import "@nuxt/ui"; @import "./transitions.css"; +@import "./prose.css"; +@import "./blog-content.css"; @source "../../components/**/*.{vue,js,ts}"; @source "../../layouts/**/*.vue"; @@ -62,6 +64,7 @@ html { overflow-y: scroll; /* Avoid width variation */ + scroll-padding-top: 2rem; /* Offset for fixed navbar when using anchor links */ } html, @@ -159,14 +162,16 @@ } /* Hide scrollbars for overflow containers */ - .no-scrollbar { + .no-scrollbar, + .scrollbar-hide { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } - .no-scrollbar::-webkit-scrollbar { + .no-scrollbar::-webkit-scrollbar, + .scrollbar-hide::-webkit-scrollbar { display: none; /* Chrome, Safari, Opera */ } diff --git a/app/assets/css/prose.css b/app/assets/css/prose.css new file mode 100644 index 0000000..1e6b51a --- /dev/null +++ b/app/assets/css/prose.css @@ -0,0 +1,388 @@ +/* + * Enhanced Prose Styles for Blog Content + * Optimized for both LTR and RTL (Persian) content + */ + +/* Base prose container */ +.prose { + color: rgb(55, 65, 81); + max-width: 65ch; +} + +.dark .prose { + color: rgb(229, 231, 235); +} + +/* Typography scale */ +.prose { + font-size: 1.125rem; /* 18px */ + line-height: 2; +} + +/* RTL-specific adjustments */ +.prose[dir="rtl"] { + line-height: 2.1; + letter-spacing: 0.02em; +} + +/* Headings */ +.prose :where(h1):not(:where([class~="not-prose"] *)) { + color: rgb(17, 24, 39); + font-weight: 800; + font-size: 2.25em; + margin-top: 0; + margin-bottom: 0.8888889em; + line-height: 1.1111111; +} + +.dark .prose :where(h1):not(:where([class~="not-prose"] *)) { + color: rgb(243, 244, 246); +} + +.prose :where(h2):not(:where([class~="not-prose"] *)) { + color: rgb(17, 24, 39); + font-weight: 700; + font-size: 1.875em; + margin-top: 2em; + margin-bottom: 1em; + line-height: 1.3333333; +} + +.dark .prose :where(h2):not(:where([class~="not-prose"] *)) { + color: rgb(243, 244, 246); +} + +.prose :where(h3):not(:where([class~="not-prose"] *)) { + color: rgb(17, 24, 39); + font-weight: 600; + font-size: 1.5em; + margin-top: 1.6em; + margin-bottom: 0.6em; + line-height: 1.6; +} + +.dark .prose :where(h3):not(:where([class~="not-prose"] *)) { + color: rgb(243, 244, 246); +} + +.prose :where(h4):not(:where([class~="not-prose"] *)) { + color: rgb(17, 24, 39); + font-weight: 600; + font-size: 1.25em; + margin-top: 1.5em; + margin-bottom: 0.5em; + line-height: 1.6; +} + +.dark .prose :where(h4):not(:where([class~="not-prose"] *)) { + color: rgb(243, 244, 246); +} + +/* Paragraphs */ +.prose :where(p):not(:where([class~="not-prose"] *)) { + margin-top: 1.75em; + margin-bottom: 1.75em; + line-height: 2; +} + +/* Lead paragraph */ +.prose :where([class~="lead"]):not(:where([class~="not-prose"] *)) { + color: rgb(75, 85, 99); + font-size: 1.25em; + line-height: 1.8; + margin-top: 1.2em; + margin-bottom: 1.2em; +} + +.dark .prose :where([class~="lead"]):not(:where([class~="not-prose"] *)) { + color: rgb(209, 213, 219); +} + +/* Links */ +.prose :where(a):not(:where([class~="not-prose"] *)) { + color: rgb(99, 102, 241); + text-decoration: underline; + text-decoration-color: rgba(99, 102, 241, 0.3); + text-underline-offset: 0.25em; + font-weight: 500; + transition: all 0.2s ease; +} + +.prose :where(a):not(:where([class~="not-prose"] *)):hover { + color: rgb(79, 70, 229); + text-decoration-color: rgba(99, 102, 241, 0.8); +} + +.dark .prose :where(a):not(:where([class~="not-prose"] *)) { + color: rgb(129, 140, 248); +} + +.dark .prose :where(a):not(:where([class~="not-prose"] *)):hover { + color: rgb(165, 180, 252); +} + +/* Strong */ +.prose :where(strong):not(:where([class~="not-prose"] *)) { + color: rgb(17, 24, 39); + font-weight: 700; +} + +.dark .prose :where(strong):not(:where([class~="not-prose"] *)) { + color: rgb(243, 244, 246); +} + +/* Lists */ +.prose :where(ul):not(:where([class~="not-prose"] *)) { + list-style-type: disc; + margin-top: 2em; + margin-bottom: 2em; + padding-left: 1.625em; +} + +.prose[dir="rtl"] :where(ul):not(:where([class~="not-prose"] *)) { + padding-left: 0; + padding-right: 1.625em; +} + +.prose :where(ol):not(:where([class~="not-prose"] *)) { + list-style-type: decimal; + margin-top: 2em; + margin-bottom: 2em; + padding-left: 1.625em; +} + +.prose[dir="rtl"] :where(ol):not(:where([class~="not-prose"] *)) { + padding-left: 0; + padding-right: 1.625em; +} + +.prose :where(li):not(:where([class~="not-prose"] *)) { + margin-top: 0.75em; + margin-bottom: 0.75em; + line-height: 2; +} + +.prose :where(li > p):not(:where([class~="not-prose"] *)) { + margin-top: 0.75em; + margin-bottom: 0.75em; +} + +/* Nested lists */ +.prose :where(ul > li > *:first-child):not(:where([class~="not-prose"] *)) { + margin-top: 0.5em; +} + +.prose :where(ul > li > *:last-child):not(:where([class~="not-prose"] *)) { + margin-bottom: 0.5em; +} + +/* Blockquotes */ +.prose :where(blockquote):not(:where([class~="not-prose"] *)) { + font-weight: 500; + font-style: italic; + color: rgb(17, 24, 39); + border-left-width: 0.25rem; + border-left-color: rgb(99, 102, 241); + quotes: "\201C""\201D""\2018""\2019"; + margin-top: 2.5em; + margin-bottom: 2.5em; + padding-left: 1.5em; + padding-top: 1em; + padding-bottom: 1em; + background-color: rgba(99, 102, 241, 0.05); + border-radius: 0.5rem; + line-height: 2; +} + +.prose[dir="rtl"] :where(blockquote):not(:where([class~="not-prose"] *)) { + border-left: none; + border-right-width: 0.25rem; + border-right-color: rgb(99, 102, 241); + padding-left: 1em; + padding-right: 1.5em; +} + +.dark .prose :where(blockquote):not(:where([class~="not-prose"] *)) { + color: rgb(229, 231, 235); + border-left-color: rgb(129, 140, 248); + background-color: rgba(99, 102, 241, 0.1); +} + +.dark .prose[dir="rtl"] :where(blockquote):not(:where([class~="not-prose"] *)) { + border-right-color: rgb(129, 140, 248); +} + +.prose :where(blockquote p:first-of-type):not(:where([class~="not-prose"] *))::before { + content: open-quote; +} + +.prose :where(blockquote p:last-of-type):not(:where([class~="not-prose"] *))::after { + content: close-quote; +} + +/* Code */ +.prose :where(code):not(:where([class~="not-prose"], pre *)) { + color: rgb(17, 24, 39); + font-weight: 600; + font-size: 0.875em; + background-color: rgba(99, 102, 241, 0.1); + padding: 0.2em 0.4em; + border-radius: 0.25rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.dark .prose :where(code):not(:where([class~="not-prose"], pre *)) { + color: rgb(243, 244, 246); + background-color: rgba(99, 102, 241, 0.2); +} + +.prose :where(code):not(:where([class~="not-prose"], pre *))::before, +.prose :where(code):not(:where([class~="not-prose"], pre *))::after { + content: "`"; +} + +/* Pre/Code blocks */ +.prose :where(pre):not(:where([class~="not-prose"] *)) { + color: rgb(229, 231, 235); + background-color: rgb(31, 41, 55); + overflow-x: auto; + font-weight: 400; + font-size: 0.875em; + line-height: 1.7142857; + margin-top: 2.5em; + margin-bottom: 2.5em; + border-radius: 0.75rem; + padding: 1.5em; +} + +.prose :where(pre code):not(:where([class~="not-prose"] *)) { + background-color: transparent; + border-width: 0; + border-radius: 0; + padding: 0; + font-weight: inherit; + color: inherit; + font-size: inherit; + font-family: inherit; + line-height: inherit; +} + +.prose :where(pre code):not(:where([class~="not-prose"] *))::before, +.prose :where(pre code):not(:where([class~="not-prose"] *))::after { + content: none; +} + +/* Horizontal rules */ +.prose :where(hr):not(:where([class~="not-prose"] *)) { + border-color: rgb(229, 231, 235); + border-top-width: 1px; + margin-top: 3em; + margin-bottom: 3em; +} + +.dark .prose :where(hr):not(:where([class~="not-prose"] *)) { + border-color: rgb(55, 65, 81); +} + +/* Images */ +.prose :where(img):not(:where([class~="not-prose"] *)) { + margin-top: 2.5em; + margin-bottom: 2.5em; + border-radius: 0.75rem; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.prose :where(figure):not(:where([class~="not-prose"] *)) { + margin-top: 2.5em; + margin-bottom: 2.5em; +} + +.prose :where(figure > *):not(:where([class~="not-prose"] *)) { + margin-top: 0; + margin-bottom: 0; +} + +.prose :where(figcaption):not(:where([class~="not-prose"] *)) { + color: rgb(107, 114, 128); + font-size: 0.875em; + line-height: 1.7; + margin-top: 1em; + text-align: center; +} + +.dark .prose :where(figcaption):not(:where([class~="not-prose"] *)) { + color: rgb(156, 163, 175); +} + +/* Tables */ +.prose :where(table):not(:where([class~="not-prose"] *)) { + width: 100%; + table-layout: auto; + text-align: left; + margin-top: 2.5em; + margin-bottom: 2.5em; + font-size: 0.875em; + line-height: 1.7142857; + border-collapse: collapse; +} + +.prose :where(thead):not(:where([class~="not-prose"] *)) { + border-bottom-width: 1px; + border-bottom-color: rgb(209, 213, 219); +} + +.dark .prose :where(thead):not(:where([class~="not-prose"] *)) { + border-bottom-color: rgb(75, 85, 99); +} + +.prose :where(thead th):not(:where([class~="not-prose"] *)) { + color: rgb(17, 24, 39); + font-weight: 600; + vertical-align: bottom; + padding-right: 0.75em; + padding-bottom: 0.75em; + padding-left: 0.75em; + background-color: rgba(99, 102, 241, 0.05); +} + +.dark .prose :where(thead th):not(:where([class~="not-prose"] *)) { + color: rgb(243, 244, 246); + background-color: rgba(99, 102, 241, 0.1); +} + +.prose :where(tbody tr):not(:where([class~="not-prose"] *)) { + border-bottom-width: 1px; + border-bottom-color: rgb(229, 231, 235); +} + +.dark .prose :where(tbody tr):not(:where([class~="not-prose"] *)) { + border-bottom-color: rgb(55, 65, 81); +} + +.prose :where(tbody tr:last-child):not(:where([class~="not-prose"] *)) { + border-bottom-width: 0; +} + +.prose :where(tbody td):not(:where([class~="not-prose"] *)) { + vertical-align: baseline; + padding: 0.75em; +} + +/* Responsive adjustments */ +@media (max-width: 640px) { + .prose { + font-size: 1rem; + } + + .prose :where(h1):not(:where([class~="not-prose"] *)) { + font-size: 2em; + } + + .prose :where(h2):not(:where([class~="not-prose"] *)) { + font-size: 1.625em; + } + + .prose :where(h3):not(:where([class~="not-prose"] *)) { + font-size: 1.375em; + } +} diff --git a/app/components/LanguageSwitcher.vue b/app/components/LanguageSwitcher.vue index 505f024..14aeb22 100644 --- a/app/components/LanguageSwitcher.vue +++ b/app/components/LanguageSwitcher.vue @@ -54,7 +54,7 @@ const selectedIcon = computed(() => items.value.find(i => i.value === mo const { startLocaleSwitching } = useLocaleSwitching() const loading = useLoadingIndicator() -// On selection change, update locale and navigate +// On selection change, navigate first then update locale watch(model, async (val, oldVal) => { if (val === oldVal) return @@ -66,25 +66,39 @@ watch(model, async (val, oldVal) => { loading.start() } - // Update locale - await setLocale(val) - // Get the current route path without locale prefix const currentPath = router.currentRoute.value.path const pathWithoutLocale = currentPath.replace(/^\/(en|fa)/, '') - // Build new path with new locale - const newLocalePrefix = val === 'en' ? '' : `/${val}` - const newPath = `${newLocalePrefix}${pathWithoutLocale || '/'}` + // Check if we're on a blog post page + const isBlogPost = pathWithoutLocale.startsWith('/blog/') && pathWithoutLocale !== '/blog' && pathWithoutLocale !== '/blog/' - // Navigate to new path + let newPath: string + + if (isBlogPost) { + // If on a blog post, redirect to blog listing page in the new locale + newPath = val === 'en' ? '/blog' : `/${val}/blog` + } else { + // For other pages, try to navigate to the equivalent page + const newLocalePrefix = val === 'en' ? '' : `/${val}` + newPath = `${newLocalePrefix}${pathWithoutLocale || '/'}` + } + + // Navigate to new path FIRST (before setLocale to avoid RTL/LTR flash) if (newPath !== currentPath) { await router.push(newPath) } - // Restore scroll position after navigation + // Update locale AFTER navigation + await setLocale(val) + + // Restore scroll position after navigation (only if not redirecting from blog post) await nextTick() - window.scrollTo(0, scrollY) + if (!isBlogPost) { + window.scrollTo(0, scrollY) + } else { + window.scrollTo(0, 0) // Scroll to top when redirecting to blog listing + } if (loading) { setTimeout(() => loading.finish(), 600) diff --git a/app/components/blog/BlogTableOfContents.vue b/app/components/blog/BlogTableOfContents.vue index c6a3724..03894ff 100644 --- a/app/components/blog/BlogTableOfContents.vue +++ b/app/components/blog/BlogTableOfContents.vue @@ -10,6 +10,7 @@ const props = defineProps<{ toc: { links: TocLink[] } + mobile?: boolean }>() const { t } = useI18n() @@ -25,33 +26,48 @@ const shouldShowToc = computed(() => { return countLinks(props.toc.links) >= 3 }) -// Smooth scroll to heading +// Smooth scroll to heading with offset for sticky header const scrollToHeading = (id: string) => { const element = document.getElementById(id) if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }) + const offset = 100 // Offset for sticky header + const elementPosition = element.getBoundingClientRect().top + window.pageYOffset + const offsetPosition = elementPosition - offset + + window.scrollTo({ + top: offsetPosition, + behavior: 'smooth' + }) + activeId.value = id } } // Track active section with IntersectionObserver onMounted(() => { + const headings = document.querySelectorAll('article h2, article h3, article h4') + const observer = new IntersectionObserver( (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - activeId.value = entry.target.id + // Find the first intersecting heading + const intersecting = entries.filter(entry => entry.isIntersecting) + if (intersecting.length > 0) { + // Sort by position and get the topmost one + const topmost = intersecting.sort((a, b) => + a.boundingClientRect.top - b.boundingClientRect.top + )[0] + if (topmost && topmost.target.id) { + activeId.value = topmost.target.id } - }) + } }, { - rootMargin: '-80px 0px -80% 0px', - threshold: 0 + rootMargin: '-100px 0px -66% 0px', + threshold: [0, 0.25, 0.5, 0.75, 1] } ) // Observe all headings - const headings = document.querySelectorAll('article h2, article h3, article h4') headings.forEach((heading) => observer.observe(heading)) // Cleanup @@ -59,51 +75,50 @@ onMounted(() => { headings.forEach((heading) => observer.unobserve(heading)) }) }) - -// Render TOC links recursively -const renderTocLinks = (links: TocLink[]) => { - return links -} @@ -26,7 +38,6 @@ const emit = defineEmits<{ const { t } = useI18n() const route = useRoute() -const router = useRouter() // Read query parameter on mount to restore filter state onMounted(() => { diff --git a/app/components/content/Alert.vue b/app/components/content/Alert.vue index 7989a10..34fa069 100644 --- a/app/components/content/Alert.vue +++ b/app/components/content/Alert.vue @@ -1,7 +1,8 @@ - - diff --git a/content/fa/blog/nuxt-content-introduction.md b/content/fa/blog/nuxt-content-introduction.md index 01b896e..ffa13f5 100644 --- a/content/fa/blog/nuxt-content-introduction.md +++ b/content/fa/blog/nuxt-content-introduction.md @@ -11,6 +11,10 @@ draft: false Nuxt Content یک سیستم مدیریت محتوای فایل‌محور قدرتمند است که به شما امکان می‌دهد محتوای خود را به صورت Markdown، YAML، CSV یا JSON بنویسید و با یک API شبیه MongoDB آن را جستجو کنید. +::blog-callout{type="info"} +این مقاله یک راهنمای جامع برای شروع کار با Nuxt Content است. اگر تازه شروع کرده‌اید، این مقاله برای شما مناسب است! +:: + ## چرا Nuxt Content؟ Nuxt Content مزایای متعددی برای برنامه‌های محتوا-محور ارائه می‌دهد: