Sync upstream improvements: new components, layout overhaul, and portfolio enhancements

- Add Services, HireCTA, and ReactExpansion components
- Overhaul Hero with grid layout, badges, CTA buttons, email dialog, and profile card
- Refactor TopNav to use computed navItems loop
- Enhance ProjectsList with nested category accordions and case study details
- Restructure Skills with purpose-grouped stackGroups and row-sync accordion logic
- Add new types: Service, ReactExpansion, CTA, and Project case-study fields
- Update portfolio data with adapted services, stackGroups, and project details
- Add OG/Twitter meta tags for SEO
- Update i18n with new keys for services, hero, buttons, and project labels
- Fix langDir path, update engine constraints, add volta config
- Change default color scheme to lime
- Rename Rider to JetBrains Rider in AI stack
This commit is contained in:
2026-07-06 18:28:26 +03:30
parent ef7ef8ecc3
commit 2fc75eeab1
18 changed files with 1049 additions and 296 deletions
+2
View File
@@ -25,3 +25,5 @@ logs
# MCP configuration files (may contain secrets)
.roo
.mimocode/
+2 -2
View File
@@ -1,10 +1,10 @@
export default defineAppConfig({
ui: {
strategy: "merge",
primary: "violet",
primary: "lime",
gray: "zinc",
colors: {
primary: 'violet',
primary: 'lime',
},
icons: {
dynamic: true,
+61 -75
View File
@@ -4,80 +4,37 @@
<div
class="backdrop-blur-md bg-white/80 dark:bg-slate-900/70 shadow-md rounded-2xl border border-white/30 dark:border-slate-700/50 pointer-events-auto transition-all duration-300">
<div class="flex items-center justify-between px-2 py-2">
<div class="flex items-center gap-3">
<!-- Home -->
<div class="flex items-center gap-1.5">
<UTooltip :text="t('nav.home')">
<UButton class="cursor-pointer transition-all duration-200"
:class="[isActive('hero') ? activeClass : inactiveClass]" variant="soft" square icon="i-twemoji-house"
:aria-label="t('nav.home')" @click="goTo('hero')" />
</UTooltip>
<button type="button"
class="hidden lg:inline-flex cursor-pointer text-sm font-medium transition-colors duration-200"
:class="[isActive('hero') ? labelActiveClass : labelInactiveClass]" @click="goTo('hero')">
{{ t('nav.home') }}
</button>
</div>
<div class="flex items-center gap-2">
<UTooltip v-for="item in navItems" :key="item.value" :text="item.label">
<UButton
type="button"
variant="soft"
class="nav-link-button cursor-pointer gap-1.5 px-2 transition-all duration-200 lg:px-2.5"
:class="[item.active ? activeClass : inactiveClass]"
:aria-label="item.label"
@click="goTo(item.target)"
>
<template #leading>
<UIcon :name="item.icon" class="text-xl" />
</template>
<span class="hidden lg:inline text-sm font-medium">{{ item.label }}</span>
</UButton>
</UTooltip>
<!-- Skills -->
<div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.skills')">
<UButton class="cursor-pointer transition-all duration-200"
:class="[isActive('skills') ? activeClass : inactiveClass]" variant="soft" square
icon="i-twemoji-hammer-and-wrench" :aria-label="t('sections.skills')" @click="goTo('skills')" />
</UTooltip>
<button type="button"
class="hidden lg:inline-flex cursor-pointer text-sm font-medium transition-colors duration-200"
:class="[isActive('skills') ? labelActiveClass : labelInactiveClass]" @click="goTo('skills')">
{{ t('sections.skills') }}
</button>
</div>
<!-- Work -->
<div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.work')">
<UButton class="cursor-pointer transition-all duration-200"
:class="[isActive('work') ? activeClass : inactiveClass]" variant="soft" square
icon="i-twemoji-briefcase" :aria-label="t('sections.work')" @click="goTo('work')" />
</UTooltip>
<button type="button"
class="hidden lg:inline-flex cursor-pointer text-sm font-medium transition-colors duration-200"
:class="[isActive('work') ? labelActiveClass : labelInactiveClass]" @click="goTo('work')">
{{ t('sections.work') }}
</button>
</div>
<!-- Projects -->
<div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.projects')">
<UButton class="cursor-pointer transition-all duration-200"
:class="[isActive('projects') ? activeClass : inactiveClass]" variant="soft" square
icon="i-twemoji-rocket" :aria-label="t('sections.projects')" @click="goTo('projects')" />
</UTooltip>
<button type="button"
class="hidden lg:inline-flex cursor-pointer text-sm font-medium transition-colors duration-200"
:class="[isActive('projects') ? labelActiveClass : labelInactiveClass]" @click="goTo('projects')">
{{ t('sections.projects') }}
</button>
</div>
<!-- Blog -->
<div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.blog')">
<NuxtLink :to="localePath('/blog')" class="cursor-pointer">
<UButton class="cursor-pointer transition-all duration-200"
:class="[isBlogActive ? activeClass : inactiveClass]" variant="soft" square icon="i-twemoji-memo"
:aria-label="t('sections.blog')" />
</NuxtLink>
</UTooltip>
<NuxtLink :to="localePath('/blog')" class="cursor-pointer">
<button type="button"
class="hidden lg:inline-flex cursor-pointer text-sm font-medium transition-colors duration-200"
:class="[isBlogActive ? labelActiveClass : labelInactiveClass]">
{{ t('sections.blog') }}
</button>
</NuxtLink>
</div>
<UTooltip :text="t('sections.blog')">
<UButton
:to="localePath('/blog')"
variant="soft"
class="nav-link-button cursor-pointer gap-1.5 px-2 transition-all duration-200 lg:px-2.5"
:class="[isBlogActive ? activeClass : inactiveClass]"
:aria-label="t('sections.blog')"
>
<template #leading>
<UIcon name="i-twemoji-memo" class="text-xl" />
</template>
<span class="hidden lg:inline text-sm font-medium">{{ t('sections.blog') }}</span>
</UButton>
</UTooltip>
</div>
<div class="flex items-center gap-2">
@@ -101,8 +58,6 @@ const localePath = useLocalePath()
const activeClass = 'ring-1 ring-primary-400/40 bg-primary-500/15 text-primary-600 dark:text-primary-400 transform scale-105'
const inactiveClass = 'text-gray-500 dark:text-gray-300 hover:text-primary-400'
const labelActiveClass = 'text-primary-700 dark:text-primary-400'
const labelInactiveClass = 'text-gray-600 dark:text-gray-300 hover:text-primary-400'
const sectionIds = ['hero', 'skills', 'work', 'projects'] as const
type Target = typeof sectionIds[number]
@@ -157,6 +112,37 @@ const isActive = (id: Target) => {
return activeSection.value === id
}
const navItems = computed(() => [
{
value: 'hero',
target: 'hero' as Target,
label: t('nav.home'),
icon: 'i-twemoji-house',
active: isActive('hero')
},
{
value: 'skills',
target: 'skills' as Target,
label: t('sections.skills'),
icon: 'i-twemoji-hammer-and-wrench',
active: isActive('skills')
},
{
value: 'work',
target: 'work' as Target,
label: t('sections.work'),
icon: 'i-twemoji-briefcase',
active: isActive('work')
},
{
value: 'projects',
target: 'projects' as Target,
label: t('sections.projects'),
icon: 'i-twemoji-rocket',
active: isActive('projects')
}
])
function scrollToSection(id: Target) {
if (typeof window === 'undefined') return
+190 -78
View File
@@ -1,55 +1,74 @@
<template>
<section id="hero" class="pt-8 pb-6 scroll-mt-20 flex items-center">
<section id="hero" class="scroll-mt-20 pt-3 pb-8 sm:pt-6 sm:pb-10">
<UContainer>
<div class="flex flex-col-reverse sm:flex-row items-center sm:items-center justify-center gap-4 sm:gap-8">
<div class="flex-1 max-w-2xl text-center sm:text-start">
<h1 class="text-primary text-3xl sm:text-4xl font-bold tracking-tight mb-3 sm:mb-4">
{{ portfolio.profile.name }}
<div class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_280px]">
<div class="text-center sm:text-start lg:col-span-2">
<div class="mb-4 flex flex-wrap items-center justify-center gap-2 sm:justify-start">
<UBadge color="primary" variant="soft" class="rounded-full">
{{ portfolio.profile.name }}
</UBadge>
<UBadge v-if="portfolio.profile.availability" color="emerald" variant="soft" class="rounded-full">
{{ portfolio.profile.availability }}
</UBadge>
</div>
<h1 class="mb-4 max-w-6xl text-3xl font-bold leading-tight tracking-normal text-gray-950 dark:text-gray-50 sm:text-5xl lg:text-6xl">
{{ portfolio.profile.headline || portfolio.profile.title }}
</h1>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 mb-4 sm:mb-3">
<p class="max-w-4xl text-base leading-relaxed text-gray-700 dark:text-gray-300 sm:text-lg">
{{ portfolio.profile.summary }}
</p>
<div v-if="portfolio.profile.location || currentRole"
class="mb-6 flex flex-col items-center gap-3 text-sm text-gray-600 dark:text-gray-300 sm:items-start">
<div v-if="portfolio.profile.location" class="flex items-center gap-2">
<UIcon name="i-twemoji-round-pushpin" class="text-base text-primary-600 me-1 dark:text-primary-300" />
<span class="leading-relaxed">{{ portfolio.profile.location }}</span>
</div>
<div v-if="currentRole"
class="flex flex-col sm:flex-row items-center justify-center sm:justify-between w-full gap-2 text-base text-gray-700 dark:text-gray-200 text-center sm:text-left">
<div class="flex flex-wrap items-center justify-center sm:justify-start gap-1">
<img v-if="currentRole.companyLogo" :src="currentRole.companyLogo" :alt="`${currentRole.company} logo`"
class="h-7 w-7 rounded-md object-contain" loading="lazy" />
<span class="">{{ t('hero.currently') }}</span>
<span class="font-semibold company-name">
<a v-if="currentRole.companyLink" :href="currentRole.companyLink" target="_blank" rel="noopener"
class="hover:underline company-name">
{{ currentRole.company }}
</a>
<span v-else>{{ currentRole.company }}</span>
</span>
</div>
<!-- Resume Button - Desktop only -->
<NuxtLink to="/resume" class="hidden sm:inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold
resume-button
text-white rounded-full
transition-all duration-300 hover:scale-105
animate-pulse hover:animate-none">
<UIcon name="i-heroicons-document-text" class="text-lg" />
<span>View Resume</span>
<UIcon name="i-heroicons-sparkles" class="text-sm opacity-80" />
</NuxtLink>
</div>
<!-- Resume Button - Mobile only -->
<NuxtLink to="/resume" class="sm:hidden inline-flex items-center justify-center gap-2 mt-2 px-4 py-2 text-sm font-medium
resume-button
text-white rounded-full
transition-all duration-300 hover:scale-105">
<UIcon name="i-heroicons-document-text" class="text-base" />
<span>View My Resume</span>
<UIcon name="i-heroicons-arrow-right" class="text-sm" />
</div>
<div class="text-center sm:text-start">
<div v-if="portfolio.profile.focusAreas?.length" class="mb-6 flex flex-wrap justify-center gap-2 sm:justify-start">
<UBadge v-for="area in portfolio.profile.focusAreas" :key="area" color="neutral" variant="soft" class="rounded-full">
{{ area }}
</UBadge>
</div>
<div class="mb-6 flex flex-wrap items-center justify-center gap-3 sm:justify-start">
<UButton icon="i-mdi-email-outline" color="primary" size="lg" class="rounded-lg" @click="copyEmail">
{{ translate('buttons.contactMe', 'Contact Me') }}
</UButton>
<UButton icon="i-mdi-folder-multiple-outline" color="neutral" variant="soft" size="lg" class="rounded-lg" @click="scrollTo('projects')">
{{ translate('buttons.viewProjects', 'View Projects') }}
</UButton>
<NuxtLink to="/resume" class="resume-gradient-button" @click="preserveColorModeForResume">
<UIcon name="i-heroicons-document-text" class="text-lg" />
<span>{{ t('hero.viewResume') }}</span>
<UIcon name="i-heroicons-sparkles" class="text-sm opacity-80" />
</NuxtLink>
</div>
<div v-if="portfolio.profile.location || currentRole" class="mb-5 flex flex-col items-center gap-3 text-sm text-gray-600 dark:text-gray-300 sm:items-start">
<div v-if="currentRole" class="flex flex-wrap items-center justify-center gap-2 sm:justify-start">
<img
v-if="currentRole.companyLogo"
:src="currentRole.companyLogo"
:alt="`${currentRole.company} logo`"
class="h-7 w-7 rounded-md object-contain"
loading="lazy"
/>
<span>{{ t('hero.currently') }}</span>
<a
v-if="currentRole.companyLink"
:href="currentRole.companyLink"
target="_blank"
rel="noopener"
class="font-semibold company-name hover:underline"
>
{{ currentRole.company }}
</a>
<span v-else class="font-semibold company-name">{{ currentRole.company }}</span>
</div>
<div v-if="portfolio.profile.location" class="flex items-center gap-2">
<UIcon name="i-mdi-map-marker-outline" class="text-base text-primary-600 dark:text-primary-300" />
<span class="leading-relaxed">{{ portfolio.profile.location }}</span>
</div>
</div>
<div class="flex flex-wrap items-center justify-center gap-3 sm:justify-start">
<ClientTooltip :text="emailTooltip">
<UButton icon="i-twemoji-e-mail" :square="true" color="gray" variant="ghost"
@@ -103,14 +122,84 @@
</ClientTooltip>
</div>
</div>
<div v-show="portfolio.profile.avatar"
class="block mx-auto sm:mx-0 shrink-0 w-24 h-24 sm:w-32 sm:h-32 md:w-40 md:h-40 ring-4 ring-primary-400/50 dark:ring-primary-300/40 rounded-full overflow-hidden">
<NuxtImg :src="portfolio.profile.avatar || undefined" :alt="portfolio.profile.name"
sizes="96px sm:128px md:160px" width="160" height="160" class="h-full w-full object-cover" format="webp"
preload />
<div v-if="portfolio.profile.avatar" class="mx-auto w-full max-w-[280px]">
<div class="rounded-lg border border-gray-200/70 bg-white/75 p-4 shadow-sm dark:border-gray-700/50 dark:bg-gray-900/45">
<div class="mx-auto h-36 w-36 overflow-hidden rounded-full ring-4 ring-primary-400/35 dark:ring-primary-300/25">
<NuxtImg
:src="portfolio.profile.avatar || undefined"
:alt="portfolio.profile.name"
sizes="144px"
width="144"
height="144"
class="h-full w-full object-cover"
format="webp"
preload
/>
</div>
<div class="mt-4 text-center">
<p class="text-sm font-semibold text-gray-950 dark:text-gray-50">{{ portfolio.profile.title }}</p>
<p class="mt-1 text-xs leading-relaxed text-gray-600 dark:text-gray-400">
{{ translate('hero.cardSummary', 'Backend services, infrastructure, and DevOps systems.') }}
</p>
</div>
</div>
</div>
</div>
</UContainer>
<UModal
v-model:open="emailDialogOpen"
:title="translate('hero.contactTitle', 'Contact Mahdi')"
:description="translate('hero.contactDescription', 'Use this email for infrastructure, backend, or DevOps project inquiries.')"
:ui="{ content: 'sm:max-w-md', body: 'space-y-4', footer: 'justify-between gap-2' }"
>
<template #body>
<div class="rounded-lg border border-gray-200/70 bg-gray-50/80 p-3 dark:border-gray-700/60 dark:bg-gray-900/70">
<div class="mb-2 flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-200">
<UIcon name="i-mdi-email-outline" class="text-base text-primary-500 dark:text-primary-300" />
<span>{{ translate('hero.emailAddress', 'Email address') }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<code class="min-w-0 break-all text-sm font-semibold text-gray-950 dark:text-gray-50">{{ emailAddress }}</code>
<UButton
icon="i-mdi-content-copy"
color="primary"
variant="soft"
size="sm"
class="shrink-0 rounded-lg"
@click="handleModalCopy"
>
{{ translate('hero.copy', 'Copy') }}
</UButton>
</div>
</div>
<p v-if="emailDialogMessage" class="text-sm text-amber-600 dark:text-amber-300">
{{ emailDialogMessage }}
</p>
</template>
<template #footer="{ close }">
<UButton
color="neutral"
variant="ghost"
class="rounded-lg"
@click="close"
>
{{ translate('hero.close', 'Close') }}
</UButton>
<UButton
:to="`mailto:${emailAddress}?subject=Infrastructure%20project%20inquiry`"
icon="i-mdi-send-outline"
color="primary"
class="rounded-lg"
@click="close"
>
{{ translate('hero.openEmailApp', 'Open Email App') }}
</UButton>
</template>
</UModal>
</section>
</template>
@@ -120,9 +209,24 @@ import { computed, ref } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
import type { CompanyExperience, Experience } from '@/types/portfolio.types'
const { t } = useI18n()
const { t, te } = useI18n()
const portfolio = usePortfolio()
const toast = useToast()
const colorMode = useColorMode()
function translate(key: string, fallback: string): string {
return te(key) ? t(key) : fallback
}
function scrollTo(id: string) {
if (typeof window === 'undefined') return
const el = document.getElementById(id)
if (el) {
const offset = 80
const top = el.getBoundingClientRect().top + window.pageYOffset - offset
window.scrollTo({ top, behavior: 'smooth' })
}
}
const currentRole = computed(() => {
const experiences = portfolio.value.experiences as Array<CompanyExperience | Experience>
@@ -164,48 +268,47 @@ const currentRole = computed(() => {
return null
})
/**
* Email copy-to-clipboard for Hero quick action
*/
const emailAddress = 'me@mahdium.ir'
const emailTooltip = ref('Email')
const emailDialogOpen = ref(false)
const emailDialogMessage = ref('')
async function copyEmail() {
const copied = await tryCopyEmail()
if (!copied) {
emailDialogOpen.value = true
}
}
async function tryCopyEmail(): Promise<boolean> {
try {
await navigator.clipboard.writeText(emailAddress)
emailTooltip.value = 'Copied'
emailDialogMessage.value = ''
setTimeout(() => { emailTooltip.value = 'Email' }, 1500)
// Nuxt UI toast: success
toast.add({
title: t('toasts.emailCopied.title'),
description: t('toasts.emailCopied.desc', { email: emailAddress }),
icon: 'i-mdi-clipboard-check',
color: 'emerald'
})
return true
} catch {
// Nuxt UI toast: failure (clipboard not accessible)
toast.add({
title: t('toasts.copyFailed.title'),
description: t('toasts.copyFailed.desc', { email: emailAddress }),
icon: 'i-mdi-clipboard-alert',
color: 'amber'
})
emailDialogMessage.value = translate('hero.clipboardBlocked', 'Clipboard access is blocked by the browser on this address. You can still select the email or open your email app.')
return false
}
}
// Fallback prompt if clipboard API is unavailable
const ok = typeof window !== 'undefined' && window.confirm(`Copy email:\n\n${emailAddress}`)
if (ok) {
emailTooltip.value = 'Copied'
setTimeout(() => { emailTooltip.value = 'Email' }, 1500)
async function handleModalCopy() {
await tryCopyEmail()
}
// Show success toast after manual copy confirmation
toast.add({
title: t('toasts.emailCopied.title'),
description: t('toasts.emailCopied.desc', { email: emailAddress }),
icon: 'i-mdi-clipboard-check',
color: 'emerald'
})
}
function preserveColorModeForResume() {
if (!import.meta.client) return
if (colorMode.value === 'dark') {
colorMode.preference = 'dark'
localStorage.setItem('nuxt-color-mode', 'dark')
}
}
</script>
@@ -219,16 +322,25 @@ async function copyEmail() {
color: var(--ui-color-primary-300);
}
/* Resume button with primary color gradient */
.resume-button {
.resume-gradient-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 600;
color: white;
border-radius: 0.5rem;
background: linear-gradient(to right,
var(--ui-color-primary-600),
var(--ui-color-primary-500),
var(--ui-color-primary-400));
box-shadow: 0 10px 15px -3px color-mix(in oklch, var(--ui-color-primary-500) 25%, transparent);
transition: all 0.3s;
}
.resume-button:hover {
.resume-gradient-button:hover {
transform: scale(1.05);
background: linear-gradient(to right,
var(--ui-color-primary-700),
var(--ui-color-primary-600),
+76
View File
@@ -0,0 +1,76 @@
<template>
<section v-if="cta" id="contact" class="section-spacing scroll-mt-20">
<UContainer>
<div class="rounded-lg border border-primary-300/40 bg-primary-50/80 p-5 shadow-sm dark:border-primary-700/40 dark:bg-primary-950/20 sm:p-6">
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-3xl">
<h2 class="text-xl font-semibold leading-tight text-gray-950 dark:text-gray-50 sm:text-2xl">
{{ cta.title }}
</h2>
<p class="mt-2 text-sm leading-relaxed text-gray-700 dark:text-gray-300 sm:text-base">
{{ cta.description }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<UButton
:to="`mailto:${emailAddress}?subject=Infrastructure%20project%20inquiry`"
icon="i-mdi-email-outline"
color="primary"
size="md"
class="rounded-lg"
>
{{ t('buttons.contactMe') }}
</UButton>
<UButton
to="#projects"
icon="i-mdi-folder-multiple-outline"
color="neutral"
variant="soft"
size="md"
class="rounded-lg"
>
{{ t('buttons.viewProjects') }}
</UButton>
<UButton
v-if="socials.linkedin"
:to="socials.linkedin"
target="_blank"
icon="i-logos-linkedin-icon"
color="neutral"
variant="soft"
size="md"
class="rounded-lg"
>
LinkedIn
</UButton>
<UButton
v-if="socials.github"
:to="socials.github"
target="_blank"
icon="i-mdi-github"
color="neutral"
variant="soft"
size="md"
class="rounded-lg"
>
GitHub
</UButton>
</div>
</div>
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
const portfolio = usePortfolio()
const { t } = useI18n()
const cta = computed(() => portfolio.value.cta)
const socials = computed(() => portfolio.value.profile.socials ?? {})
const emailAddress = 'me@mahdium.ir'
</script>
+163 -54
View File
@@ -6,30 +6,46 @@
<h2 class="section-title text-start">{{ t('sections.projects') }}</h2>
</div>
<UAccordion type="single" :unmount-on-hide="false" :items="accordionItems" default-value="projects"
<UAccordion type="single" collapsible :unmount-on-hide="true" :items="accordionItems" default-value="projects"
:ui="accordionUi">
<template #body>
<div v-for="g in nonEmptyCategoryList" :key="g.cat" class="space-y-3 mb-5">
<div class="flex items-center gap-2">
<UIcon name="i-twemoji-open-book" class="text-xl" />
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{{ tCategory(g.cat) }}</h3>
</div>
<div class="grid gap-4 md:grid-cols-2">
<UCard v-for="(p, i) in g.items" :key="`${g.cat}-${i}-${p.name}`"
class="flex h-full flex-col border border-gray-200/60 shadow-none transition hover:-translate-y-1 hover:shadow-lg dark:border-gray-700/40">
<div class="flex h-full flex-col gap-4">
<UAccordion
type="multiple"
collapsible
:unmount-on-hide="true"
:items="categoryAccordionItems"
:default-value="openProjectCategories"
:ui="categoryAccordionUi"
>
<template #leading>
<UIcon name="i-twemoji-open-book" class="text-lg" />
</template>
<template #body="{ item }">
<div class="grid gap-4 lg:grid-cols-2">
<UCard v-for="(p, i) in item.items" :key="`${item.cat}-${i}-${p.name}`"
class="flex h-full flex-col border border-gray-200/60 shadow-none transition hover:-translate-y-0.5 hover:shadow-md dark:border-gray-700/40">
<article class="flex h-full flex-col gap-4">
<div class="flex gap-3">
<NuxtImg v-if="p.thumbnail" :src="p.thumbnail" :alt="`${p.name} logo`"
class="h-12 w-12 rounded-xl border border-gray-200/70 bg-white object-cover shadow-sm dark:border-gray-700/40 dark:bg-slate-900"
width="96" height="96" sizes="96px" format="webp" loading="lazy" />
<div v-else
class="flex h-12 w-12 items-center justify-center rounded-xl border border-gray-200/70 bg-primary-500/10 text-primary-600 shadow-sm dark:border-gray-700/40 dark:bg-primary-400/10 dark:text-primary-200">
<NuxtImg
v-if="p.thumbnail"
:src="p.thumbnail"
:alt="`${p.name} logo`"
class="h-14 w-14 rounded-lg border border-gray-200/70 bg-white object-contain p-1 shadow-sm dark:border-gray-700/40 dark:bg-slate-900"
width="112"
height="112"
sizes="112px"
format="webp"
loading="lazy"
/>
<div
v-else
class="flex h-14 w-14 items-center justify-center rounded-lg border border-gray-200/70 bg-primary-500/10 text-primary-600 shadow-sm dark:border-gray-700/40 dark:bg-primary-400/10 dark:text-primary-200"
>
<UIcon :name="getProjectIcon(p)" class="text-2xl" />
</div>
<div class="flex flex-1 flex-col gap-3">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{{ p.name }}</h3>
<h3 class="text-base font-semibold leading-snug text-gray-950 dark:text-gray-50">{{ p.name }}</h3>
<div v-if="p.status || p.opensource" class="mt-2 flex flex-wrap items-center gap-2">
<UBadge v-if="p.status" color="primary" variant="soft" class="rounded-full capitalize">
{{ p.status }}
@@ -40,27 +56,79 @@
</UBadge>
</div>
</div>
<p class="text-sm text-gray-700 dark:text-gray-200">{{ p.description }}</p>
<div class="flex flex-1 flex-col gap-3 pt-1">
<div v-if="p.icons?.length"
class="flex flex-wrap items-center gap-2 text-primary-500 dark:text-primary-300">
<UIcon v-for="(ic, k) in p.icons" :key="k" :name="ic" class="text-xl" />
</div>
<div v-if="p.links?.length" class="mt-auto flex flex-wrap gap-2">
<UButton v-for="(l, j) in p.links" :key="j" :to="l.to" target="_blank" size="xs"
color="primary" variant="soft" trailing-icon="i-mdi-arrow-top-right-thin"
class="hover-ring-tint" :aria-label="l.label">
<UIcon v-if="l.icon" :name="l.icon" class="mr-1" />
{{ l.label }}
</UButton>
</div>
</div>
<p class="text-sm leading-relaxed text-gray-700 dark:text-gray-200">{{ p.description }}</p>
</div>
</div>
</div>
</UCard>
</div>
</div>
<UAccordion
v-if="hasCaseStudyContent(p)"
type="single"
collapsible
:unmount-on-hide="true"
:items="getProjectDetailItems(p)"
:ui="projectDetailsAccordionUi"
>
<template #body>
<div class="grid gap-3 text-sm">
<div v-if="p.context">
<h4 class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">{{ t('projectLabels.context') }}</h4>
<p class="mt-1 leading-relaxed text-gray-700 dark:text-gray-300">{{ p.context }}</p>
</div>
<div v-if="p.role">
<h4 class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">{{ t('projectLabels.role') }}</h4>
<p class="mt-1 leading-relaxed text-gray-700 dark:text-gray-300">{{ p.role }}</p>
</div>
<div v-if="p.features?.length">
<h4 class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">{{ t('projectLabels.keyFeatures') }}</h4>
<div class="mt-2 flex flex-wrap gap-1.5">
<UBadge v-for="feature in p.features" :key="feature" color="neutral" variant="soft" class="rounded-full">
{{ feature }}
</UBadge>
</div>
</div>
<div v-if="p.stack?.length">
<h4 class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">{{ t('projectLabels.techStack') }}</h4>
<div class="mt-2 flex flex-wrap gap-1.5">
<UBadge v-for="stackItem in p.stack" :key="stackItem" color="primary" variant="soft" class="rounded-full">
{{ stackItem }}
</UBadge>
</div>
</div>
<div v-if="p.outcome">
<h4 class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">{{ t('projectLabels.outcome') }}</h4>
<p class="mt-1 leading-relaxed text-gray-700 dark:text-gray-300">{{ p.outcome }}</p>
</div>
</div>
</template>
</UAccordion>
<div class="mt-auto flex flex-col gap-3 pt-1">
<div v-if="p.icons?.length" class="flex flex-wrap items-center gap-2 text-primary-500 dark:text-primary-300">
<UIcon v-for="(ic, k) in p.icons" :key="k" :name="ic" class="text-xl" />
</div>
<div v-if="p.links?.length" class="flex flex-wrap gap-2">
<UButton
v-for="(l, j) in p.links"
:key="j"
:to="l.to"
target="_blank"
size="xs"
color="primary"
variant="soft"
trailing-icon="i-mdi-arrow-top-right-thin"
class="hover-ring-tint rounded-lg"
:aria-label="l.label"
>
<UIcon v-if="l.icon" :name="l.icon" class="mr-1" />
{{ l.label }}
</UButton>
</div>
</div>
</article>
</UCard>
</div>
</template>
</UAccordion>
</template>
</UAccordion>
</UContainer>
@@ -68,26 +136,16 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { computed } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
import type { Project } from '@/types/portfolio.types'
const portfolio = usePortfolio()
const { t } = useI18n()
const { t, te } = useI18n()
// Detect mobile for accordion behavior (SSR-safe)
const isMobile = ref(true)
onMounted(() => {
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
checkMobile()
window.addEventListener('resize', checkMobile)
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
})
function translate(key: string, fallback: string): string {
return te(key) ? t(key) : fallback
}
// Accordion config
const accordionItems = computed(() => [{
@@ -98,9 +156,9 @@ const accordionItems = computed(() => [{
const accordionUi = {
root: 'flex flex-col',
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm',
header: 'px-4 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
trigger: 'group flex-1 items-center gap-2 py-3 text-left cursor-pointer',
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
header: 'sticky top-[60px] z-30 px-3 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50 rounded-t-2xl bg-white/95 shadow-sm backdrop-blur-md dark:bg-gray-950/95 sm:top-[60px]',
trigger: 'group flex-1 h-9 min-h-0 items-center gap-2 py-0 text-left cursor-pointer',
label: 'text-xs font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300 sm:text-sm',
leadingIcon: 'shrink-0',
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
content: 'px-4 pb-4 pt-3 data-[state=closed]:hidden',
@@ -127,6 +185,17 @@ const nonEmptyCategoryList = computed<Array<{ cat: Category; items: Project[] }>
return categoryList.value.filter(g => g.items.length > 0)
})
const categoryAccordionItems = computed(() =>
nonEmptyCategoryList.value.map(group => ({
label: tCategory(group.cat),
value: group.cat,
cat: group.cat,
items: group.items,
}))
)
const openProjectCategories = computed(() => categoryAccordionItems.value.map(item => item.value))
function tCategory(cat: Category): string {
return t(`projectCategories.${cat}`)
}
@@ -146,4 +215,44 @@ function getProjectIcon(project: Project): string {
return 'i-twemoji-rocket'
}
function hasCaseStudyContent(project: Project): boolean {
return Boolean(
project.context ||
project.role ||
project.features?.length ||
project.stack?.length ||
project.outcome
)
}
function getProjectDetailItems(project: Project) {
return [{
label: translate('projectLabels.caseStudyDetails', 'Case study details'),
value: `${project.name}-details`,
}]
}
const categoryAccordionUi = {
root: 'flex flex-col gap-3',
item: 'flex flex-col rounded-xl border border-gray-200/70 dark:border-gray-700/50 bg-white/60 dark:bg-gray-900/35 shadow-sm',
header: 'px-4 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
trigger: 'group flex-1 items-center gap-2 py-3 text-left cursor-pointer',
label: 'text-sm font-semibold text-gray-900 dark:text-gray-100',
leadingIcon: 'shrink-0',
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
content: 'px-4 pb-4 pt-4 data-[state=closed]:hidden',
body: 'pt-0'
} as const
const projectDetailsAccordionUi = {
root: 'flex flex-col',
item: 'rounded-lg border border-gray-200/70 dark:border-gray-700/50 bg-gray-50/70 dark:bg-gray-950/30',
header: 'px-3 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
trigger: 'group flex-1 items-center gap-2 py-2 text-left cursor-pointer',
label: 'text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300',
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
content: 'px-3 pb-3 pt-3 data-[state=closed]:hidden',
body: 'pt-0'
} as const
</script>
@@ -0,0 +1,41 @@
<template>
<section v-if="reactExpansion" id="react-next" class="section-spacing scroll-mt-20">
<UContainer>
<div class="rounded-lg border border-gray-200/70 bg-white/75 p-4 shadow-sm dark:border-gray-700/50 dark:bg-gray-900/45 sm:p-5">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="max-w-3xl">
<div class="mb-2 flex items-center gap-2 text-sm font-semibold text-primary-600 dark:text-primary-300">
<UIcon name="i-mdi-progress-wrench" class="text-lg" />
<span>{{ t('sections.currentlyExpanding') }}</span>
</div>
<h2 class="text-lg font-semibold leading-tight text-gray-950 dark:text-gray-50">
{{ reactExpansion.title }}
</h2>
<p class="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
{{ reactExpansion.description }}
</p>
</div>
<div class="flex shrink-0 flex-wrap gap-1.5 md:max-w-sm md:justify-end">
<UBadge v-for="item in reactExpansion.items" :key="item.label" variant="soft" class="chip-base">
<span class="inline-flex items-center gap-1.5">
<UIcon v-if="item.icon" :name="item.icon" class="h-4 w-4 text-base" />
<span class="text-xs">{{ item.label }}</span>
</span>
</UBadge>
</div>
</div>
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
const portfolio = usePortfolio()
const { t } = useI18n()
const reactExpansion = computed(() => portfolio.value.reactExpansion)
</script>
+43
View File
@@ -0,0 +1,43 @@
<template>
<section v-if="services.length" id="services" class="section-spacing scroll-mt-20">
<UContainer>
<div class="section-header">
<UIcon name="i-mdi-briefcase-check-outline" class="text-2xl text-primary-500 dark:text-primary-300" />
<div>
<h2 class="section-title">{{ t('sections.services') }}</h2>
<p class="mt-1 max-w-2xl text-sm text-gray-600 dark:text-gray-300">
{{ t('services.subtitle') }}
</p>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<article
v-for="service in services"
:key="service.title"
class="rounded-lg border border-gray-200/70 bg-white/75 p-4 shadow-sm dark:border-gray-700/50 dark:bg-gray-900/45"
>
<div class="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-primary-500/10 text-primary-600 dark:bg-primary-400/10 dark:text-primary-300">
<UIcon :name="service.icon || 'i-mdi-check-circle-outline'" class="text-xl" />
</div>
<h3 class="text-base font-semibold leading-snug text-gray-950 dark:text-gray-50">
{{ service.title }}
</h3>
<p class="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
{{ service.description }}
</p>
</article>
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
const portfolio = usePortfolio()
const { t } = useI18n()
const services = computed(() => portfolio.value.services ?? [])
</script>
+186 -55
View File
@@ -4,18 +4,43 @@
<div class="section-header flex-nowrap justify-between">
<div class="flex items-center gap-3 min-w-0">
<UIcon name="i-twemoji-hammer-and-wrench" class="text-2xl" />
<h2 class="section-title">{{ t('sections.skills') }}</h2>
<div>
<h2 class="section-title">{{ t('sections.skills') }}</h2>
<p class="mt-1 max-w-2xl text-sm text-gray-600 dark:text-gray-300">
{{ translate('skills.subtitle', 'Grouped by how the tools support backend, infrastructure, and DevOps work.') }}
</p>
</div>
</div>
<SkillFilters v-model="selectedTypes" />
</div>
<UAccordion type="multiple" :unmount-on-hide="false" :items="skillSections" :default-value="openSkillSections"
:ui="accordionUi" :disabled="!isMobile">
<template #leading="{ item }">
<UIcon v-if="item.icon" :name="item.icon" class="text-base text-primary-500 dark:text-primary-300" />
</template>
<template #body="{ item }">
<SkillGrid :items="sectionItems[item.value as SkillSectionKey] || []" />
<UAccordion
type="single"
collapsible
:unmount-on-hide="true"
:items="skillsAccordionItems"
default-value="skills"
:ui="outerAccordionUi"
>
<template #body>
<UAccordion
:model-value="openSkillValues"
type="multiple"
collapsible
:unmount-on-hide="true"
:items="skillSections"
:ui="accordionUi"
@update:model-value="syncOpenSkillRows"
>
<template #leading="{ item }">
<UIcon v-if="item.icon" :name="item.icon" class="text-base text-primary-500 dark:text-primary-300" />
</template>
<template #body="{ item }">
<p v-if="item.description" class="mb-3 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
{{ item.description }}
</p>
<SkillGrid :items="sectionItems[item.value as string] || []" />
</template>
</UAccordion>
</template>
</UAccordion>
</UContainer>
@@ -23,65 +48,171 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { Tag, SkillType } from '@/types/portfolio.types'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { Tag } from '@/types/portfolio.types'
import { expert, proficient, usedBefore } from '@/data/skills'
import { usePortfolio } from '@/composables/usePortfolio'
import SkillGrid from '@/components/portfolio/SkillGrid.vue'
import SkillFilters from '@/components/portfolio/SkillFilters.vue'
const { t } = useI18n()
const { t, te } = useI18n()
const portfolio = usePortfolio()
// Detect mobile for accordion behavior (SSR-safe)
const isMobile = ref(true)
function translate(key: string, fallback: string): string {
return te(key) ? t(key) : fallback
}
onMounted(() => {
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
checkMobile()
window.addEventListener('resize', checkMobile)
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
})
type StackGroupView = {
title: string
value: string
icon: string
description?: string
items: Tag[]
}
type SkillSectionKey = 'expert' | 'proficient' | 'usedBefore'
// Multi-select filters for Skill types
const selectedTypes = ref<SkillType[]>([])
const filterByType = (items: Tag[]) =>
selectedTypes.value.length === 0
? items
: items.filter(i => i.type && selectedTypes.value.includes(i.type))
const filteredExpert = computed(() => filterByType(expert))
const filteredProficient = computed(() => filterByType(proficient))
const filteredUsedBefore = computed(() => filterByType(usedBefore))
const sectionItems = computed<Record<SkillSectionKey, Tag[]>>(() => ({
expert: filteredExpert.value,
proficient: filteredProficient.value,
usedBefore: filteredUsedBefore.value
}))
const skillSections = computed(() => ([
{ label: t('skills.expert'), value: 'expert', icon: 'i-twemoji-military-medal' },
{ label: t('skills.proficient'), value: 'proficient', icon: 'i-twemoji-rocket' },
{ label: t('skills.usedBefore'), value: 'usedBefore', icon: 'i-twemoji-toolbox' }
const fallbackGroups = computed<StackGroupView[]>(() => ([
{ title: t('skills.expert'), value: 'expert', icon: 'i-twemoji-military-medal', items: expert },
{ title: t('skills.proficient'), value: 'proficient', icon: 'i-twemoji-rocket', items: proficient },
{ title: t('skills.usedBefore'), value: 'usedBefore', icon: 'i-twemoji-toolbox', items: usedBefore }
]))
const groups = computed<StackGroupView[]>(() => {
if (portfolio.value.stackGroups?.length) {
return portfolio.value.stackGroups.map((group, index) => ({
title: group.title,
value: `stack-${index}`,
icon: iconForStackGroup(group.title),
description: group.description,
items: group.items,
}))
}
return fallbackGroups.value
})
const sectionItems = computed<Record<string, Tag[]>>(() =>
Object.fromEntries(groups.value.map(group => [group.value, group.items]))
)
const skillSections = computed(() =>
groups.value.map(group => ({
label: group.title,
value: group.value,
icon: group.icon,
description: group.description,
}))
)
const openSkillSections = computed(() => skillSections.value.map(section => section.value))
const openSkillValues = ref<string[]>([])
const skillGridColumns = ref(1)
const skillsAccordionItems = computed(() => [{
label: translate('skills.accordionLabel', 'Purpose-grouped stack for backend, infrastructure, and DevOps work.'),
value: 'skills'
}])
function iconForStackGroup(title: string): string {
const lower = title.toLowerCase()
if (lower.includes('backend')) return 'i-mdi-server-network'
if (lower.includes('infrastructure') || lower.includes('devops')) return 'i-mdi-cog-outline'
if (lower.includes('observability') || lower.includes('monitoring')) return 'i-mdi-chart-box-outline'
if (lower.includes('frontend')) return 'i-mdi-monitor-dashboard'
if (lower.includes('testing')) return 'i-mdi-source-branch-check'
if (lower.includes('expanding')) return 'i-mdi-progress-wrench'
return 'i-twemoji-hammer-and-wrench'
}
function updateSkillGridColumns() {
if (!import.meta.client) return
const width = window.innerWidth
if (width >= 1280) {
skillGridColumns.value = 3
} else if (width >= 768) {
skillGridColumns.value = 2
} else {
skillGridColumns.value = 1
}
}
function syncOpenSkillRows(value: string | string[] | undefined) {
const nextValues = Array.isArray(value) ? value : value ? [value] : []
if (skillGridColumns.value <= 1) {
openSkillValues.value = nextValues
return
}
const openedValues = nextValues.filter(value => !openSkillValues.value.includes(value))
const collapsedValues = openSkillValues.value.filter(value => !nextValues.includes(value))
if (!openedValues.length && !collapsedValues.length) {
openSkillValues.value = nextValues
return
}
const normalizedValues = new Set(nextValues)
for (const value of openedValues) {
const index = skillSections.value.findIndex(section => section.value === value)
if (index === -1) continue
const rowStart = Math.floor(index / skillGridColumns.value) * skillGridColumns.value
const rowEnd = rowStart + skillGridColumns.value
skillSections.value.slice(rowStart, rowEnd).forEach(section => normalizedValues.add(section.value))
}
const valuesToClose = new Set<string>()
for (const value of collapsedValues) {
const index = skillSections.value.findIndex(section => section.value === value)
if (index === -1) continue
const rowStart = Math.floor(index / skillGridColumns.value) * skillGridColumns.value
const rowEnd = rowStart + skillGridColumns.value
skillSections.value.slice(rowStart, rowEnd).forEach(section => valuesToClose.add(section.value))
}
openSkillValues.value = skillSections.value
.map(section => section.value)
.filter(value => normalizedValues.has(value) && !valuesToClose.has(value))
}
watch(
openSkillSections,
(values) => {
openSkillValues.value = [...values]
},
{ immediate: true }
)
onMounted(() => {
updateSkillGridColumns()
window.addEventListener('resize', updateSkillGridColumns)
})
onUnmounted(() => {
if (!import.meta.client) return
window.removeEventListener('resize', updateSkillGridColumns)
})
const accordionUi = {
root: 'flex flex-col gap-3 md:grid md:grid-cols-3 md:gap-4 md:items-stretch',
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm md:self-stretch data-[state=closed]:md:self-start md:h-full data-[state=open]:md:h-full data-[state=closed]:md:h-auto data-[state=open]:md:min-h-[320px] data-[state=closed]:md:min-h-[64px]',
header: 'px-4 border-b border-gray-200/70 dark:border-gray-700/50',
trigger: 'group flex-1 items-center gap-2 py-3 text-left md:cursor-default',
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
root: 'flex flex-col gap-3 md:grid md:grid-cols-2 xl:grid-cols-3 md:gap-4 md:items-stretch',
item: 'flex flex-col rounded-lg border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm data-[state=open]:md:min-h-[240px]',
header: 'px-4 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
trigger: 'group flex-1 items-center gap-2 py-3 text-left cursor-pointer',
label: 'text-sm font-semibold text-slate-700 dark:text-slate-200',
leadingIcon: 'shrink-0',
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180 md:hidden',
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
content: 'px-4 pb-4 pt-1 data-[state=closed]:hidden',
body: 'pt-1'
} as const
const outerAccordionUi = {
root: 'flex flex-col',
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm',
header: 'sticky top-[60px] z-30 px-3 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50 rounded-t-2xl bg-white/95 shadow-sm backdrop-blur-md dark:bg-gray-950/95 sm:top-[60px]',
trigger: 'group flex-1 h-9 min-h-0 items-center gap-2 py-0 text-left cursor-pointer',
label: 'text-xs font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300 sm:text-sm',
leadingIcon: 'shrink-0',
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
content: 'px-4 pb-4 pt-3 data-[state=closed]:hidden',
body: 'pt-1'
} as const
</script>
+1 -1
View File
@@ -92,7 +92,7 @@ export const aiStackItems: AiItem[] = [
},
{
id: "ide-rider",
name: "Rider",
name: "JetBrains Rider",
category: "ide",
group: "ide_dev",
icon: "i-twemoji-rocket",
+163 -3
View File
@@ -3,17 +3,20 @@ import type { PortfolioData } from "@/types/portfolio.types";
export const portfolio: PortfolioData = {
profile: {
name: "Mohammad Mahdi Mohammadi",
title: "A Developer?",
title: "Backend & DevOps Engineer",
headline: "Backend & DevOps Practitioner",
location: "Kerman Province - Iran",
summary:
"My background is in backend development and system administration, with a strong emphasis on infrastructure, automation, and server side logic. Over the past year, I've shifted my focus toward DevOps and observabilityrecently working with the LGTM stack (Loki, Grafana, Tempo, Mimir + Prometheus, alloy, ...), which has been a major step toward more mature, enterprise grade systems. I continue to work extensively with Go and .NET for backend services, and I genuinely enjoy tackling system design problems that require thoughtful architecture, scalability, and reliability. While I still build interfaces when needed, my primary interest lies in the layers beneath the UI.",
"My background is in backend development and system administration, with a strong emphasis on infrastructure, automation, and server side logic. Over the past year, I've shifted my focus toward DevOps and observability; recently working with the LGTM stack (Loki, Grafana, Tempo, Mimir + Prometheus, alloy, ...), which has been a major step toward more mature, enterprise grade systems. I continue to work extensively with Go and .NET for backend services, and I genuinely enjoy tackling system design problems that require thoughtful architecture, scalability, and reliability. While I still build interfaces when needed, my primary interest lies in the layers beneath the UI.",
availability: "Open to DevOps, infrastructure, and backend engineering roles.",
focusAreas: ["Infrastructure & Observability", "Backend Services", "System Administration", "CI/CD & Automation"],
// avatar: "/img/AliProfile.webp",
socials: {
website: "https://mahdium.ir/",
github: "https://github.com/mmahdium",
// linkedin: "https://www.linkedin.com/in/aliarghyani/",
// telegram: "https://t.me/mmahdium",
// whatsapp: "https://wa.me/989123220694",
whatsapp: "https://wa.me/989907875312",
// spotify: "https://open.spotify.com/user/aliarghyani",
// bento: "https://bento.me/arghyani",
// instagram: "https://www.instagram.com/mmahdiom/",
@@ -75,6 +78,143 @@ export const portfolio: PortfolioData = {
],
},
services: [
{
title: "Infrastructure & Observability Setup",
description: "Designing and deploying monitoring stacks with Prometheus, Grafana, Loki, and Tempo for enterprise-grade observability.",
icon: "i-mdi-chart-box-outline",
},
{
title: "Backend Service Development",
description: "Building production-ready backend services with Go and .NET, focused on architecture, scalability, and reliability.",
icon: "i-mdi-server-network",
},
{
title: "CI/CD & Automation",
description: "Setting up continuous integration and deployment pipelines, infrastructure automation with Ansible, and GitOps workflows.",
icon: "i-mdi-source-branch-sync",
},
{
title: "System Administration & Linux Ops",
description: "Managing server infrastructure, networking, container orchestration, and internal services across Linux environments.",
icon: "i-mdi-linux",
},
{
title: "Database Design & Management",
description: "Working with PostgreSQL, MariaDB, Elasticsearch, and Supabase for data persistence, search, and real-time features.",
icon: "i-mdi-database-outline",
},
{
title: "Container Orchestration & Docker",
description: "Containerizing applications, managing Docker environments, and building scalable deployment configurations.",
icon: "i-logos-docker-icon",
},
],
stackGroups: [
{
title: "Observability & Analytics",
description: "Monitoring, logging, metrics, and analytics across infrastructure.",
items: [
{ label: "Grafana", icon: "i-logos-grafana", to: "https://grafana.com/oss/grafana/", type: "Tool" },
{ label: "Loki", icon: "i-simple-icons-grafana", to: "https://grafana.com/oss/grafana/", type: "Service" },
{ label: "Prometheus", icon: "i-logos-prometheus", to: "https://grafana.com/oss/prometheus/", type: "Service" },
{ label: "Alloy", icon: "i-vscode-icons-file-type-alloy", to: "https://grafana.com/oss/alloy-opentelemetry-collector/", type: "Service" },
{ label: "OpenTelemetry", icon: "i-logos-opentelemetry-icon", to: "https://opentelemetry.io/", type: "Service" },
{ label: "Matomo", icon: "i-logos-matomo-icon", to: "https://matomo.org", type: "Service" },
{ label: "Umami", icon: "i-simple-icons-umami", to: "https://umami.is/", type: "Service" },
],
},
{
title: "Infrastructure & DevOps",
description: "Containerization, orchestration, CI/CD, and server management.",
items: [
{ label: "Docker", icon: "i-logos-docker-icon", to: "https://www.docker.com", type: "DevOps" },
{ label: "Kubernetes", icon: "i-devicon-kubernetes", to: "https://kubernetes.io/", type: "DevOps" },
{ label: "Ansible", icon: "i-simple-icons-ansible", to: "https://ansible.com/", type: "DevOps" },
{ label: "Nginx", icon: "i-logos-nginx", to: "https://www.nginx.com", type: "DevOps" },
{ label: "Linux", icon: "i-logos-linux-tux", to: "https://www.kernel.org", type: "Tool" },
{ label: "Ubuntu", icon: "i-logos-ubuntu", to: "https://www.ubuntu.com", type: "Tool" },
{ label: "Git", icon: "i-logos-git-icon", to: "https://git-scm.com", type: "Tool" },
{ label: "GitHub", icon: "i-logos-github-octocat", to: "https://github.com", type: "Service" },
{ label: "GitLab", icon: "i-logos-gitlab-icon", to: "https://gitlab.com", type: "Service" },
{ label: "GitHub Actions", icon: "simple-icons:githubactions", to: "https://docs.github.com/actions", type: "DevOps" },
{ label: "Gitea", icon: "i-devicon-gitea", to: "https://about.gitea.com", type: "Service" },
{ label: "Cloudflare", icon: "i-logos-cloudflare-icon", to: "https://www.cloudflare.com", type: "Service" },
{ label: "Vercel", icon: "i-logos-vercel-icon", to: "https://vercel.com", type: "Service" },
],
},
{
title: "Backend Languages & Frameworks",
description: "Server-side languages and frameworks for building backend services.",
items: [
{ label: "Go", icon: "i-logos-go", to: "https://golang.org/", type: "Language" },
{ label: "Gin", icon: "i-logos-gin", to: "https://gin-gonic.com", type: "Framework" },
{ label: ".NET", icon: "i-logos-dotnet", to: "https://dotnet.microsoft.com/", type: "Framework" },
{ label: "TypeScript", icon: "i-logos-typescript-icon", to: "https://www.typescriptlang.org", type: "Language" },
{ label: "Python", icon: "i-logos-python", to: "https://www.python.org", type: "Language" },
],
},
{
title: "Databases & Storage",
description: "Relational, document, cache, and search data stores.",
items: [
{ label: "PostgreSQL", icon: "i-logos-postgresql", to: "https://www.postgresql.org", type: "Data" },
{ label: "MariaDB", icon: "i-logos-mariadb-icon", to: "https://mariadb.org", type: "Data" },
{ label: "MongoDB", icon: "i-logos-mongodb-icon", to: "https://www.mongodb.com", type: "Data" },
{ label: "Redis", icon: "i-logos-elasticsearch", to: "https://redis.io/", type: "Data" },
{ label: "ElasticSearch", icon: "i-logos-elasticsearch", to: "https://www.elastic.co/elasticsearch", type: "Data" },
{ label: "SQLite", icon: "i-logos-sqlite", to: "https://www.sqlite.org/index.html", type: "Data" },
{ label: "Supabase", icon: "i-logos-supabase-icon", to: "https://supabase.io", type: "Service" },
],
},
{
title: "Frontend",
description: "UI frameworks, component libraries, and styling tools.",
items: [
{ label: "Vue.js", icon: "i-logos-vue", to: "https://vuejs.org", type: "Framework" },
{ label: "Nuxt.js", icon: "i-logos-nuxt-icon", to: "https://nuxt.com", type: "Framework" },
{ label: "Tailwind CSS", icon: "i-logos-tailwindcss-icon", to: "https://tailwindcss.com", type: "Library" },
{ label: "DaisyUI", icon: "i-logos-daisyui-icon", to: "https://daisyui.com", type: "Library" },
{ label: "Pinia", icon: "i-logos-pinia", to: "https://pinia.vuejs.org", type: "Library" },
{ label: "vue-i18n", icon: "i-twemoji-globe-with-meridians", to: "https://vue-i18n.intlify.dev", type: "Library" },
{ label: "Axios", icon: "simple-icons:axios", to: "https://axios-http.com", type: "Library" },
{ label: "WebSocket", icon: "i-twemoji-electric-plug", to: "https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API", type: "Library" },
{ label: "Material UI", icon: "i-logos-material-ui", to: "https://mui.com", type: "Library" },
],
},
{
title: "APIs, Auth & Hardware",
description: "API documentation, authentication protocols, and hardware prototyping.",
items: [
{ label: "Swagger", icon: "logos:swagger", to: "https://swagger.io", type: "Tool" },
{ label: "OAuth2", icon: "i-devicon-oauth", to: "https://oauth.net/", type: "Service" },
{ label: "C", icon: "i-logos-c", to: "https://en.wikipedia.org/wiki/C_(programming_language)", type: "Language" },
{ label: "C++", icon: "i-logos-c-plusplus", to: "https://en.wikipedia.org/wiki/C%2B%2B", type: "Language" },
{ label: "Arduino", icon: "simple-icons:arduino", to: "https://www.arduino.cc/", type: "Tool" },
],
},
],
reactExpansion: {
title: "Currently expanding into scalable system design and observability",
description:
"Building deeper skills in designing asynchronous, scalable backend systems and full observability solutions with the Grafana stack and OpenTelemetry.",
items: [
{ label: "OpenTelemetry", icon: "i-logos-opentelemetry-icon" },
{ label: "Grafana Stack", icon: "i-logos-grafana" },
{ label: "Async Systems", icon: "i-mdi-arrows-split-vertical" },
{ label: "Scalable Architecture", icon: "i-mdi-chart-box-outline" },
{ label: "Go", icon: "i-logos-go" },
],
},
cta: {
title: "Available for DevOps, infrastructure, and backend engineering opportunities",
description:
"I am especially interested in DevOps, infrastructure automation, backend services, and observability platforms that require thoughtful architecture and reliability.",
},
experiences: [
{
company: "Group7Sky",
@@ -165,6 +305,11 @@ export const portfolio: PortfolioData = {
name: "TBW (To Be Watched)",
description:
"A simple and beautiful media list app built with Vue 3 and Tailwind CSS (daisyUI).",
context: "Personal project for tracking and organizing media content in a clean, accessible interface.",
role: "Full-stack development, UI design, and API integration.",
features: ["Media list management", "Responsive UI", "API-driven content", "Clean component architecture"],
stack: ["Vue 3", "TypeScript", "Tailwind CSS", "daisyUI", "Vite"],
outcome: "A polished media tracking app with a focus on simplicity and user experience.",
links: [
{
label: "GitHub",
@@ -192,6 +337,11 @@ export const portfolio: PortfolioData = {
{
name: "TorrentMax",
description: "Enrich Your Magnet Links with Trackers Instantly",
context: "Open-source tool for automatically enriching torrent magnet links with tracker lists.",
role: "Designed and built the Nuxt frontend, implemented tracker API integration.",
features: ["Magnet link enrichment", "Tracker database", "One-click copy", "Responsive design"],
stack: ["Nuxt", "Vue", "TypeScript", "Tailwind CSS"],
outcome: "Helps users improve torrent download speeds by ensuring comprehensive tracker coverage.",
// thumbnail: "/img/projects/ideh.png",
status: "Active",
opensource: true,
@@ -219,6 +369,11 @@ export const portfolio: PortfolioData = {
name: "HoolIt",
description:
"HoolIt is an open-source project aiming to provide a simple and easy-to-use alternative to the beloved dweet.io.",
context: "Open-source IoT data sharing platform as a lightweight alternative to dweet.io.",
role: "Backend development with .NET, containerization with Docker.",
features: ["REST API for data posting", "Real-time data retrieval", "Docker deployment", "Simple integration"],
stack: [".NET", "Docker"],
outcome: "Provides a self-hostable, lightweight data sharing service for IoT and quick prototyping.",
// thumbnail: "/img/projects/monasef.png",
status: "Active",
opensource: true,
@@ -236,6 +391,11 @@ export const portfolio: PortfolioData = {
name: "PolyList",
description:
"PolyList is a social music platform where you can create, manage, and share playlists and libraries with friends. Track your listening history through a public profile powered by web scrobbling.",
context: "Social music platform for collaborative playlist management and listening history tracking.",
role: "Backend development with Go, frontend with Nuxt, infrastructure with Docker and Supabase.",
features: ["Playlist creation & sharing", "Web scrobbling", "Social profiles", "Real-time updates"],
stack: ["Go", "Gin", "Nuxt", "Vue", "Supabase", "PostgreSQL", "Docker", "Vercel"],
outcome: "A growing social music platform with active users sharing and discovering music together.",
thumbnail: "/img/projects/polylist.png",
status: "Active",
opensource: false,
+25 -16
View File
@@ -1,44 +1,49 @@
<template>
<div class="max-w-6xl mx-auto pt-12 md:pt-24">
<!-- Above-the-fold content only (improves LCP by reducing initial render work) -->
<div class="mx-auto max-w-6xl pt-10 md:pt-20">
<Hero />
<!-- Below-the-fold sections rendered on server for SEO (SSR) -->
<!-- <TagGroups /> -->
<Services />
<Skills />
<ReactExpansion />
<ProjectsList />
<WorkExperience />
<!-- <GitHubActivity username="mmahdium" /> -->
<AIStack />
<SoftSkills />
<LanguageSkills />
<!-- <GitHubActivity username="mmahdium" /> -->
<WorkExperience />
<EducationList />
<RecommendationsCarousel />
<ProjectsList />
<HireCTA />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, defineAsyncComponent } from 'vue'
import { defineAsyncComponent } from 'vue'
import Hero from '@/components/portfolio/Hero.vue'
const TagGroups = defineAsyncComponent(() => import('@/components/portfolio/TagGroups.vue'))
import { usePortfolio } from '@/composables/usePortfolio'
const Services = defineAsyncComponent(() => import('@/components/portfolio/Services.vue'))
const Skills = defineAsyncComponent(() => import('@/components/portfolio/Skills.vue'))
const ReactExpansion = defineAsyncComponent(() => import('@/components/portfolio/ReactExpansion.vue'))
const ProjectsList = defineAsyncComponent(() => import('@/components/portfolio/ProjectsList.vue'))
const WorkExperience = defineAsyncComponent(() => import('@/components/portfolio/WorkExperience.vue'))
const GitHubActivity = defineAsyncComponent(() => import('@/components/portfolio/GitHubActivity.vue'))
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'))
const ProjectsList = defineAsyncComponent(() => import('@/components/portfolio/ProjectsList.vue'))
import { usePortfolio } from '@/composables/usePortfolio'
const HireCTA = defineAsyncComponent(() => import('@/components/portfolio/HireCTA.vue'))
const appConfig = useAppConfig()
const portfolio = usePortfolio()
const { t, locale } = useI18n()
const siteTitle = computed(() => `${portfolio.value.profile.name}${t('meta.portfolioTitleSuffix')}`)
const description = computed(() => `${portfolio.value.profile.title}. ${portfolio.value.profile.summary}`)
const siteTitle = computed(() => `${portfolio.value.profile.name} - ${t('meta.portfolioTitleSuffix')}`)
const description = computed(() =>
'Backend and DevOps engineer specializing in Go, .NET, infrastructure automation, observability, and system administration.'
)
const siteUrl = 'https://mahdium.ir'
useHead(() => ({
title: siteTitle.value,
@@ -52,7 +57,11 @@ useSeoMeta({
description: () => description.value,
ogTitle: () => siteTitle.value,
ogDescription: () => description.value,
ogUrl: siteUrl,
ogType: 'website',
ogLocale: () => (locale.value === 'fa' ? 'fa_IR' : 'en_US'),
twitterCard: 'summary_large_image',
twitterTitle: () => siteTitle.value,
twitterDescription: () => description.value,
})
</script>
+6 -1
View File
@@ -5,6 +5,7 @@ definePageMeta({
})
const route = useRoute()
const colorMode = useColorMode()
const isPrintMode = computed(() => route.query.print === 'true')
useHead({
@@ -16,12 +17,16 @@ useHead({
// Reset scroll position on mount
onMounted(() => {
const savedMode = localStorage.getItem('nuxt-color-mode')
if (savedMode === 'dark') {
colorMode.preference = 'dark'
}
window.scrollTo(0, 0)
})
</script>
<template>
<div class="bg-white dark:bg-gray-900 print:bg-white">
<div class="bg-white dark:bg-gray-900 print:bg-white" :class="{ dark: colorMode.value === 'dark' }">
<!-- 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
+30
View File
@@ -15,8 +15,11 @@ export type SocialLinks = Partial<{
export type Profile = {
name: string
title: string
headline?: string
location?: string
summary: string
availability?: string
focusAreas?: string[]
avatar?: string
socials?: SocialLinks
}
@@ -52,9 +55,27 @@ export type Tag = {
export type TagGroup = {
title: string
description?: string
items: Tag[]
}
export type Service = {
title: string
description: string
icon?: string
}
export type ReactExpansion = {
title: string
description: string
items: Tag[]
}
export type CTA = {
title: string
description: string
}
// Backward-compatible single-position experience
export type Experience = {
company: string
@@ -108,6 +129,11 @@ export type Education = {
export type Project = {
name: string
description: string
context?: string
role?: string
features?: string[]
stack?: string[]
outcome?: string
status?: string
links?: { label: string; to: string; icon?: string }[]
icons?: string[]
@@ -121,6 +147,10 @@ export type PortfolioData = {
mainTools: TagGroup
roles: TagGroup
values: TagGroup
services?: Service[]
stackGroups?: TagGroup[]
reactExpansion?: ReactExpansion
cta?: CTA
// Supports both single experiences and grouped company timelines
experiences: Array<Experience | CompanyExperience>
education: Education[]
+28 -5
View File
@@ -4,14 +4,16 @@
"workAccordion": "Building enterprise solutions and managing infrastructure",
"education": "Education",
"recommendations": "Recommendations",
"projects": " Projects",
"projects": "Projects",
"projectsAccordion": "Real-world applications built for clients and the community.",
"blog": "Blog",
"contact": "Contact",
"services": "What I Can Help With",
"mainTools": "Main tools",
"roles": "Roles",
"values": "Values",
"skills": "Skills",
"currentlyExpanding": "Currently expanding",
"softSkills": "Values & Soft Skills",
"language": "Language Proficiency",
"githubActivity": "GitHub Activity"
@@ -20,7 +22,12 @@
"expert": "Expert",
"proficient": "Proficient",
"usedBefore": "Used before",
"aiStack": "AI Stack"
"aiStack": "AI Stack",
"subtitle": "Grouped by how the tools support backend, infrastructure, and DevOps work.",
"accordionLabel": "Purpose-grouped stack for backend, infrastructure, and DevOps work."
},
"services": {
"subtitle": "Backend, infrastructure, and DevOps work focused on reliability and automation."
},
"softSkillsAccordion": {
"label": "Mindset and interpersonal strengths that drive team success."
@@ -72,7 +79,15 @@
"hero": {
"currently": "Currently at",
"viewResume": "View Resume",
"downloadPdf": "Download PDF"
"downloadPdf": "Download PDF",
"cardSummary": "Backend services, infrastructure, and DevOps systems.",
"contactTitle": "Contact Mahdi",
"contactDescription": "Use this email for infrastructure, backend, or DevOps project inquiries.",
"emailAddress": "Email address",
"clipboardBlocked": "Clipboard access is blocked by the browser on this address. You can still select the email or open your email app.",
"close": "Close",
"copy": "Copy",
"openEmailApp": "Open Email App"
},
"toasts": {
"emailCopied": {
@@ -87,6 +102,8 @@
"buttons": {
"blog": "Blog Posts",
"contact": "Contact",
"contactMe": "Contact Me",
"viewProjects": "View Projects",
"website": "Website",
"switchToEnglish": "English",
"switchToPersian": "فارسی"
@@ -142,10 +159,16 @@
"suggestion3": "Return to the homepage and start fresh"
},
"meta": {
"portfolioTitleSuffix": "Portfolio"
"portfolioTitleSuffix": "Backend & DevOps Engineer"
},
"projectLabels": {
"openSource": "Open Source"
"openSource": "Open Source",
"context": "Context",
"role": "My Role",
"keyFeatures": "Key Features",
"techStack": "Tech Stack",
"outcome": "Outcome",
"caseStudyDetails": "Case study details"
},
"theme": {
"customizer": "Theme Customizer",
+27 -4
View File
@@ -1,17 +1,19 @@
{
"sections": {
"work": "سوابق کاری",
"workAccordion": "بیش از ۷ سال تجربه در توسعه راهکارهای سازمانی و اپلیکیشن‌های وب.",
"workAccordion": "تجربه در توسعه سیستم‌ها و مدیریت زیرساخت.",
"education": "تحصیلات",
"recommendations": "توصیه‌نامه‌ها",
"projects": "پروژه‌ها",
"projectsAccordion": "اپلیکیشن‌های واقعی ساخته شده برای مشتریان و جامعه توسعه‌دهندگان.",
"blog": "وبلاگ",
"contact": "ارتباط",
"services": "خدماتی که می‌توانم ارائه کنم",
"mainTools": "ابزارهای اصلی",
"roles": "نقش‌ها",
"values": "ارزش‌ها",
"skills": "مهارت‌ها",
"currentlyExpanding": "در حال گسترش مهارت‌ها",
"softSkills": "ارزش‌ها و مهارت‌های نرم",
"language": "مهارت‌های زبانی",
"githubActivity": "فعالیت گیت‌هاب"
@@ -20,7 +22,12 @@
"expert": "حرفه‌ای",
"proficient": "مسلط",
"usedBefore": "تجربه قبلی",
"aiStack": "استک هوش مصنوعی"
"aiStack": "استک هوش مصنوعی",
"subtitle": "گروه‌بندی ابزارها بر اساس کاربردشان در بک‌اند، زیرساخت و عملیات.",
"accordionLabel": "مهارت‌های فرانت‌اند گروه‌بندی‌شده برای بک‌اند، زیرساخت و عملیات."
},
"services": {
"subtitle": "تمرکز روی بک‌اند، زیرساخت و اتوماسیون با تمرکز بر قابلیت اطمینان."
},
"softSkillsAccordion": {
"label": "ذهنیت و توانمندی‌های بین‌فردی که موفقیت تیم را رقم می‌زنند."
@@ -58,6 +65,8 @@
"buttons": {
"blog": "مقالات",
"contact": "ارتباط",
"contactMe": "تماس با من",
"viewProjects": "مشاهده پروژه‌ها",
"website": "وب‌سایت",
"switchToEnglish": "English",
"switchToPersian": "فارسی"
@@ -116,7 +125,13 @@
"portfolioTitleSuffix": "نمونه‌کارها"
},
"projectLabels": {
"openSource": "اوپن‌سورس"
"openSource": "اوپن‌سورس",
"context": "زمینه پروژه",
"role": "نقش من",
"keyFeatures": "ویژگی‌های کلیدی",
"techStack": "استک فنی",
"outcome": "نتیجه",
"caseStudyDetails": "جزئیات کیس‌استادی"
},
"theme": {
"customizer": "سفارشی‌سازی تم",
@@ -151,7 +166,15 @@
"hero": {
"currently": "در حال حاضر در",
"viewResume": "مشاهده رزومه",
"downloadPdf": "دانلود PDF"
"downloadPdf": "دانلود PDF",
"cardSummary": "سیستم‌های بک‌اند، زیرساخت و عملیات.",
"contactTitle": "ارتباط با مهدی",
"contactDescription": "برای پروژه‌های زیرساخت، بک‌اند یا عملیات از این ایمیل استفاده کنید.",
"emailAddress": "آدرس ایمیل",
"clipboardBlocked": "دسترسی به کلیپ‌بورد روی این آدرس توسط مرورگر محدود شده است. می‌توانید ایمیل را انتخاب کنید یا اپلیکیشن ایمیل را باز کنید.",
"close": "بستن",
"copy": "کپی",
"openEmailApp": "باز کردن ایمیل"
},
"toasts": {
"emailCopied": {
+1 -1
View File
@@ -175,7 +175,7 @@ export default defineNuxtConfig({
file: "fa.json",
},
],
langDir: "locales",
langDir: '../i18n/locales',
detectBrowserLanguage: {
useCookie: true,
cookieKey: "i18n_redirected",
+4 -1
View File
@@ -56,7 +56,7 @@
"@intlify/unplugin-vue-i18n": "11.0.1"
},
"engines": {
"node": ">=18.20.0 <23"
"node": "^20.19.0 || >=22.12.0"
},
"browserslist": [
"last 2 chrome versions",
@@ -67,6 +67,9 @@
"not dead"
],
"packageManager": "pnpm@10.24.0",
"volta": {
"node": "22.14.0"
},
"onlyBuiltDependencies": [
"better-sqlite3"
]