mirror of
https://github.com/mmahdium/portfolio.git
synced 2026-09-28 02:21:41 +03:30
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:
@@ -25,3 +25,5 @@ logs
|
|||||||
|
|
||||||
# MCP configuration files (may contain secrets)
|
# MCP configuration files (may contain secrets)
|
||||||
.roo
|
.roo
|
||||||
|
|
||||||
|
.mimocode/
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
export default defineAppConfig({
|
export default defineAppConfig({
|
||||||
ui: {
|
ui: {
|
||||||
strategy: "merge",
|
strategy: "merge",
|
||||||
primary: "violet",
|
primary: "lime",
|
||||||
gray: "zinc",
|
gray: "zinc",
|
||||||
colors: {
|
colors: {
|
||||||
primary: 'violet',
|
primary: 'lime',
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
dynamic: true,
|
dynamic: true,
|
||||||
|
|||||||
@@ -4,80 +4,37 @@
|
|||||||
<div
|
<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">
|
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 justify-between px-2 py-2">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Home -->
|
<UTooltip v-for="item in navItems" :key="item.value" :text="item.label">
|
||||||
<div class="flex items-center gap-1.5">
|
<UButton
|
||||||
<UTooltip :text="t('nav.home')">
|
type="button"
|
||||||
<UButton class="cursor-pointer transition-all duration-200"
|
variant="soft"
|
||||||
:class="[isActive('hero') ? activeClass : inactiveClass]" variant="soft" square icon="i-twemoji-house"
|
class="nav-link-button cursor-pointer gap-1.5 px-2 transition-all duration-200 lg:px-2.5"
|
||||||
:aria-label="t('nav.home')" @click="goTo('hero')" />
|
:class="[item.active ? activeClass : inactiveClass]"
|
||||||
</UTooltip>
|
:aria-label="item.label"
|
||||||
<button type="button"
|
@click="goTo(item.target)"
|
||||||
class="hidden lg:inline-flex cursor-pointer text-sm font-medium transition-colors duration-200"
|
>
|
||||||
:class="[isActive('hero') ? labelActiveClass : labelInactiveClass]" @click="goTo('hero')">
|
<template #leading>
|
||||||
{{ t('nav.home') }}
|
<UIcon :name="item.icon" class="text-xl" />
|
||||||
</button>
|
</template>
|
||||||
</div>
|
<span class="hidden lg:inline text-sm font-medium">{{ item.label }}</span>
|
||||||
|
</UButton>
|
||||||
|
</UTooltip>
|
||||||
|
|
||||||
<!-- Skills -->
|
<UTooltip :text="t('sections.blog')">
|
||||||
<div class="flex items-center gap-1.5">
|
<UButton
|
||||||
<UTooltip :text="t('sections.skills')">
|
:to="localePath('/blog')"
|
||||||
<UButton class="cursor-pointer transition-all duration-200"
|
variant="soft"
|
||||||
:class="[isActive('skills') ? activeClass : inactiveClass]" variant="soft" square
|
class="nav-link-button cursor-pointer gap-1.5 px-2 transition-all duration-200 lg:px-2.5"
|
||||||
icon="i-twemoji-hammer-and-wrench" :aria-label="t('sections.skills')" @click="goTo('skills')" />
|
:class="[isBlogActive ? activeClass : inactiveClass]"
|
||||||
</UTooltip>
|
:aria-label="t('sections.blog')"
|
||||||
<button type="button"
|
>
|
||||||
class="hidden lg:inline-flex cursor-pointer text-sm font-medium transition-colors duration-200"
|
<template #leading>
|
||||||
:class="[isActive('skills') ? labelActiveClass : labelInactiveClass]" @click="goTo('skills')">
|
<UIcon name="i-twemoji-memo" class="text-xl" />
|
||||||
{{ t('sections.skills') }}
|
</template>
|
||||||
</button>
|
<span class="hidden lg:inline text-sm font-medium">{{ t('sections.blog') }}</span>
|
||||||
</div>
|
</UButton>
|
||||||
|
</UTooltip>
|
||||||
<!-- 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<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 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 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
|
const sectionIds = ['hero', 'skills', 'work', 'projects'] as const
|
||||||
type Target = typeof sectionIds[number]
|
type Target = typeof sectionIds[number]
|
||||||
@@ -157,6 +112,37 @@ const isActive = (id: Target) => {
|
|||||||
return activeSection.value === id
|
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) {
|
function scrollToSection(id: Target) {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,74 @@
|
|||||||
<template>
|
<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>
|
<UContainer>
|
||||||
<div class="flex flex-col-reverse sm:flex-row items-center sm:items-center justify-center gap-4 sm:gap-8">
|
<div class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||||
<div class="flex-1 max-w-2xl text-center sm:text-start">
|
<div class="text-center sm:text-start lg:col-span-2">
|
||||||
<h1 class="text-primary text-3xl sm:text-4xl font-bold tracking-tight mb-3 sm:mb-4">
|
<div class="mb-4 flex flex-wrap items-center justify-center gap-2 sm:justify-start">
|
||||||
{{ portfolio.profile.name }}
|
<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>
|
</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 }}
|
{{ portfolio.profile.summary }}
|
||||||
</p>
|
</p>
|
||||||
<div v-if="portfolio.profile.location || currentRole"
|
</div>
|
||||||
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">
|
<div class="text-center sm:text-start">
|
||||||
<UIcon name="i-twemoji-round-pushpin" class="text-base text-primary-600 me-1 dark:text-primary-300" />
|
<div v-if="portfolio.profile.focusAreas?.length" class="mb-6 flex flex-wrap justify-center gap-2 sm:justify-start">
|
||||||
<span class="leading-relaxed">{{ portfolio.profile.location }}</span>
|
<UBadge v-for="area in portfolio.profile.focusAreas" :key="area" color="neutral" variant="soft" class="rounded-full">
|
||||||
</div>
|
{{ area }}
|
||||||
<div v-if="currentRole"
|
</UBadge>
|
||||||
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>
|
||||||
<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`"
|
<div class="mb-6 flex flex-wrap items-center justify-center gap-3 sm:justify-start">
|
||||||
class="h-7 w-7 rounded-md object-contain" loading="lazy" />
|
<UButton icon="i-mdi-email-outline" color="primary" size="lg" class="rounded-lg" @click="copyEmail">
|
||||||
<span class="">{{ t('hero.currently') }}</span>
|
{{ translate('buttons.contactMe', 'Contact Me') }}
|
||||||
<span class="font-semibold company-name">
|
</UButton>
|
||||||
<a v-if="currentRole.companyLink" :href="currentRole.companyLink" target="_blank" rel="noopener"
|
<UButton icon="i-mdi-folder-multiple-outline" color="neutral" variant="soft" size="lg" class="rounded-lg" @click="scrollTo('projects')">
|
||||||
class="hover:underline company-name">
|
{{ translate('buttons.viewProjects', 'View Projects') }}
|
||||||
{{ currentRole.company }}
|
</UButton>
|
||||||
</a>
|
<NuxtLink to="/resume" class="resume-gradient-button" @click="preserveColorModeForResume">
|
||||||
<span v-else>{{ currentRole.company }}</span>
|
<UIcon name="i-heroicons-document-text" class="text-lg" />
|
||||||
</span>
|
<span>{{ t('hero.viewResume') }}</span>
|
||||||
</div>
|
<UIcon name="i-heroicons-sparkles" class="text-sm opacity-80" />
|
||||||
<!-- 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" />
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</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">
|
<div class="flex flex-wrap items-center justify-center gap-3 sm:justify-start">
|
||||||
<ClientTooltip :text="emailTooltip">
|
<ClientTooltip :text="emailTooltip">
|
||||||
<UButton icon="i-twemoji-e-mail" :square="true" color="gray" variant="ghost"
|
<UButton icon="i-twemoji-e-mail" :square="true" color="gray" variant="ghost"
|
||||||
@@ -103,14 +122,84 @@
|
|||||||
</ClientTooltip>
|
</ClientTooltip>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div v-if="portfolio.profile.avatar" class="mx-auto w-full max-w-[280px]">
|
||||||
<NuxtImg :src="portfolio.profile.avatar || undefined" :alt="portfolio.profile.name"
|
<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">
|
||||||
sizes="96px sm:128px md:160px" width="160" height="160" class="h-full w-full object-cover" format="webp"
|
<div class="mx-auto h-36 w-36 overflow-hidden rounded-full ring-4 ring-primary-400/35 dark:ring-primary-300/25">
|
||||||
preload />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</UContainer>
|
</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>
|
</section>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
@@ -120,9 +209,24 @@ import { computed, ref } from 'vue'
|
|||||||
import { usePortfolio } from '@/composables/usePortfolio'
|
import { usePortfolio } from '@/composables/usePortfolio'
|
||||||
import type { CompanyExperience, Experience } from '@/types/portfolio.types'
|
import type { CompanyExperience, Experience } from '@/types/portfolio.types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t, te } = useI18n()
|
||||||
const portfolio = usePortfolio()
|
const portfolio = usePortfolio()
|
||||||
const toast = useToast()
|
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 currentRole = computed(() => {
|
||||||
const experiences = portfolio.value.experiences as Array<CompanyExperience | Experience>
|
const experiences = portfolio.value.experiences as Array<CompanyExperience | Experience>
|
||||||
@@ -164,48 +268,47 @@ const currentRole = computed(() => {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Email copy-to-clipboard for Hero quick action
|
|
||||||
*/
|
|
||||||
const emailAddress = 'me@mahdium.ir'
|
const emailAddress = 'me@mahdium.ir'
|
||||||
const emailTooltip = ref('Email')
|
const emailTooltip = ref('Email')
|
||||||
|
const emailDialogOpen = ref(false)
|
||||||
|
const emailDialogMessage = ref('')
|
||||||
|
|
||||||
async function copyEmail() {
|
async function copyEmail() {
|
||||||
|
const copied = await tryCopyEmail()
|
||||||
|
if (!copied) {
|
||||||
|
emailDialogOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryCopyEmail(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(emailAddress)
|
await navigator.clipboard.writeText(emailAddress)
|
||||||
emailTooltip.value = 'Copied'
|
emailTooltip.value = 'Copied'
|
||||||
|
emailDialogMessage.value = ''
|
||||||
setTimeout(() => { emailTooltip.value = 'Email' }, 1500)
|
setTimeout(() => { emailTooltip.value = 'Email' }, 1500)
|
||||||
|
|
||||||
// Nuxt UI toast: success
|
|
||||||
toast.add({
|
toast.add({
|
||||||
title: t('toasts.emailCopied.title'),
|
title: t('toasts.emailCopied.title'),
|
||||||
description: t('toasts.emailCopied.desc', { email: emailAddress }),
|
description: t('toasts.emailCopied.desc', { email: emailAddress }),
|
||||||
icon: 'i-mdi-clipboard-check',
|
icon: 'i-mdi-clipboard-check',
|
||||||
color: 'emerald'
|
color: 'emerald'
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
// Nuxt UI toast: failure (clipboard not accessible)
|
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.')
|
||||||
toast.add({
|
return false
|
||||||
title: t('toasts.copyFailed.title'),
|
}
|
||||||
description: t('toasts.copyFailed.desc', { email: emailAddress }),
|
}
|
||||||
icon: 'i-mdi-clipboard-alert',
|
|
||||||
color: 'amber'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fallback prompt if clipboard API is unavailable
|
async function handleModalCopy() {
|
||||||
const ok = typeof window !== 'undefined' && window.confirm(`Copy email:\n\n${emailAddress}`)
|
await tryCopyEmail()
|
||||||
if (ok) {
|
}
|
||||||
emailTooltip.value = 'Copied'
|
|
||||||
setTimeout(() => { emailTooltip.value = 'Email' }, 1500)
|
|
||||||
|
|
||||||
// Show success toast after manual copy confirmation
|
function preserveColorModeForResume() {
|
||||||
toast.add({
|
if (!import.meta.client) return
|
||||||
title: t('toasts.emailCopied.title'),
|
if (colorMode.value === 'dark') {
|
||||||
description: t('toasts.emailCopied.desc', { email: emailAddress }),
|
colorMode.preference = 'dark'
|
||||||
icon: 'i-mdi-clipboard-check',
|
localStorage.setItem('nuxt-color-mode', 'dark')
|
||||||
color: 'emerald'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -219,16 +322,25 @@ async function copyEmail() {
|
|||||||
color: var(--ui-color-primary-300);
|
color: var(--ui-color-primary-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Resume button with primary color gradient */
|
.resume-gradient-button {
|
||||||
.resume-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,
|
background: linear-gradient(to right,
|
||||||
var(--ui-color-primary-600),
|
var(--ui-color-primary-600),
|
||||||
var(--ui-color-primary-500),
|
var(--ui-color-primary-500),
|
||||||
var(--ui-color-primary-400));
|
var(--ui-color-primary-400));
|
||||||
box-shadow: 0 10px 15px -3px color-mix(in oklch, var(--ui-color-primary-500) 25%, transparent);
|
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,
|
background: linear-gradient(to right,
|
||||||
var(--ui-color-primary-700),
|
var(--ui-color-primary-700),
|
||||||
var(--ui-color-primary-600),
|
var(--ui-color-primary-600),
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -6,30 +6,46 @@
|
|||||||
<h2 class="section-title text-start">{{ t('sections.projects') }}</h2>
|
<h2 class="section-title text-start">{{ t('sections.projects') }}</h2>
|
||||||
</div>
|
</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">
|
:ui="accordionUi">
|
||||||
<template #body>
|
<template #body>
|
||||||
<div v-for="g in nonEmptyCategoryList" :key="g.cat" class="space-y-3 mb-5">
|
<UAccordion
|
||||||
<div class="flex items-center gap-2">
|
type="multiple"
|
||||||
<UIcon name="i-twemoji-open-book" class="text-xl" />
|
collapsible
|
||||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{{ tCategory(g.cat) }}</h3>
|
:unmount-on-hide="true"
|
||||||
</div>
|
:items="categoryAccordionItems"
|
||||||
|
:default-value="openProjectCategories"
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
:ui="categoryAccordionUi"
|
||||||
<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">
|
<template #leading>
|
||||||
<div class="flex h-full flex-col gap-4">
|
<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">
|
<div class="flex gap-3">
|
||||||
<NuxtImg v-if="p.thumbnail" :src="p.thumbnail" :alt="`${p.name} logo`"
|
<NuxtImg
|
||||||
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"
|
v-if="p.thumbnail"
|
||||||
width="96" height="96" sizes="96px" format="webp" loading="lazy" />
|
:src="p.thumbnail"
|
||||||
<div v-else
|
:alt="`${p.name} logo`"
|
||||||
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">
|
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" />
|
<UIcon :name="getProjectIcon(p)" class="text-2xl" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-3">
|
<div class="flex flex-1 flex-col gap-3">
|
||||||
<div>
|
<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">
|
<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">
|
<UBadge v-if="p.status" color="primary" variant="soft" class="rounded-full capitalize">
|
||||||
{{ p.status }}
|
{{ p.status }}
|
||||||
@@ -40,27 +56,79 @@
|
|||||||
</UBadge>
|
</UBadge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-200">{{ p.description }}</p>
|
<p class="text-sm leading-relaxed 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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</UCard>
|
<UAccordion
|
||||||
</div>
|
v-if="hasCaseStudyContent(p)"
|
||||||
</div>
|
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>
|
</template>
|
||||||
</UAccordion>
|
</UAccordion>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
@@ -68,26 +136,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { usePortfolio } from '@/composables/usePortfolio'
|
import { usePortfolio } from '@/composables/usePortfolio'
|
||||||
import type { Project } from '@/types/portfolio.types'
|
import type { Project } from '@/types/portfolio.types'
|
||||||
|
|
||||||
const portfolio = usePortfolio()
|
const portfolio = usePortfolio()
|
||||||
const { t } = useI18n()
|
const { t, te } = useI18n()
|
||||||
|
|
||||||
// Detect mobile for accordion behavior (SSR-safe)
|
function translate(key: string, fallback: string): string {
|
||||||
const isMobile = ref(true)
|
return te(key) ? t(key) : fallback
|
||||||
|
}
|
||||||
onMounted(() => {
|
|
||||||
const checkMobile = () => {
|
|
||||||
isMobile.value = window.innerWidth < 768
|
|
||||||
}
|
|
||||||
checkMobile()
|
|
||||||
window.addEventListener('resize', checkMobile)
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('resize', checkMobile)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Accordion config
|
// Accordion config
|
||||||
const accordionItems = computed(() => [{
|
const accordionItems = computed(() => [{
|
||||||
@@ -98,9 +156,9 @@ const accordionItems = computed(() => [{
|
|||||||
const accordionUi = {
|
const accordionUi = {
|
||||||
root: 'flex flex-col',
|
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',
|
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',
|
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 items-center gap-2 py-3 text-left cursor-pointer',
|
trigger: 'group flex-1 h-9 min-h-0 items-center gap-2 py-0 text-left cursor-pointer',
|
||||||
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
|
label: 'text-xs font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300 sm:text-sm',
|
||||||
leadingIcon: 'shrink-0',
|
leadingIcon: 'shrink-0',
|
||||||
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
|
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',
|
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)
|
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 {
|
function tCategory(cat: Category): string {
|
||||||
return t(`projectCategories.${cat}`)
|
return t(`projectCategories.${cat}`)
|
||||||
}
|
}
|
||||||
@@ -146,4 +215,44 @@ function getProjectIcon(project: Project): string {
|
|||||||
|
|
||||||
return 'i-twemoji-rocket'
|
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>
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -4,18 +4,43 @@
|
|||||||
<div class="section-header flex-nowrap justify-between">
|
<div class="section-header flex-nowrap justify-between">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<UIcon name="i-twemoji-hammer-and-wrench" class="text-2xl" />
|
<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>
|
</div>
|
||||||
<SkillFilters v-model="selectedTypes" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UAccordion type="multiple" :unmount-on-hide="false" :items="skillSections" :default-value="openSkillSections"
|
<UAccordion
|
||||||
:ui="accordionUi" :disabled="!isMobile">
|
type="single"
|
||||||
<template #leading="{ item }">
|
collapsible
|
||||||
<UIcon v-if="item.icon" :name="item.icon" class="text-base text-primary-500 dark:text-primary-300" />
|
:unmount-on-hide="true"
|
||||||
</template>
|
:items="skillsAccordionItems"
|
||||||
<template #body="{ item }">
|
default-value="skills"
|
||||||
<SkillGrid :items="sectionItems[item.value as SkillSectionKey] || []" />
|
: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>
|
</template>
|
||||||
</UAccordion>
|
</UAccordion>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
@@ -23,65 +48,171 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import type { Tag, SkillType } from '@/types/portfolio.types'
|
import type { Tag } from '@/types/portfolio.types'
|
||||||
import { expert, proficient, usedBefore } from '@/data/skills'
|
import { expert, proficient, usedBefore } from '@/data/skills'
|
||||||
|
import { usePortfolio } from '@/composables/usePortfolio'
|
||||||
import SkillGrid from '@/components/portfolio/SkillGrid.vue'
|
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)
|
function translate(key: string, fallback: string): string {
|
||||||
const isMobile = ref(true)
|
return te(key) ? t(key) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
type StackGroupView = {
|
||||||
const checkMobile = () => {
|
title: string
|
||||||
isMobile.value = window.innerWidth < 768
|
value: string
|
||||||
}
|
icon: string
|
||||||
checkMobile()
|
description?: string
|
||||||
window.addEventListener('resize', checkMobile)
|
items: Tag[]
|
||||||
onUnmounted(() => {
|
}
|
||||||
window.removeEventListener('resize', checkMobile)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
type SkillSectionKey = 'expert' | 'proficient' | 'usedBefore'
|
const fallbackGroups = computed<StackGroupView[]>(() => ([
|
||||||
|
{ title: t('skills.expert'), value: 'expert', icon: 'i-twemoji-military-medal', items: expert },
|
||||||
// Multi-select filters for Skill types
|
{ title: t('skills.proficient'), value: 'proficient', icon: 'i-twemoji-rocket', items: proficient },
|
||||||
const selectedTypes = ref<SkillType[]>([])
|
{ title: t('skills.usedBefore'), value: 'usedBefore', icon: 'i-twemoji-toolbox', items: usedBefore }
|
||||||
|
|
||||||
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 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 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 = {
|
const accordionUi = {
|
||||||
root: 'flex flex-col gap-3 md:grid md:grid-cols-3 md:gap-4 md:items-stretch',
|
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-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]',
|
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 border-b border-gray-200/70 dark:border-gray-700/50',
|
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 md:cursor-default',
|
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',
|
label: 'text-sm font-semibold text-slate-700 dark:text-slate-200',
|
||||||
leadingIcon: 'shrink-0',
|
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',
|
content: 'px-4 pb-4 pt-1 data-[state=closed]:hidden',
|
||||||
body: 'pt-1'
|
body: 'pt-1'
|
||||||
} as const
|
} 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>
|
</script>
|
||||||
|
|||||||
+1
-1
@@ -92,7 +92,7 @@ export const aiStackItems: AiItem[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ide-rider",
|
id: "ide-rider",
|
||||||
name: "Rider",
|
name: "JetBrains Rider",
|
||||||
category: "ide",
|
category: "ide",
|
||||||
group: "ide_dev",
|
group: "ide_dev",
|
||||||
icon: "i-twemoji-rocket",
|
icon: "i-twemoji-rocket",
|
||||||
|
|||||||
+163
-3
@@ -3,17 +3,20 @@ import type { PortfolioData } from "@/types/portfolio.types";
|
|||||||
export const portfolio: PortfolioData = {
|
export const portfolio: PortfolioData = {
|
||||||
profile: {
|
profile: {
|
||||||
name: "Mohammad Mahdi Mohammadi",
|
name: "Mohammad Mahdi Mohammadi",
|
||||||
title: "A Developer?",
|
title: "Backend & DevOps Engineer",
|
||||||
|
headline: "Backend & DevOps Practitioner",
|
||||||
location: "Kerman Province - Iran",
|
location: "Kerman Province - Iran",
|
||||||
summary:
|
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 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.",
|
"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",
|
// avatar: "/img/AliProfile.webp",
|
||||||
socials: {
|
socials: {
|
||||||
website: "https://mahdium.ir/",
|
website: "https://mahdium.ir/",
|
||||||
github: "https://github.com/mmahdium",
|
github: "https://github.com/mmahdium",
|
||||||
// linkedin: "https://www.linkedin.com/in/aliarghyani/",
|
// linkedin: "https://www.linkedin.com/in/aliarghyani/",
|
||||||
// telegram: "https://t.me/mmahdium",
|
// telegram: "https://t.me/mmahdium",
|
||||||
// whatsapp: "https://wa.me/989123220694",
|
whatsapp: "https://wa.me/989907875312",
|
||||||
// spotify: "https://open.spotify.com/user/aliarghyani",
|
// spotify: "https://open.spotify.com/user/aliarghyani",
|
||||||
// bento: "https://bento.me/arghyani",
|
// bento: "https://bento.me/arghyani",
|
||||||
// instagram: "https://www.instagram.com/mmahdiom/",
|
// 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: [
|
experiences: [
|
||||||
{
|
{
|
||||||
company: "Group7Sky",
|
company: "Group7Sky",
|
||||||
@@ -165,6 +305,11 @@ export const portfolio: PortfolioData = {
|
|||||||
name: "TBW (To Be Watched)",
|
name: "TBW (To Be Watched)",
|
||||||
description:
|
description:
|
||||||
"A simple and beautiful media list app built with Vue 3 and Tailwind CSS (daisyUI).",
|
"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: [
|
links: [
|
||||||
{
|
{
|
||||||
label: "GitHub",
|
label: "GitHub",
|
||||||
@@ -192,6 +337,11 @@ export const portfolio: PortfolioData = {
|
|||||||
{
|
{
|
||||||
name: "TorrentMax",
|
name: "TorrentMax",
|
||||||
description: "Enrich Your Magnet Links with Trackers Instantly",
|
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",
|
// thumbnail: "/img/projects/ideh.png",
|
||||||
status: "Active",
|
status: "Active",
|
||||||
opensource: true,
|
opensource: true,
|
||||||
@@ -219,6 +369,11 @@ export const portfolio: PortfolioData = {
|
|||||||
name: "HoolIt",
|
name: "HoolIt",
|
||||||
description:
|
description:
|
||||||
"HoolIt is an open-source project aiming to provide a simple and easy-to-use alternative to the beloved dweet.io.",
|
"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",
|
// thumbnail: "/img/projects/monasef.png",
|
||||||
status: "Active",
|
status: "Active",
|
||||||
opensource: true,
|
opensource: true,
|
||||||
@@ -236,6 +391,11 @@ export const portfolio: PortfolioData = {
|
|||||||
name: "PolyList",
|
name: "PolyList",
|
||||||
description:
|
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.",
|
"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",
|
thumbnail: "/img/projects/polylist.png",
|
||||||
status: "Active",
|
status: "Active",
|
||||||
opensource: false,
|
opensource: false,
|
||||||
|
|||||||
+25
-16
@@ -1,44 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-w-6xl mx-auto pt-12 md:pt-24">
|
<div class="mx-auto max-w-6xl pt-10 md:pt-20">
|
||||||
<!-- Above-the-fold content only (improves LCP by reducing initial render work) -->
|
|
||||||
<Hero />
|
<Hero />
|
||||||
|
|
||||||
<!-- Below-the-fold sections rendered on server for SEO (SSR) -->
|
<!-- Below-the-fold sections rendered on server for SEO (SSR) -->
|
||||||
<!-- <TagGroups /> -->
|
<Services />
|
||||||
<Skills />
|
<Skills />
|
||||||
|
<ReactExpansion />
|
||||||
|
<ProjectsList />
|
||||||
|
<WorkExperience />
|
||||||
|
<!-- <GitHubActivity username="mmahdium" /> -->
|
||||||
<AIStack />
|
<AIStack />
|
||||||
<SoftSkills />
|
<SoftSkills />
|
||||||
<LanguageSkills />
|
<LanguageSkills />
|
||||||
<!-- <GitHubActivity username="mmahdium" /> -->
|
|
||||||
<WorkExperience />
|
|
||||||
<EducationList />
|
<EducationList />
|
||||||
<RecommendationsCarousel />
|
<RecommendationsCarousel />
|
||||||
<ProjectsList />
|
<HireCTA />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, defineAsyncComponent } from 'vue'
|
import { defineAsyncComponent } from 'vue'
|
||||||
import Hero from '@/components/portfolio/Hero.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 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 AIStack = defineAsyncComponent(() => import('@/components/portfolio/AIStack.vue'))
|
||||||
const SoftSkills = defineAsyncComponent(() => import('@/components/portfolio/SoftSkills.vue'))
|
const SoftSkills = defineAsyncComponent(() => import('@/components/portfolio/SoftSkills.vue'))
|
||||||
const LanguageSkills = defineAsyncComponent(() => import('@/components/portfolio/LanguageSkills.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 EducationList = defineAsyncComponent(() => import('@/components/portfolio/EducationList.vue'))
|
||||||
const RecommendationsCarousel = defineAsyncComponent(() => import('@/components/portfolio/RecommendationsCarousel.vue'))
|
const RecommendationsCarousel = defineAsyncComponent(() => import('@/components/portfolio/RecommendationsCarousel.vue'))
|
||||||
const ProjectsList = defineAsyncComponent(() => import('@/components/portfolio/ProjectsList.vue'))
|
const HireCTA = defineAsyncComponent(() => import('@/components/portfolio/HireCTA.vue'))
|
||||||
import { usePortfolio } from '@/composables/usePortfolio'
|
|
||||||
|
|
||||||
const appConfig = useAppConfig()
|
|
||||||
const portfolio = usePortfolio()
|
const portfolio = usePortfolio()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
|
const siteTitle = computed(() => `${portfolio.value.profile.name} - ${t('meta.portfolioTitleSuffix')}`)
|
||||||
const siteTitle = computed(() => `${portfolio.value.profile.name} — ${t('meta.portfolioTitleSuffix')}`)
|
const description = computed(() =>
|
||||||
const description = computed(() => `${portfolio.value.profile.title}. ${portfolio.value.profile.summary}`)
|
'Backend and DevOps engineer specializing in Go, .NET, infrastructure automation, observability, and system administration.'
|
||||||
|
)
|
||||||
|
const siteUrl = 'https://mahdium.ir'
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: siteTitle.value,
|
title: siteTitle.value,
|
||||||
@@ -52,7 +57,11 @@ useSeoMeta({
|
|||||||
description: () => description.value,
|
description: () => description.value,
|
||||||
ogTitle: () => siteTitle.value,
|
ogTitle: () => siteTitle.value,
|
||||||
ogDescription: () => description.value,
|
ogDescription: () => description.value,
|
||||||
|
ogUrl: siteUrl,
|
||||||
ogType: 'website',
|
ogType: 'website',
|
||||||
ogLocale: () => (locale.value === 'fa' ? 'fa_IR' : 'en_US'),
|
ogLocale: () => (locale.value === 'fa' ? 'fa_IR' : 'en_US'),
|
||||||
|
twitterCard: 'summary_large_image',
|
||||||
|
twitterTitle: () => siteTitle.value,
|
||||||
|
twitterDescription: () => description.value,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ definePageMeta({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const colorMode = useColorMode()
|
||||||
const isPrintMode = computed(() => route.query.print === 'true')
|
const isPrintMode = computed(() => route.query.print === 'true')
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
@@ -16,12 +17,16 @@ useHead({
|
|||||||
|
|
||||||
// Reset scroll position on mount
|
// Reset scroll position on mount
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
const savedMode = localStorage.getItem('nuxt-color-mode')
|
||||||
|
if (savedMode === 'dark') {
|
||||||
|
colorMode.preference = 'dark'
|
||||||
|
}
|
||||||
window.scrollTo(0, 0)
|
window.scrollTo(0, 0)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 -->
|
<!-- 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
|
<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
|
bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ export type SocialLinks = Partial<{
|
|||||||
export type Profile = {
|
export type Profile = {
|
||||||
name: string
|
name: string
|
||||||
title: string
|
title: string
|
||||||
|
headline?: string
|
||||||
location?: string
|
location?: string
|
||||||
summary: string
|
summary: string
|
||||||
|
availability?: string
|
||||||
|
focusAreas?: string[]
|
||||||
avatar?: string
|
avatar?: string
|
||||||
socials?: SocialLinks
|
socials?: SocialLinks
|
||||||
}
|
}
|
||||||
@@ -52,9 +55,27 @@ export type Tag = {
|
|||||||
|
|
||||||
export type TagGroup = {
|
export type TagGroup = {
|
||||||
title: string
|
title: string
|
||||||
|
description?: string
|
||||||
items: Tag[]
|
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
|
// Backward-compatible single-position experience
|
||||||
export type Experience = {
|
export type Experience = {
|
||||||
company: string
|
company: string
|
||||||
@@ -108,6 +129,11 @@ export type Education = {
|
|||||||
export type Project = {
|
export type Project = {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
|
context?: string
|
||||||
|
role?: string
|
||||||
|
features?: string[]
|
||||||
|
stack?: string[]
|
||||||
|
outcome?: string
|
||||||
status?: string
|
status?: string
|
||||||
links?: { label: string; to: string; icon?: string }[]
|
links?: { label: string; to: string; icon?: string }[]
|
||||||
icons?: string[]
|
icons?: string[]
|
||||||
@@ -121,6 +147,10 @@ export type PortfolioData = {
|
|||||||
mainTools: TagGroup
|
mainTools: TagGroup
|
||||||
roles: TagGroup
|
roles: TagGroup
|
||||||
values: TagGroup
|
values: TagGroup
|
||||||
|
services?: Service[]
|
||||||
|
stackGroups?: TagGroup[]
|
||||||
|
reactExpansion?: ReactExpansion
|
||||||
|
cta?: CTA
|
||||||
// Supports both single experiences and grouped company timelines
|
// Supports both single experiences and grouped company timelines
|
||||||
experiences: Array<Experience | CompanyExperience>
|
experiences: Array<Experience | CompanyExperience>
|
||||||
education: Education[]
|
education: Education[]
|
||||||
|
|||||||
+28
-5
@@ -4,14 +4,16 @@
|
|||||||
"workAccordion": "Building enterprise solutions and managing infrastructure",
|
"workAccordion": "Building enterprise solutions and managing infrastructure",
|
||||||
"education": "Education",
|
"education": "Education",
|
||||||
"recommendations": "Recommendations",
|
"recommendations": "Recommendations",
|
||||||
"projects": " Projects",
|
"projects": "Projects",
|
||||||
"projectsAccordion": "Real-world applications built for clients and the community.",
|
"projectsAccordion": "Real-world applications built for clients and the community.",
|
||||||
"blog": "Blog",
|
"blog": "Blog",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
|
"services": "What I Can Help With",
|
||||||
"mainTools": "Main tools",
|
"mainTools": "Main tools",
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
"values": "Values",
|
"values": "Values",
|
||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
|
"currentlyExpanding": "Currently expanding",
|
||||||
"softSkills": "Values & Soft Skills",
|
"softSkills": "Values & Soft Skills",
|
||||||
"language": "Language Proficiency",
|
"language": "Language Proficiency",
|
||||||
"githubActivity": "GitHub Activity"
|
"githubActivity": "GitHub Activity"
|
||||||
@@ -20,7 +22,12 @@
|
|||||||
"expert": "Expert",
|
"expert": "Expert",
|
||||||
"proficient": "Proficient",
|
"proficient": "Proficient",
|
||||||
"usedBefore": "Used before",
|
"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": {
|
"softSkillsAccordion": {
|
||||||
"label": "Mindset and interpersonal strengths that drive team success."
|
"label": "Mindset and interpersonal strengths that drive team success."
|
||||||
@@ -72,7 +79,15 @@
|
|||||||
"hero": {
|
"hero": {
|
||||||
"currently": "Currently at",
|
"currently": "Currently at",
|
||||||
"viewResume": "View Resume",
|
"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": {
|
"toasts": {
|
||||||
"emailCopied": {
|
"emailCopied": {
|
||||||
@@ -87,6 +102,8 @@
|
|||||||
"buttons": {
|
"buttons": {
|
||||||
"blog": "Blog Posts",
|
"blog": "Blog Posts",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
|
"contactMe": "Contact Me",
|
||||||
|
"viewProjects": "View Projects",
|
||||||
"website": "Website",
|
"website": "Website",
|
||||||
"switchToEnglish": "English",
|
"switchToEnglish": "English",
|
||||||
"switchToPersian": "فارسی"
|
"switchToPersian": "فارسی"
|
||||||
@@ -142,10 +159,16 @@
|
|||||||
"suggestion3": "Return to the homepage and start fresh"
|
"suggestion3": "Return to the homepage and start fresh"
|
||||||
},
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"portfolioTitleSuffix": "Portfolio"
|
"portfolioTitleSuffix": "Backend & DevOps Engineer"
|
||||||
},
|
},
|
||||||
"projectLabels": {
|
"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": {
|
"theme": {
|
||||||
"customizer": "Theme Customizer",
|
"customizer": "Theme Customizer",
|
||||||
|
|||||||
+27
-4
@@ -1,17 +1,19 @@
|
|||||||
{
|
{
|
||||||
"sections": {
|
"sections": {
|
||||||
"work": "سوابق کاری",
|
"work": "سوابق کاری",
|
||||||
"workAccordion": "بیش از ۷ سال تجربه در توسعه راهکارهای سازمانی و اپلیکیشنهای وب.",
|
"workAccordion": "تجربه در توسعه سیستمها و مدیریت زیرساخت.",
|
||||||
"education": "تحصیلات",
|
"education": "تحصیلات",
|
||||||
"recommendations": "توصیهنامهها",
|
"recommendations": "توصیهنامهها",
|
||||||
"projects": "پروژهها",
|
"projects": "پروژهها",
|
||||||
"projectsAccordion": "اپلیکیشنهای واقعی ساخته شده برای مشتریان و جامعه توسعهدهندگان.",
|
"projectsAccordion": "اپلیکیشنهای واقعی ساخته شده برای مشتریان و جامعه توسعهدهندگان.",
|
||||||
"blog": "وبلاگ",
|
"blog": "وبلاگ",
|
||||||
"contact": "ارتباط",
|
"contact": "ارتباط",
|
||||||
|
"services": "خدماتی که میتوانم ارائه کنم",
|
||||||
"mainTools": "ابزارهای اصلی",
|
"mainTools": "ابزارهای اصلی",
|
||||||
"roles": "نقشها",
|
"roles": "نقشها",
|
||||||
"values": "ارزشها",
|
"values": "ارزشها",
|
||||||
"skills": "مهارتها",
|
"skills": "مهارتها",
|
||||||
|
"currentlyExpanding": "در حال گسترش مهارتها",
|
||||||
"softSkills": "ارزشها و مهارتهای نرم",
|
"softSkills": "ارزشها و مهارتهای نرم",
|
||||||
"language": "مهارتهای زبانی",
|
"language": "مهارتهای زبانی",
|
||||||
"githubActivity": "فعالیت گیتهاب"
|
"githubActivity": "فعالیت گیتهاب"
|
||||||
@@ -20,7 +22,12 @@
|
|||||||
"expert": "حرفهای",
|
"expert": "حرفهای",
|
||||||
"proficient": "مسلط",
|
"proficient": "مسلط",
|
||||||
"usedBefore": "تجربه قبلی",
|
"usedBefore": "تجربه قبلی",
|
||||||
"aiStack": "استک هوش مصنوعی"
|
"aiStack": "استک هوش مصنوعی",
|
||||||
|
"subtitle": "گروهبندی ابزارها بر اساس کاربردشان در بکاند، زیرساخت و عملیات.",
|
||||||
|
"accordionLabel": "مهارتهای فرانتاند گروهبندیشده برای بکاند، زیرساخت و عملیات."
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"subtitle": "تمرکز روی بکاند، زیرساخت و اتوماسیون با تمرکز بر قابلیت اطمینان."
|
||||||
},
|
},
|
||||||
"softSkillsAccordion": {
|
"softSkillsAccordion": {
|
||||||
"label": "ذهنیت و توانمندیهای بینفردی که موفقیت تیم را رقم میزنند."
|
"label": "ذهنیت و توانمندیهای بینفردی که موفقیت تیم را رقم میزنند."
|
||||||
@@ -58,6 +65,8 @@
|
|||||||
"buttons": {
|
"buttons": {
|
||||||
"blog": "مقالات",
|
"blog": "مقالات",
|
||||||
"contact": "ارتباط",
|
"contact": "ارتباط",
|
||||||
|
"contactMe": "تماس با من",
|
||||||
|
"viewProjects": "مشاهده پروژهها",
|
||||||
"website": "وبسایت",
|
"website": "وبسایت",
|
||||||
"switchToEnglish": "English",
|
"switchToEnglish": "English",
|
||||||
"switchToPersian": "فارسی"
|
"switchToPersian": "فارسی"
|
||||||
@@ -116,7 +125,13 @@
|
|||||||
"portfolioTitleSuffix": "نمونهکارها"
|
"portfolioTitleSuffix": "نمونهکارها"
|
||||||
},
|
},
|
||||||
"projectLabels": {
|
"projectLabels": {
|
||||||
"openSource": "اوپنسورس"
|
"openSource": "اوپنسورس",
|
||||||
|
"context": "زمینه پروژه",
|
||||||
|
"role": "نقش من",
|
||||||
|
"keyFeatures": "ویژگیهای کلیدی",
|
||||||
|
"techStack": "استک فنی",
|
||||||
|
"outcome": "نتیجه",
|
||||||
|
"caseStudyDetails": "جزئیات کیساستادی"
|
||||||
},
|
},
|
||||||
"theme": {
|
"theme": {
|
||||||
"customizer": "سفارشیسازی تم",
|
"customizer": "سفارشیسازی تم",
|
||||||
@@ -151,7 +166,15 @@
|
|||||||
"hero": {
|
"hero": {
|
||||||
"currently": "در حال حاضر در",
|
"currently": "در حال حاضر در",
|
||||||
"viewResume": "مشاهده رزومه",
|
"viewResume": "مشاهده رزومه",
|
||||||
"downloadPdf": "دانلود PDF"
|
"downloadPdf": "دانلود PDF",
|
||||||
|
"cardSummary": "سیستمهای بکاند، زیرساخت و عملیات.",
|
||||||
|
"contactTitle": "ارتباط با مهدی",
|
||||||
|
"contactDescription": "برای پروژههای زیرساخت، بکاند یا عملیات از این ایمیل استفاده کنید.",
|
||||||
|
"emailAddress": "آدرس ایمیل",
|
||||||
|
"clipboardBlocked": "دسترسی به کلیپبورد روی این آدرس توسط مرورگر محدود شده است. میتوانید ایمیل را انتخاب کنید یا اپلیکیشن ایمیل را باز کنید.",
|
||||||
|
"close": "بستن",
|
||||||
|
"copy": "کپی",
|
||||||
|
"openEmailApp": "باز کردن ایمیل"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"emailCopied": {
|
"emailCopied": {
|
||||||
|
|||||||
+1
-1
@@ -175,7 +175,7 @@ export default defineNuxtConfig({
|
|||||||
file: "fa.json",
|
file: "fa.json",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
langDir: "locales",
|
langDir: '../i18n/locales',
|
||||||
detectBrowserLanguage: {
|
detectBrowserLanguage: {
|
||||||
useCookie: true,
|
useCookie: true,
|
||||||
cookieKey: "i18n_redirected",
|
cookieKey: "i18n_redirected",
|
||||||
|
|||||||
+4
-1
@@ -56,7 +56,7 @@
|
|||||||
"@intlify/unplugin-vue-i18n": "11.0.1"
|
"@intlify/unplugin-vue-i18n": "11.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.20.0 <23"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
"last 2 chrome versions",
|
"last 2 chrome versions",
|
||||||
@@ -67,6 +67,9 @@
|
|||||||
"not dead"
|
"not dead"
|
||||||
],
|
],
|
||||||
"packageManager": "pnpm@10.24.0",
|
"packageManager": "pnpm@10.24.0",
|
||||||
|
"volta": {
|
||||||
|
"node": "22.14.0"
|
||||||
|
},
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"better-sqlite3"
|
"better-sqlite3"
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user