mirror of
https://github.com/mmahdium/portfolio.git
synced 2026-09-28 02:21:41 +03:30
Merge remote-tracking branch 'upstream/main' into feature/sync-with-upstream
This commit is contained in:
@@ -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://mahdium.ir' // 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',
|
||||
@@ -139,17 +167,18 @@ if (post.value) {
|
||||
<ContentRenderer v-if="(post as any).body" :value="(post as any).body" />
|
||||
</article>
|
||||
|
||||
<!-- Share Buttons -->
|
||||
<BlogShare :title="(post as any).title" :url="`${siteUrl}${(post as any).path.replace(/^\/(en|fa)/, '')}`" />
|
||||
|
||||
<!-- Blog Navigation (Prev/Next) -->
|
||||
<BlogNavigation :prev="prevPost" :next="nextPost" />
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
|
||||
+3
-1
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto pt-24">
|
||||
<div class="max-w-6xl mx-auto pt-12 md:pt-24">
|
||||
<!-- Above-the-fold content only (improves LCP by reducing initial render work) -->
|
||||
<Hero />
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<AIStack />
|
||||
<SoftSkills />
|
||||
<LanguageSkills />
|
||||
<GitHubActivity username="aliarghyani" />
|
||||
<WorkExperience />
|
||||
<EducationList />
|
||||
<RecommendationsCarousel />
|
||||
@@ -24,6 +25,7 @@ const Skills = defineAsyncComponent(() => import('@/components/portfolio/Skills.
|
||||
const AIStack = defineAsyncComponent(() => import('@/components/portfolio/AIStack.vue'))
|
||||
const SoftSkills = defineAsyncComponent(() => import('@/components/portfolio/SoftSkills.vue'))
|
||||
const LanguageSkills = defineAsyncComponent(() => import('@/components/portfolio/LanguageSkills.vue'))
|
||||
const GitHubActivity = defineAsyncComponent(() => import('@/components/portfolio/GitHubActivity.vue'))
|
||||
const WorkExperience = defineAsyncComponent(() => import('@/components/portfolio/WorkExperience.vue'))
|
||||
const EducationList = defineAsyncComponent(() => import('@/components/portfolio/EducationList.vue'))
|
||||
const RecommendationsCarousel = defineAsyncComponent(() => import('@/components/portfolio/RecommendationsCarousel.vue'))
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
scrollToTop: true
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const isPrintMode = computed(() => route.query.print === 'true')
|
||||
|
||||
useHead({
|
||||
title: 'Resume - Ali Arghyani',
|
||||
meta: [
|
||||
{ name: 'robots', content: 'noindex' }
|
||||
]
|
||||
})
|
||||
|
||||
// Reset scroll position on mount
|
||||
onMounted(() => {
|
||||
window.scrollTo(0, 0)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white dark:bg-gray-900 print:bg-white">
|
||||
<!-- Back to Home Button -->
|
||||
<NuxtLink v-if="!isPrintMode" to="/" class="fixed top-4 right-4 z-50 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium
|
||||
bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700
|
||||
text-gray-700 dark:text-gray-200 rounded-full shadow-lg
|
||||
transition-all duration-200 hover:scale-105 no-print">
|
||||
<UIcon name="i-heroicons-arrow-left" class="text-base" />
|
||||
<span>Back to Home</span>
|
||||
</NuxtLink>
|
||||
|
||||
<ResumePreview />
|
||||
<ResumeDownloadButton :is-print-mode="isPrintMode" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user