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
This commit is contained in:
mahdiarghyani
2025-11-11 16:52:54 +03:30
parent 56f6a11285
commit 5f28eaa179
11 changed files with 1077 additions and 154 deletions

View File

@@ -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;
}
}

View File

@@ -3,6 +3,8 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@nuxt/ui"; @import "@nuxt/ui";
@import "./transitions.css"; @import "./transitions.css";
@import "./prose.css";
@import "./blog-content.css";
@source "../../components/**/*.{vue,js,ts}"; @source "../../components/**/*.{vue,js,ts}";
@source "../../layouts/**/*.vue"; @source "../../layouts/**/*.vue";
@@ -62,6 +64,7 @@
html { html {
overflow-y: scroll; overflow-y: scroll;
/* Avoid width variation */ /* Avoid width variation */
scroll-padding-top: 2rem; /* Offset for fixed navbar when using anchor links */
} }
html, html,
@@ -159,14 +162,16 @@
} }
/* Hide scrollbars for overflow containers */ /* Hide scrollbars for overflow containers */
.no-scrollbar { .no-scrollbar,
.scrollbar-hide {
-ms-overflow-style: none; -ms-overflow-style: none;
/* IE and Edge */ /* IE and Edge */
scrollbar-width: none; scrollbar-width: none;
/* Firefox */ /* Firefox */
} }
.no-scrollbar::-webkit-scrollbar { .no-scrollbar::-webkit-scrollbar,
.scrollbar-hide::-webkit-scrollbar {
display: none; display: none;
/* Chrome, Safari, Opera */ /* Chrome, Safari, Opera */
} }

388
app/assets/css/prose.css Normal file
View File

@@ -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;
}
}

View File

