mirror of
https://github.com/mmahdium/portfolio.git
synced 2026-09-28 02:21:41 +03:30
feat: enhance blog components, improve routing, and add clean script for build process
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<UInput :model-value="modelValue" :placeholder="t('blog.searchPlaceholder')" icon="i-heroicons-magnifying-glass"
|
<UInput :model-value="modelValue" :placeholder="t('blog.searchPlaceholder')" icon="i-heroicons-magnifying-glass"
|
||||||
size="lg" :ui="{ icon: { trailing: { pointer: '' } } }" @update:model-value="handleInput">
|
size="lg" @update:model-value="handleInput">
|
||||||
<template v-if="modelValue" #trailing>
|
<template v-if="modelValue" #trailing>
|
||||||
<UButton color="gray" variant="link" icon="i-heroicons-x-mark-20-solid" :padded="false" @click="clearSearch" />
|
<UButton color="gray" variant="link" icon="i-heroicons-x-mark-20-solid" :padded="false" @click="clearSearch" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ const shouldShowToc = computed(() => {
|
|||||||
return countLinks(props.toc.links) >= 3
|
return countLinks(props.toc.links) >= 3
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const setHashWithoutNavigation = (id: string) => {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.hash = id
|
||||||
|
window.history.replaceState(window.history.state, '', url.toString())
|
||||||
|
}
|
||||||
|
|
||||||
// Smooth scroll to heading with offset for sticky header
|
// 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)
|
||||||
@@ -40,11 +46,28 @@ const scrollToHeading = (id: string) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
activeId.value = id
|
activeId.value = id
|
||||||
|
setHashWithoutNavigation(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track active section with IntersectionObserver
|
// Track active section with IntersectionObserver
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Handle initial hash (avoid full page navigation / transition)
|
||||||
|
const initialId = window.location.hash.replace(/^#/, '')
|
||||||
|
if (initialId) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.getElementById(initialId)
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const offset = 100
|
||||||
|
const elementPosition = el.getBoundingClientRect().top + window.pageYOffset
|
||||||
|
const offsetPosition = elementPosition - offset
|
||||||
|
|
||||||
|
window.scrollTo({ top: offsetPosition, behavior: 'auto' })
|
||||||
|
activeId.value = initialId
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
const headings = document.querySelectorAll('article h2, article h3, article h4')
|
const headings = document.querySelectorAll('article h2, article h3, article h4')
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
<UContainer>
|
<UContainer>
|
||||||
<div class="flex flex-col items-center gap-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
<div class="flex flex-col items-center gap-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<NuxtImg :src="logoSrc" alt="Ali Arghyani logo" width="64" height="64" class="h-12 w-12" format="png"
|
<img :src="logoSrc" alt="Ali Arghyani logo" width="64" height="64" class="h-12 w-12" loading="lazy" />
|
||||||
loading="lazy" />
|
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<NuxtImg src="/favicon/android-chrome-192x192.png" alt="Ali Arghyani logo" width="64" height="64"
|
<img src="/favicon/android-chrome-192x192.png" alt="Ali Arghyani logo" width="64" height="64"
|
||||||
class="h-12 w-12" format="png" loading="lazy" />
|
class="h-12 w-12" loading="lazy" />
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
<p>© {{ currentYear }}, <span class="font-semibold text-gray-900 dark:text-gray-100">AliArghyani</span> -
|
<p>© {{ currentYear }}, <span class="font-semibold text-gray-900 dark:text-gray-100">AliArghyani</span> -
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<UCard :ui="{
|
<UCard :ui="{
|
||||||
base: 'my-6',
|
root: 'my-6 ring-2',
|
||||||
body: { padding: 'p-4 sm:p-5' },
|
body: 'p-4 sm:p-5'
|
||||||
ring: 'ring-2',
|
|
||||||
divide: ''
|
|
||||||
}" :class="cardClass">
|
}" :class="cardClass">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<UIcon :name="icon" :class="iconClass" class="mt-0.5 h-5 w-5 flex-shrink-0" />
|
<UIcon :name="icon" :class="iconClass" class="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||||
|
|||||||
@@ -2,17 +2,28 @@
|
|||||||
const { locale, t } = useI18n()
|
const { locale, t } = useI18n()
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const slug = Array.isArray(route.params.slug) ? route.params.slug : [route.params.slug]
|
|
||||||
|
|
||||||
// Fetch current post
|
const slugParts = computed(() => {
|
||||||
const { data: post } = await useAsyncData(`blog-post-${slug.join('/')}`, async () => {
|
const raw = route.params.slug
|
||||||
const posts = await queryCollection('blog')
|
const parts = Array.isArray(raw) ? raw : [raw]
|
||||||
.where('path', '=', `/${locale.value}/blog/${slug.join('/')}`)
|
return parts.filter((p): p is string => typeof p === 'string' && p.length > 0)
|
||||||
.first()
|
|
||||||
return posts
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!post.value) {
|
// Fetch current post
|
||||||
|
const { data: post, error: postError } = await useAsyncData(
|
||||||
|
() => `blog-post-${locale.value}-${slugParts.value.join('/')}`,
|
||||||
|
async () => {
|
||||||
|
return await queryCollection('blog')
|
||||||
|
.where('path', '=', `/${locale.value}/blog/${slugParts.value.join('/')}`)
|
||||||
|
.first()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
watch: [locale, slugParts],
|
||||||
|
server: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (process.server && (!post.value || postError.value)) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 404,
|
statusCode: 404,
|
||||||
message: 'Blog post not found',
|
message: 'Blog post not found',
|
||||||
@@ -21,18 +32,25 @@ if (!post.value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all posts for prev/next navigation
|
// Fetch all posts for prev/next navigation
|
||||||
const { data: allPosts } = await useAsyncData(`blog-posts-nav-${locale.value}`, async () => {
|
const { data: allPosts } = await useAsyncData(
|
||||||
const posts = await queryCollection('blog')
|
() => `blog-posts-nav-${locale.value}`,
|
||||||
.where('draft', '<>', true)
|
async () => {
|
||||||
.order('date', 'DESC')
|
const posts = await queryCollection('blog')
|
||||||
.all()
|
.where('draft', '<>', true)
|
||||||
|
.order('date', 'DESC')
|
||||||
|
.all()
|
||||||
|
|
||||||
// Filter by locale and posts without draft field
|
// Filter by locale and posts without draft field
|
||||||
return posts.filter((p: any) =>
|
return posts.filter((p: any) =>
|
||||||
p.path?.startsWith(`/${locale.value}/blog/`) &&
|
p.path?.startsWith(`/${locale.value}/blog/`) &&
|
||||||
(p.draft === false || p.draft === undefined)
|
(p.draft === false || p.draft === undefined)
|
||||||
)
|
)
|
||||||
})
|
},
|
||||||
|
{
|
||||||
|
watch: [locale],
|
||||||
|
server: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Calculate adjacent posts
|
// Calculate adjacent posts
|
||||||
const currentIndex = computed(() => {
|
const currentIndex = computed(() => {
|
||||||
@@ -158,11 +176,9 @@ if (post.value) {
|
|||||||
|
|
||||||
<!-- Sidebar: Table of Contents (Desktop) -->
|
<!-- Sidebar: Table of Contents (Desktop) -->
|
||||||
<aside v-if="(post as any).body?.toc?.links?.length" class="hidden lg:block">
|
<aside v-if="(post as any).body?.toc?.links?.length" class="hidden lg:block">
|
||||||
<UContentToc :links="(post as any).body.toc.links" :title="t('blog.tableOfContents')" color="primary"
|
<div class="sticky top-24">
|
||||||
highlight :ui="{
|
<BlogTableOfContents :toc="(post as any).body.toc" />
|
||||||
root: 'sticky top-24',
|
</div>
|
||||||
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>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const { extractUniqueTags, filterPostsBySearch, filterPostsByTag } = useBlog()
|
|||||||
|
|
||||||
// Fetch posts using queryCollection (Nuxt Content v3 API)
|
// Fetch posts using queryCollection (Nuxt Content v3 API)
|
||||||
const { data: posts } = await useAsyncData<any[]>(
|
const { data: posts } = await useAsyncData<any[]>(
|
||||||
`blog-posts-${locale.value}`,
|
() => `blog-posts-${locale.value}`,
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
// Use queryCollection for Nuxt Content v3
|
// Use queryCollection for Nuxt Content v3
|
||||||
|
|||||||
+9
-2
@@ -1,4 +1,10 @@
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
const isGenerate = process.env.npm_lifecycle_event === 'generate'
|
||||||
|
|
||||||
|
const prerenderIgnore = ['/_vercel/image']
|
||||||
|
// When generating a static site, we must not ignore internal endpoints/assets that the client fetches at runtime.
|
||||||
|
if (!isGenerate) prerenderIgnore.push('/_ipx', '/_nuxt', '/_i18n', '/__nuxt_content')
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
srcDir: 'app',
|
srcDir: 'app',
|
||||||
|
|
||||||
@@ -83,6 +89,7 @@ export default defineNuxtConfig({
|
|||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
loadPlausible: "", // overrided by env,
|
loadPlausible: "", // overrided by env,
|
||||||
|
siteName: 'AliArghyani',
|
||||||
siteUrl: 'https://aliarghyani.vercel.app', // Used for sitemap and RSS generation
|
siteUrl: 'https://aliarghyani.vercel.app', // Used for sitemap and RSS generation
|
||||||
githubToken: '' // GitHub API token - set via NUXT_PUBLIC_GITHUB_TOKEN env variable
|
githubToken: '' // GitHub API token - set via NUXT_PUBLIC_GITHUB_TOKEN env variable
|
||||||
},
|
},
|
||||||
@@ -177,8 +184,8 @@ export default defineNuxtConfig({
|
|||||||
crawlLinks: true,
|
crawlLinks: true,
|
||||||
routes: ['/', '/blog', '/fa/blog', '/blog/rss.xml', '/fa/blog/rss.xml'],
|
routes: ['/', '/blog', '/fa/blog', '/blog/rss.xml', '/fa/blog/rss.xml'],
|
||||||
failOnError: false,
|
failOnError: false,
|
||||||
// Avoid crawling and prerendering internal/asset endpoints (speeds up builds significantly)
|
// Avoid crawling internal endpoints during SSR builds (speeds up builds significantly)
|
||||||
ignore: ['/_vercel/image', '/_ipx', '/_nuxt', '/_i18n', '/__nuxt_content']
|
ignore: prerenderIgnore
|
||||||
},
|
},
|
||||||
devProxy: {
|
devProxy: {
|
||||||
host: '0.0.0.0'
|
host: '0.0.0.0'
|
||||||
|
|||||||
+2
-1
@@ -4,8 +4,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
|
"clean": "node scripts/clean.mjs",
|
||||||
"dev": "nuxt dev",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "pnpm run clean && nuxt generate",
|
||||||
"preview": "nuxt preview",
|
"preview": "nuxt preview",
|
||||||
"postinstall": "nuxt prepare",
|
"postinstall": "nuxt prepare",
|
||||||
"typecheck": "nuxt typecheck",
|
"typecheck": "nuxt typecheck",
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
|
||||||
|
const pathsToRemove = ['.nuxt', '.output']
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
async function rmWithRetry(path, retries = 10) {
|
||||||
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(path, { recursive: true, force: true })
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
const code = error?.code
|
||||||
|
const shouldRetry = code === 'EBUSY' || code === 'EPERM'
|
||||||
|
|
||||||
|
if (!shouldRetry || attempt === retries) {
|
||||||
|
console.error(
|
||||||
|
`[clean] Failed to remove "${path}".\n` +
|
||||||
|
`Close any running "nuxt preview" / dev servers using it, then rerun.\n` +
|
||||||
|
`Error: ${code || error}`
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of pathsToRemove) {
|
||||||
|
await rmWithRetry(path)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { queryCollection } from '@nuxt/content/server'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const siteUrl = config.public.siteUrl || 'https://example.com'
|
const siteUrl = config.public.siteUrl || 'https://example.com'
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { queryCollection } from '@nuxt/content/server'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const siteUrl = config.public.siteUrl || 'https://example.com'
|
const siteUrl = config.public.siteUrl || 'https://example.com'
|
||||||
|
|||||||
Reference in New Issue
Block a user