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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user