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
+24 -10
View File
@@ -54,7 +54,7 @@ const selectedIcon = computed<string>(() => 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)
+63 -54
View File
@@ -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
}
</script>
<template>
<aside v-if="shouldShowToc" class="toc-container">
<!-- Desktop: Sticky sidebar -->
<nav class="hidden lg:block sticky top-24 max-h-[calc(100vh-8rem)] overflow-y-auto">
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-gray-100">
<div v-if="shouldShowToc">
<!-- Desktop TOC -->
<div v-if="!mobile"
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') }}
</h3>
<ul class="space-y-2 text-sm">
<template v-for="link in toc.links" :key="link.id">
<li>
<a :href="`#${link.id}`" :class="[
'block py-1 transition-colors',
activeId === link.id
? 'text-primary-600 dark:text-primary-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
]" @click.prevent="scrollToHeading(link.id)">
{{ link.text }}
</a>
<!-- Nested children (h3) -->
<ul v-if="link.children && link.children.length > 0" class="ml-4 mt-1 space-y-1">
<li v-for="child in link.children" :key="child.id">
<a :href="`#${child.id}`" :class="[
'block py-1 transition-colors',
activeId === child.id
? 'text-primary-600 dark:text-primary-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
]" @click.prevent="scrollToHeading(child.id)">
{{ child.text }}
</a>
</li>
</ul>
</li>
</template>
</ul>
</nav>
<nav>
<ul class="space-y-1 text-sm">
<template v-for="link in toc.links" :key="link.id">
<li>
<a :href="`#${link.id}`" :class="[
'block py-1.5 px-2 rounded transition-all',
activeId === link.id
? 'text-primary-600 dark:text-primary-400 font-medium bg-primary-50 dark:bg-primary-900/20'
: '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'
]" @click.prevent="scrollToHeading(link.id)">
{{ link.text }}
</a>
<!-- Nested children (h3) -->
<ul v-if="link.children && link.children.length > 0"
class="ml-3 mt-0.5 space-y-0.5 border-l-2 border-gray-200 dark:border-gray-700 pl-3">
<li v-for="child in link.children" :key="child.id">
<a :href="`#${child.id}`" :class="[
'block py-1 px-2 rounded text-xs transition-all',
activeId === child.id
? 'text-primary-600 dark:text-primary-400 font-medium bg-primary-50 dark:bg-primary-900/20'
: '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'
]" @click.prevent="scrollToHeading(child.id)">
{{ child.text }}
</a>
</li>
</ul>
</li>
</template>
</ul>
</nav>
</div>
<!-- Mobile: Collapsible accordion -->
<UAccordion class="lg:hidden mb-6" :items="[
<UAccordion v-if="mobile" :items="[
{
label: t('blog.tableOfContents'),
icon: 'i-heroicons-list-bullet',
@@ -141,11 +156,5 @@ const renderTocLinks = (links: TocLink[]) => {
</ul>
</template>
</UAccordion>
</aside>
</div>
</template>
<style scoped>
.toc-container {
@apply w-full;
}
</style>
+20 -9
View File
@@ -1,15 +1,27 @@
<template>
<div v-if="tags.length > 0" class="overflow-x-auto">
<div class="flex flex-wrap gap-2">
<UButton :color="!modelValue ? 'primary' : 'gray'" :variant="!modelValue ? 'solid' : 'soft'" size="sm"
@click="selectTag(null)">
<div v-if="tags.length > 0" class="overflow-x-auto scrollbar-hide -mx-1 px-1">
<div class="flex flex-wrap gap-1.5 py-1">
<!-- All Posts Badge -->
<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') }}
</UButton>
</button>
<UButton v-for="tag in tags" :key="tag" :color="modelValue === tag ? 'primary' : 'gray'"
:variant="modelValue === tag ? 'solid' : 'soft'" size="sm" @click="selectTag(tag)">
<!-- Tag Badges -->
<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 }}
</UButton>
</button>
</div>
</div>
</template>
@@ -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(() => {
+3 -2
View File
@@ -1,7 +1,8 @@
<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" />
<div class="flex-1 text-sm">
<div class="flex-1 text-sm leading-relaxed">
<slot />
</div>
</div>
+15 -17
View File
@@ -69,31 +69,31 @@ const languageLabel = computed(() => {
.code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
justify-content: center;
padding: 0.875rem 1.5rem;
background: rgba(30, 41, 59, 0.8);
border-bottom: 1px solid rgba(71, 85, 105, 0.3);
backdrop-filter: blur(8px);
min-height: 3rem;
min-height: 2.75rem;
}
.code-filename {
font-size: 0.9375rem;
font-size: 0.875rem;
color: #e2e8f0;
font-family: 'Courier New', monospace;
font-weight: 500;
}
.code-language {
font-size: 0.8125rem;
color: #94a3b8;
font-size: 0.75rem;
color: #cbd5e1;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
padding: 0.375rem 0.75rem;
background: rgba(99, 102, 241, 0.15);
padding: 0.25rem 0.625rem;
background: rgba(99, 102, 241, 0.2);
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 {
@@ -177,14 +177,12 @@ const languageLabel = computed(() => {
<div class="code-block-wrapper" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
<!-- Header with filename and language -->
<div v-if="filename || language" class="code-block-header">
<div class="flex items-center gap-3">
<span v-if="filename" class="code-filename">
{{ filename }}
</span>
<span v-if="language && !filename" class="code-language">
{{ languageLabel }}
</span>
</div>
<span v-if="filename" class="code-filename">
{{ filename }}
</span>
<span v-if="language && !filename" class="code-language">
{{ languageLabel }}
</span>
</div>
<!-- Code block -->