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