@@ -54,7 +54,7 @@ const selectedIcon = computed<string>(() => items.value.find(i => i.value === mo
const { startLocaleSwitching } = useLocaleSwitching() const { startLocaleSwitching } = useLocaleSwitching()
const loading = useLoadingIndicator() const loading = useLoadingIndicator()
// On selection change, update locale and navigate // On selection change, navigate first then update locale
watch(model, async (val, oldVal) => { watch(model, async (val, oldVal) => {
if (val === oldVal) return if (val === oldVal) return
@@ -66,25 +66,39 @@ watch(model, async (val, oldVal) => {
loading.start() loading.start()
} }
// Update locale
await setLocale(val)
// Get the current route path without locale prefix // Get the current route path without locale prefix
const currentPath = router.currentRoute.value.path const currentPath = router.currentRoute.value.path
const pathWithoutLocale = currentPath.replace(/^\/(en|fa)/, '') const pathWithoutLocale = currentPath.replace(/^\/(en|fa)/, '')
// Build new path with new locale // Check if we're on a blog post page
const newLocalePrefix = val === 'en' ? '' : `/${val}` const isBlogPost = pathWithoutLocale.startsWith('/blog/') && pathWithoutLocale !== '/blog' && pathWithoutLocale !== '/blog/'
const newPath = `${newLocalePrefix}${pathWithoutLocale || '/'}`
// 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) { if (newPath !== currentPath) {
await router.push(newPath) 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() 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) { if (loading) {
setTimeout(() => loading.finish(), 600) setTimeout(() => loading.finish(), 600)

View File

@@ -10,6 +10,7 @@ const props = defineProps<{
toc: { toc: {
links: TocLink[] links: TocLink[]
} }
mobile?: boolean
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@@ -25,33 +26,48 @@ const shouldShowToc = computed(() => {
return countLinks(props.toc.links) >= 3 return countLinks(props.toc.links) >= 3
}) })
// Smooth scroll to heading // Smooth scroll to heading with offset for sticky header
const scrollToHeading = (id: string) => { const scrollToHeading = (id: string) => {
const element = document.getElementById(id) const element = document.getElementById(id)
if (element) { 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 activeId.value = id
} }
} }
// Track active section with IntersectionObserver // Track active section with IntersectionObserver
onMounted(() => { onMounted(() => {
const headings = document.querySelectorAll('article h2, article h3, article h4')
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { // Find the first intersecting heading
if (entry.isIntersecting) { const intersecting = entries.filter(entry => entry.isIntersecting)
activeId.value = entry.target.id 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', rootMargin: '-100px 0px -66% 0px',
threshold: 0 threshold: [0, 0.25, 0.5, 0.75, 1]
} }
) )
// Observe all headings // Observe all headings
const headings = document.querySelectorAll('article h2, article h3, article h4')
headings.forEach((heading) => observer.observe(heading)) headings.forEach((heading) => observer.observe(heading))
// Cleanup // Cleanup
@@ -59,51 +75,50 @@ onMounted(() => {
headings.forEach((heading) => observer.unobserve(heading)) headings.forEach((heading) => observer.unobserve(heading))
}) })
}) })
// Render TOC links recursively
const renderTocLinks = (links: TocLink[]) => {
return links
}
</script> </script>
<template> <template>
<aside v-if="shouldShowToc" class="toc-container"> <div v-if="shouldShowToc">
<!-- Desktop: Sticky sidebar --> <!-- Desktop TOC -->
<nav class="hidden lg:block sticky top-24 max-h-[calc(100vh-8rem)] overflow-y-auto"> <div v-if="!mobile"
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100"> class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4 max-h-[calc(100vh-7rem)] overflow-y-auto">
<h3 class="text-sm font-semibold mb-3 text-gray-900 dark:text-gray-100 uppercase tracking-wide">
{{ t('blog.tableOfContents') }} {{ t('blog.tableOfContents') }}
</h3> </h3>
<ul class="space-y-2 text-sm"> <nav>
<template v-for="link in toc.links" :key="link.id"> <ul class="space-y-1 text-sm">
<li> <template v-for="link in toc.links" :key="link.id">
<a :href="`#${link.id}`" :class="[ <li>
'block py-1 transition-colors', <a :href="`#${link.id}`" :class="[
activeId === link.id 'block py-1.5 px-2 rounded transition-all',
? 'text-primary-600 dark:text-primary-400 font-medium' activeId === link.id
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100' ? 'text-primary-600 dark:text-primary-400 font-medium bg-primary-50 dark:bg-primary-900/20'
]" @click.prevent="scrollToHeading(link.id)"> : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800/50'
{{ link.text }} ]" @click.prevent="scrollToHeading(link.id)">
</a> {{ link.text }}
<!-- Nested children (h3) --> </a>
<ul v-if="link.children && link.children.length > 0" class="ml-4 mt-1 space-y-1"> <!-- Nested children (h3) -->
<li v-for="child in link.children" :key="child.id"> <ul v-if="link.children && link.children.length > 0"
<a :href="`#${child.id}`" :class="[ class="ml-3 mt-0.5 space-y-0.5 border-l-2 border-gray-200 dark:border-gray-700 pl-3">
'block py-1 transition-colors', <li v-for="child in link.children" :key="child.id">
activeId === child.id <a :href="`#${child.id}`" :class="[
? 'text-primary-600 dark:text-primary-400 font-medium' 'block py-1 px-2 rounded text-xs transition-all',
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100' activeId === child.id
]" @click.prevent="scrollToHeading(child.id)"> ? 'text-primary-600 dark:text-primary-400 font-medium bg-primary-50 dark:bg-primary-900/20'
{{ child.text }} : 'text-gray-500 dark:text-gray-500 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800/50'
</a> ]" @click.prevent="scrollToHeading(child.id)">
</li> {{ child.text }}
</ul> </a>
</li> </li>
</template> </ul>
</ul> </li>
</nav> </template>
</ul>
</nav>
</div>
<!-- Mobile: Collapsible accordion --> <!-- Mobile: Collapsible accordion -->
<UAccordion class="lg:hidden mb-6" :items="[ <UAccordion v-if="mobile" :items="[
{ {
label: t('blog.tableOfContents'), label: t('blog.tableOfContents'),
icon: 'i-heroicons-list-bullet', icon: 'i-heroicons-list-bullet',
@@ -141,11 +156,5 @@ const renderTocLinks = (links: TocLink[]) => {
</ul> </ul>
</template> </template>
</UAccordion> </UAccordion>
</aside> </div>
</template> </template>
<style scoped>
.toc-container {
@apply w-full;
}
</style>

View File

@@ -1,15 +1,27 @@
<template> <template>
<div v-if="tags.length > 0" class="overflow-x-auto"> <div v-if="tags.length > 0" class="overflow-x-auto scrollbar-hide -mx-1 px-1">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-1.5 py-1">
<UButton :color="!modelValue ? 'primary' : 'gray'" :variant="!modelValue ? 'solid' : 'soft'" size="sm" <!-- All Posts Badge -->
@click="selectTag(null)"> <button :class="[
'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
'ring-1 ring-inset',
!modelValue
? 'bg-primary-500 text-white ring-primary-500 shadow-sm'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 ring-gray-200 dark:ring-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 hover:ring-gray-300 dark:hover:ring-gray-600 hover:shadow-sm hover:scale-105'
]" @click="selectTag(null)">
{{ t('blog.allPosts') }} {{ t('blog.allPosts') }}
</UButton> </button>
<UButton v-for="tag in tags" :key="tag" :color="modelValue === tag ? 'primary' : 'gray'" <!-- Tag Badges -->
:variant="modelValue === tag ? 'solid' : 'soft'" size="sm" @click="selectTag(tag)"> <button v-for="tag in tags" :key="tag" :class="[
'px-3 py-1 rounded-full text-xs font-medium transition-all duration-200',
'ring-1 ring-inset',
modelValue === tag
? 'bg-primary-500 text-white ring-primary-500 shadow-sm'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 ring-gray-200 dark:ring-gray-700 hover:bg-gray-200 dark:hover:bg-gray-700 hover:ring-gray-300 dark:hover:ring-gray-600 hover:shadow-sm hover:scale-105'
]" @click="selectTag(tag)">
{{ tag }} {{ tag }}
</UButton> </button>
</div> </div>
</div> </div>
</template> </template>
@@ -26,7 +38,6 @@ const emit = defineEmits<{
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
const router = useRouter()
// Read query parameter on mount to restore filter state // Read query parameter on mount to restore filter state
onMounted(() => { onMounted(() => {

View File

@@ -1,7 +1,8 @@
<template> <template>
<div :class="alertClass" class="my-4 flex items-center gap-2 rounded-lg px-4 py-3"> <div :class="alertClass"
class="my-6 flex items-center gap-3 rounded-xl px-4 py-3.5 ring-1 ring-inset backdrop-blur-sm">
<UIcon :name="icon" class="h-5 w-5 flex-shrink-0" /> <UIcon :name="icon" class="h-5 w-5 flex-shrink-0" />
<div class="flex-1 text-sm"> <div class="flex-1 text-sm leading-relaxed">
<slot /> <slot />
</div> </div>
</div> </div>

View File

@@ -69,31 +69,31 @@ const languageLabel = computed(() => {
.code-block-header { .code-block-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
padding: 1rem 1.5rem; padding: 0.875rem 1.5rem;
background: rgba(30, 41, 59, 0.8); background: rgba(30, 41, 59, 0.8);
border-bottom: 1px solid rgba(71, 85, 105, 0.3); border-bottom: 1px solid rgba(71, 85, 105, 0.3);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
min-height: 3rem; min-height: 2.75rem;
} }
.code-filename { .code-filename {
font-size: 0.9375rem; font-size: 0.875rem;
color: #e2e8f0; color: #e2e8f0;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-weight: 500; font-weight: 500;
} }
.code-language { .code-language {
font-size: 0.8125rem; font-size: 0.75rem;
color: #94a3b8; color: #cbd5e1;
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.05em;
padding: 0.375rem 0.75rem; padding: 0.25rem 0.625rem;
background: rgba(99, 102, 241, 0.15); background: rgba(99, 102, 241, 0.2);
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid rgba(99, 102, 241, 0.3); border: 1px solid rgba(99, 102, 241, 0.4);
} }
.code-block-content { .code-block-content {
@@ -177,14 +177,12 @@ const languageLabel = computed(() => {
<div class="code-block-wrapper" @mouseenter="isHovered = true" @mouseleave="isHovered = false"> <div class="code-block-wrapper" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
<!-- Header with filename and language --> <!-- Header with filename and language -->
<div v-if="filename || language" class="code-block-header"> <div v-if="filename || language" class="code-block-header">
<div class="flex items-center gap-3"> <span v-if="filename" class="code-filename">
<span v-if="filename" class="code-filename"> {{ filename }}
{{ filename }} </span>
</span> <span v-if="language && !filename" class="code-language">
<span v-if="language && !filename" class="code-language"> {{ languageLabel }}
{{ languageLabel }} </span>
</span>
</div>
</div> </div>
<!-- Code block --> <!-- Code block -->

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
const route = useRoute()
const { data: page, error } = await useAsyncData(route.path, () => queryContent(route.path).findOne())
if (error.value) {
throw createError({
statusCode: 404,
message: 'Page not found',
fatal: true,
})
}
useSeoMeta({
title: page.value?.title,
description: page.value?.description,
ogTitle: page.value?.title,
ogDescription: page.value?.description,
})
const { data: relatedArticles } = await useAsyncData(`content:related-articles:${page.value?.title}`, () => queryContent('/articles').where({ categories: { $in: page.value?.categories }, _extension: 'md' }).only(['_path', 'title', 'categories', 'description', 'publishedAt', 'image', 'authors']).sort({ publishedAt: -1 }).limit(3).find())
const appConfig = useAppConfig()
const runtimeConfig = useRuntimeConfig()
</script>
<template>
<UContainer
v-if="page"
>
<UPage>
<UPageHeader
class="max-w-5xl mx-auto"
:title="page.title"
:description="page.description"
>
<template #headline>
<!-- Waiting for https://github.com/nuxt/ui-pro/issues/114 -->
<!-- <dl>
<dt class="sr-only">
Categories
</dt>
<dd> -->
<template
v-for="(category, index) in page.categories"
:key="category"
>
<ULink
:to="`/categories/${category}`"
>
{{ formatCategory(category) }}
</ULink>
<span v-if="index < page.categories.length - 1">
-
</span>
</template>
<!-- </dd>
</dl> -->
</template>
<NuxtImg
v-if="page.image"
:src="page.image.src"
:alt="page.image.alt"
class="mt-8 w-full object-cover rounded-lg aspect-[16/9]"
>
<dl class="mt-8 flex justify-between text-stone-700 text-sm">
<dt class="sr-only">
Author
</dt>
<dd>
<ol class="space-x-4">
<li
v-for="author in page.authors"
:key="author.name"
>
<ULink
:to="author.social"
target="_blank"
rel="noopener"
class="flex items-center gap-2"
>
<UAvatar
:src="author.avatar"
:alt="author.name"
size="sm"
/>
<span>
{{ author.name }}
</span>
</ULink>
</li>
</ol>
</dd>
<dt class="sr-only">
Published at
</dt>
<dd>
<time :datetime="page.publishedAt">
{{ formatDate(page.publishedAt) }}
</time>
</dd>
</dl>
</nuxtimg>
</UPageHeader>
<div class="mt-8 max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-[96px_768px_1fr]">
<div class="lg:px-8 flex lg:flex-col lg:items-end gap-2">
<UTooltip text="Share on X">
<UButton
:to="`https://twitter.com/share?text=${page.title}&url=${runtimeConfig.app.name}${page._path}`"
target="_blank"
icon="i-simple-icons-x"
size="sm"
color="primary"
square
variant="ghost"
/>
</UTooltip>
<UTooltip text="Share on Facebook">
<UButton
:to="`https://www.facebook.com/sharer/sharer.php?u=${runtimeConfig.app.name}${page._path}&t=${page.title}`"
target="_blank"
icon="i-simple-icons-facebook"
size="sm"
color="primary"
square
variant="ghost"
/>
</UTooltip>
<UTooltip text="Share on LinkedIn">
<UButton
:to="`https://www.linkedin.com/shareArticle?url=${runtimeConfig.app.name}${page._path}&title=${page.title}`"
target="_blank"
icon="i-simple-icons-linkedin"
size="sm"
color="primary"
square
variant="ghost"
/>
</UTooltip>
</div>
<div class="mt-8 lg:mt-0 w-full">
<UPageBody
prose
:ui="{ wrapper: 'mt-0' }"
>
<ContentRenderer :value="page" />
</UPageBody>
</div>
<div class="row-start-2 lg:row-start-1 lg:col-start-3 lg:px-8 space-y-8">
<UButton
v-bind="appConfig.page.article.cta"
color="primary"
size="lg"
:ui="{ base: 'w-full justify-center' }"
class="hidden lg:inline-flex"
/>
<UDivider />
<UContentToc
:links="page.body?.toc?.links"
:ui="{ wrapper: 'top-4', container: { base: 'py-0 pb-3 lg:py-0 lg:pb-8' } }"
/>
<UDivider class="lg:hidden" />
</div>
</div>
<section v-if="relatedArticles">
<h2 class="text-2xl text-stone-900 font-bold">
Related Articles
</h2>
<div class="mt-3 border-b border-stone-200" />
<UPageGrid class="mt-8">
<ArticleCard
v-for="article in relatedArticles"
:key="article._path"
:to="article._path!"
:title="article.title!"
:description="article.description"
:date="article.publishedAt"
:image="article.image"
:authors="article.authors"
/>
</UPageGrid>
</section>
</UPage>
</UContainer>
</template>

View File

@@ -120,15 +120,21 @@ if (post.value) {
{{ t('blog.backToBlog') }} {{ t('blog.backToBlog') }}
</NuxtLink> </NuxtLink>
<!-- Mobile TOC -->
<div v-if="(post as any).body?.toc?.links?.length" class="lg:hidden mb-8">
<BlogTableOfContents :toc="(post as any).body.toc" :mobile="true" />
</div>
<!-- Main Content Layout --> <!-- Main Content Layout -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-8 lg:gap-12">
<!-- Main Content --> <!-- Main Content -->
<div class="lg:col-span-8"> <div class="min-w-0 overflow-x-hidden">
<!-- Blog Post Metadata --> <!-- Blog Post Metadata -->
<BlogPost :post="post" /> <BlogPost :post="post" />
<!-- Content Renderer --> <!-- Content Renderer -->
<article :dir="locale === 'fa' ? 'rtl' : 'ltr'" class="prose prose-lg dark:prose-invert max-w-none mt-8" <article :dir="locale === 'fa' ? 'rtl' : 'ltr'"
:class="['blog-content', locale === 'fa' ? 'blog-content-rtl' : 'blog-content-ltr']"
suppressHydrationWarning> suppressHydrationWarning>
<ContentRenderer v-if="(post as any).body" :value="(post as any).body" /> <ContentRenderer v-if="(post as any).body" :value="(post as any).body" />
</article> </article>
@@ -137,65 +143,15 @@ if (post.value) {
<BlogNavigation :prev="prevPost" :next="nextPost" /> <BlogNavigation :prev="prevPost" :next="nextPost" />
</div> </div>
<!-- Sidebar: Table of Contents --> <!-- Sidebar: Table of Contents (Desktop) -->
<aside class="lg:col-span-4"> <aside v-if="(post as any).body?.toc?.links?.length" class="hidden lg:block">
<BlogTableOfContents v-if="(post as any).body?.toc" :toc="(post as any).body.toc" /> <UContentToc :links="(post as any).body.toc.links" :title="t('blog.tableOfContents')" color="primary"
highlight :ui="{
root: 'sticky top-24',
container: 'bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4'
}" />
</aside> </aside>
</div> </div>
</div> </div>
</UContainer> </UContainer>
</template> </template>
<style scoped>
/* Force LTR for code blocks in RTL context */
article[dir="rtl"] :deep(pre),
article[dir="rtl"] :deep(code) {
direction: ltr;
text-align: left;
}
/* Better spacing for prose elements */
article :deep(h1) {
margin-top: 2.5rem;
margin-bottom: 1.5rem;
}
article :deep(h2) {
margin-top: 2.25rem;
margin-bottom: 1.25rem;
}
article :deep(h3) {
margin-top: 2rem;
margin-bottom: 1rem;
}
article :deep(p) {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
}
article :deep(ul),
article :deep(ol) {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
padding-left: 1.75rem;
}
article :deep(li) {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
article :deep(pre) {
margin-top: 2rem;
margin-bottom: 2rem;
}
article :deep(blockquote) {
margin-top: 2rem;
margin-bottom: 2rem;
padding-left: 1.5rem;
border-left: 4px solid rgba(99, 102, 241, 0.5);
}
</style>

View File

@@ -11,6 +11,10 @@ draft: false
Nuxt Content یک سیستم مدیریت محتوای فایل‌محور قدرتمند است که به شما امکان می‌دهد محتوای خود را به صورت Markdown، YAML، CSV یا JSON بنویسید و با یک API شبیه MongoDB آن را جستجو کنید. Nuxt Content یک سیستم مدیریت محتوای فایل‌محور قدرتمند است که به شما امکان می‌دهد محتوای خود را به صورت Markdown، YAML، CSV یا JSON بنویسید و با یک API شبیه MongoDB آن را جستجو کنید.
::blog-callout{type="info"}
این مقاله یک راهنمای جامع برای شروع کار با Nuxt Content است. اگر تازه شروع کرده‌اید، این مقاله برای شما مناسب است!
::
## چرا Nuxt Content؟ ## چرا Nuxt Content؟
Nuxt Content مزایای متعددی برای برنامه‌های محتوا-محور ارائه می‌دهد: Nuxt Content مزایای متعددی برای برنامه‌های محتوا-محور ارائه می‌دهد: