mirror of
https://github.com/mmahdium/portfolio.git
synced 2026-02-07 00:07:08 +01:00
Merge pull request #7 from aliarghyani/visualEdit
some blog issues fixed
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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>
|
||||
<UButton color="gray" variant="link" icon="i-heroicons-x-mark-20-solid" :padded="false" @click="clearSearch" />
|
||||
</template>
|
||||
|
||||
@@ -26,6 +26,12 @@ const shouldShowToc = computed(() => {
|
||||
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
|
||||
const scrollToHeading = (id: string) => {
|
||||
const element = document.getElementById(id)
|
||||
@@ -40,11 +46,28 @@ const scrollToHeading = (id: string) => {
|
||||
})
|
||||
|
||||
activeId.value = id
|
||||
setHashWithoutNavigation(id)
|
||||
}
|
||||
}
|
||||
|
||||
// Track active section with IntersectionObserver
|
||||
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 observer = new IntersectionObserver(
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
<UContainer>
|
||||
<div class="flex flex-col items-center gap-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<ClientOnly>
|
||||
<NuxtImg :src="logoSrc" alt="Ali Arghyani logo" width="64" height="64" class="h-12 w-12" format="png"
|
||||
loading="lazy" />
|
||||
<img :src="logoSrc" alt="Ali Arghyani logo" width="64" height="64" class="h-12 w-12" loading="lazy" />
|
||||
<template #fallback>
|
||||
<NuxtImg src="/favicon/android-chrome-192x192.png" alt="Ali Arghyani logo" width="64" height="64"
|
||||
class="h-12 w-12" format="png" loading="lazy" />
|
||||
<img src="/favicon/android-chrome-192x192.png" alt="Ali Arghyani logo" width="64" height="64"
|
||||
class="h-12 w-12" loading="lazy" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
<p>© {{ currentYear }}, <span class="font-semibold text-gray-900 dark:text-gray-100">AliArghyani</span> -
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<UCard :ui="{
|
||||
base: 'my-6',
|
||||
body: { padding: 'p-4 sm:p-5' },
|
||||
ring: 'ring-2',
|
||||
divide: ''
|
||||
root: 'my-6 ring-2',
|
||||
body: 'p-4 sm:p-5'
|
||||
}" :class="cardClass">
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon :name="icon" :class="iconClass" class="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
<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>
|
||||
@@ -2,17 +2,28 @@
|
||||
const { locale, t } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const route = useRoute()
|
||||
const slug = Array.isArray(route.params.slug) ? route.params.slug : [route.params.slug]
|
||||
|
||||
// Fetch current post
|
||||
const { data: post } = await useAsyncData(`blog-post-${slug.join('/')}`, async () => {
|
||||
const posts = await queryCollection('blog')
|
||||
.where('path', '=', `/${locale.value}/blog/${slug.join('/')}`)
|
||||
.first()
|
||||
return posts
|
||||
const slugParts = computed(() => {
|
||||
const raw = route.params.slug
|
||||
const parts = Array.isArray(raw) ? raw : [raw]
|
||||
return parts.filter((p): p is string => typeof p === 'string' && p.length > 0)
|
||||
})
|
||||
|
||||
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({
|
||||
statusCode: 404,
|
||||
message: 'Blog post not found',
|
||||
@@ -21,18 +32,25 @@ if (!post.value) {
|
||||
}
|
||||
|
||||
// Fetch all posts for prev/next navigation
|
||||
const { data: allPosts } = await useAsyncData(`blog-posts-nav-${locale.value}`, async () => {
|
||||
const posts = await queryCollection('blog')
|
||||
.where('draft', '<>', true)
|
||||
.order('date', 'DESC')
|
||||
.all()
|
||||
const { data: allPosts } = await useAsyncData(
|
||||
() => `blog-posts-nav-${locale.value}`,
|
||||
async () => {
|
||||
const posts = await queryCollection('blog')
|
||||
.where('draft', '<>', true)
|
||||
.order('date', 'DESC')
|
||||
.all()
|
||||
|
||||
// Filter by locale and posts without draft field
|
||||
return posts.filter((p: any) =>
|
||||
p.path?.startsWith(`/${locale.value}/blog/`) &&
|
||||
(p.draft === false || p.draft === undefined)
|
||||
)
|
||||
})
|
||||
// Filter by locale and posts without draft field
|
||||
return posts.filter((p: any) =>
|
||||
p.path?.startsWith(`/${locale.value}/blog/`) &&
|
||||
(p.draft === false || p.draft === undefined)
|
||||
)
|
||||
},
|
||||
{
|
||||
watch: [locale],
|
||||
server: true
|
||||
}
|
||||
)
|
||||
|
||||
// Calculate adjacent posts
|
||||
const currentIndex = computed(() => {
|
||||
@@ -56,6 +74,8 @@ const siteUrl = 'https://aliarghyani.vercel.app' // TODO: Move to runtime config
|
||||
// Custom meta tags
|
||||
if (post.value) {
|
||||
const postData = post.value as any
|
||||
const canonicalPath = postData.path ? postData.path.replace(/^\/(en|fa)/, '') : ''
|
||||
const canonicalUrl = canonicalPath ? `${siteUrl}${canonicalPath}` : siteUrl
|
||||
|
||||
useSeoMeta({
|
||||
title: `${postData.title} | ${t('blog.title')}`,
|
||||
@@ -64,7 +84,7 @@ if (post.value) {
|
||||
ogDescription: postData.description,
|
||||
ogImage: postData.image || '/img/blog/default-cover.jpg',
|
||||
ogType: 'article',
|
||||
ogUrl: `${siteUrl}${postData.path}`,
|
||||
ogUrl: canonicalUrl,
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterTitle: postData.title,
|
||||
twitterDescription: postData.description,
|
||||
@@ -77,6 +97,14 @@ if (post.value) {
|
||||
|
||||
// JSON-LD structured data
|
||||
useHead({
|
||||
link: canonicalPath
|
||||
? [
|
||||
{
|
||||
rel: 'canonical',
|
||||
href: canonicalUrl
|
||||
}
|
||||
]
|
||||
: [],
|
||||
script: [
|
||||
{
|
||||
type: 'application/ld+json',
|
||||
@@ -140,7 +168,7 @@ if (post.value) {
|
||||
</article>
|
||||
|
||||
<!-- Share Buttons -->
|
||||
<BlogShare :title="(post as any).title" :url="`${siteUrl}${(post as any).path}`" />
|
||||
<BlogShare :title="(post as any).title" :url="`${siteUrl}${(post as any).path.replace(/^\/(en|fa)/, '')}`" />
|
||||
|
||||
<!-- Blog Navigation (Prev/Next) -->
|
||||
<BlogNavigation :prev="prevPost" :next="nextPost" />
|
||||
@@ -148,11 +176,9 @@ if (post.value) {
|
||||
|
||||
<!-- Sidebar: Table of Contents (Desktop) -->
|
||||
<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"
|
||||
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'
|
||||
}" />
|
||||
<div class="sticky top-24">
|
||||
<BlogTableOfContents :toc="(post as any).body.toc" />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ const { extractUniqueTags, filterPostsBySearch, filterPostsByTag } = useBlog()
|
||||
|
||||
// Fetch posts using queryCollection (Nuxt Content v3 API)
|
||||
const { data: posts } = await useAsyncData<any[]>(
|
||||
`blog-posts-${locale.value}`,
|
||||
() => `blog-posts-${locale.value}`,
|
||||
async () => {
|
||||
try {
|
||||
// Use queryCollection for Nuxt Content v3
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
// 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({
|
||||
srcDir: 'app',
|
||||
|
||||
@@ -83,6 +89,7 @@ export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
loadPlausible: "", // overrided by env,
|
||||
siteName: 'AliArghyani',
|
||||
siteUrl: 'https://aliarghyani.vercel.app', // Used for sitemap and RSS generation
|
||||
githubToken: '' // GitHub API token - set via NUXT_PUBLIC_GITHUB_TOKEN env variable
|
||||
},
|
||||
@@ -175,9 +182,10 @@ export default defineNuxtConfig({
|
||||
nitro: {
|
||||
prerender: {
|
||||
crawlLinks: true,
|
||||
routes: ['/', '/blog', '/fa/blog'],
|
||||
routes: ['/', '/blog', '/fa/blog', '/blog/rss.xml', '/fa/blog/rss.xml'],
|
||||
failOnError: false,
|
||||
ignore: ['/_vercel/image']
|
||||
// Avoid crawling internal endpoints during SSR builds (speeds up builds significantly)
|
||||
ignore: prerenderIgnore
|
||||
},
|
||||
devProxy: {
|
||||
host: '0.0.0.0'
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"clean": "node scripts/clean.mjs",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"generate": "pnpm run clean && nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"typecheck": "nuxt typecheck",
|
||||
|
||||
32
scripts/clean.mjs
Normal file
32
scripts/clean.mjs
Normal file
@@ -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,15 +1,17 @@
|
||||
import { queryCollection } from '@nuxt/content/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const siteUrl = config.public.siteUrl || 'https://example.com'
|
||||
|
||||
// Detect locale from path
|
||||
const locale = 'en'
|
||||
|
||||
// Fetch published blog posts
|
||||
const posts = await serverQueryContent(event, `${locale}/blog`)
|
||||
.where({ draft: { $ne: true } })
|
||||
.sort({ date: -1 })
|
||||
.find()
|
||||
// In Nitro server routes, `queryCollection` expects the H3 event as the first argument.
|
||||
const allPosts = await queryCollection(event, 'blog').all()
|
||||
|
||||
const posts = allPosts
|
||||
.filter((post: any) => post.path?.startsWith('/en/blog/') && post.draft !== true)
|
||||
.sort((a: any, b: any) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
|
||||
const escapeXml = (str: string) => {
|
||||
return str
|
||||
@@ -22,7 +24,8 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const rssItems = posts
|
||||
.map((post) => {
|
||||
const link = `${siteUrl}${post._path}`
|
||||
const routePath = post.path?.replace(/^\/en/, '') || ''
|
||||
const link = `${siteUrl}${routePath}`
|
||||
const pubDate = new Date(post.date).toUTCString()
|
||||
|
||||
return `
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { queryCollection } from '@nuxt/content/server'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const siteUrl = config.public.siteUrl || 'https://example.com'
|
||||
|
||||
// Persian locale
|
||||
const locale = 'fa'
|
||||
|
||||
// Fetch published blog posts
|
||||
const posts = await serverQueryContent(event, `${locale}/blog`)
|
||||
.where({ draft: { $ne: true } })
|
||||
.sort({ date: -1 })
|
||||
.find()
|
||||
// In Nitro server routes, `queryCollection` expects the H3 event as the first argument.
|
||||
const allPosts = await queryCollection(event, 'blog').all()
|
||||
|
||||
const posts = allPosts
|
||||
.filter((post: any) => post.path?.startsWith('/fa/blog/') && post.draft !== true)
|
||||
.sort((a: any, b: any) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
|
||||
const escapeXml = (str: string) => {
|
||||
return str
|
||||
@@ -22,7 +24,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const rssItems = posts
|
||||
.map((post) => {
|
||||
const link = `${siteUrl}${post._path}`
|
||||
const link = `${siteUrl}${post.path}`
|
||||
const pubDate = new Date(post.date).toUTCString()
|
||||
|
||||
return `
|
||||
|
||||
Reference in New Issue
Block a user