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

- Add Services, HireCTA, and ReactExpansion components
- Overhaul Hero with grid layout, badges, CTA buttons, email dialog, and profile card
- Refactor TopNav to use computed navItems loop
- Enhance ProjectsList with nested category accordions and case study details
- Restructure Skills with purpose-grouped stackGroups and row-sync accordion logic
- Add new types: Service, ReactExpansion, CTA, and Project case-study fields
- Update portfolio data with adapted services, stackGroups, and project details
- Add OG/Twitter meta tags for SEO
- Update i18n with new keys for services, hero, buttons, and project labels
- Fix langDir path, update engine constraints, add volta config
- Change default color scheme to lime
- Rename Rider to JetBrains Rider in AI stack
This commit is contained in:
2026-07-06 18:28:26 +03:30
parent ef7ef8ecc3
commit 2fc75eeab1
18 changed files with 1049 additions and 296 deletions
+61 -75
View File
@@ -4,80 +4,37 @@
<div
class="backdrop-blur-md bg-white/80 dark:bg-slate-900/70 shadow-md rounded-2xl border border-white/30 dark:border-slate-700/50 pointer-events-auto transition-all duration-300">
<div class="flex items-center justify-between px-2 py-2">
<div class="flex items-center gap-3">
<!-- Home -->
<div class="flex items-center gap-1.5">
<UTooltip :text="t('nav.home')">
<UButton class="cursor-pointer transition-all duration-200"
:class="[isActive('hero') ? activeClass : inactiveClass]" variant="soft" square icon="i-twemoji-house"
:aria-label="t('nav.home')" @click="goTo('hero')" />
</UTooltip>
<button type="button"
class="hidden lg:inline-flex cursor-pointer text-sm font-medium transition-colors duration-200"
:class="[isActive('hero') ? labelActiveClass : labelInactiveClass]" @click="goTo('hero')">
{{ t('nav.home') }}
</button>
</div>
<div class="flex items-center gap-2">
<UTooltip v-for="item in navItems" :key="item.value" :text="item.label">
<UButton
type="button"
variant="soft"
class="nav-link-button cursor-pointer gap-1.5 px-2 transition-all duration-200 lg:px-2.5"
:class="[item.active ? activeClass : inactiveClass]"
:aria-label="item.label"
@click="goTo(item.target)"
>
<template #leading>
<UIcon :name="item.icon" class="text-xl" />
</template>
<span class="hidden lg:inline text-sm font-medium">{{ item.label }}</span>
</UButton>
</UTooltip>
<!-- Skills -->
<div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.skills')">
<UButton class="cursor-pointer transition-all duration-200"
:class="[isActive('skills') ? activeClass : inactiveClass]" variant="soft" square
icon="i-twemoji-hammer-and-wrench" :aria-label="t('sections.skills')" @click="goTo('skills')" />
</UTooltip>
<button type="button"
class="hidden lg:inline-flex cursor-pointer text-sm font-medium transition-colors duration-200"
:class="[isActive('skills') ? labelActiveClass : labelInactiveClass]" @click="goTo('skills')">
{{ t('sections.skills') }}
</button>
</div>
<!-- Work -->
<div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.work')">
<UButton class="cursor-pointer transition-all duration-200"
:class="[isActive('work') ? activeClass : inactiveClass]" variant="soft" square
icon="i-twemoji-briefcase" :aria-label="t('sections.work')" @click="goTo('work')" />
</UTooltip>
<button type="button"
class="hidden lg:inline-flex cursor-pointer text-sm font-medium transition-colors duration-200"
:class="[isActive('work') ? labelActiveClass : labelInactiveClass]" @click="goTo('work')">
{{ t('sections.work') }}
</button>
</div>
<!-- Projects -->
<div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.projects')">
<UButton class="cursor-pointer transition-all duration-200"
:class="[isActive('projects') ? activeClass : inactiveClass]" variant="soft" square
icon="i-twemoji-rocket" :aria-label="t('sections.projects')" @click="goTo('projects')" />
</UTooltip>
<button type="button"
class="hidden lg:inline-flex cursor-pointer text-sm font-medium transition-colors duration-200"
:class="[isActive('projects') ? labelActiveClass : labelInactiveClass]" @click="goTo('projects')">
{{ t('sections.projects') }}
</button>
</div>
<!-- Blog -->
<div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.blog')">
<NuxtLink :to="localePath('/blog')" class="cursor-pointer">
<UButton class="cursor-pointer transition-all duration-200"
:class="[isBlogActive ? activeClass : inactiveClass]" variant="soft" square icon="i-twemoji-memo"
:aria-label="t('sections.blog')" />
</NuxtLink>
</UTooltip>
<NuxtLink :to="localePath('/blog')" class="cursor-pointer">
<button type="button"
class="hidden lg:inline-flex cursor-pointer text-sm font-medium transition-colors duration-200"
:class="[isBlogActive ? labelActiveClass : labelInactiveClass]">
{{ t('sections.blog') }}
</button>
</NuxtLink>
</div>
<UTooltip :text="t('sections.blog')">
<UButton
:to="localePath('/blog')"
variant="soft"
class="nav-link-button cursor-pointer gap-1.5 px-2 transition-all duration-200 lg:px-2.5"
:class="[isBlogActive ? activeClass : inactiveClass]"
:aria-label="t('sections.blog')"
>
<template #leading>
<UIcon name="i-twemoji-memo" class="text-xl" />
</template>
<span class="hidden lg:inline text-sm font-medium">{{ t('sections.blog') }}</span>
</UButton>
</UTooltip>
</div>
<div class="flex items-center gap-2">
@@ -101,8 +58,6 @@ const localePath = useLocalePath()
const activeClass = 'ring-1 ring-primary-400/40 bg-primary-500/15 text-primary-600 dark:text-primary-400 transform scale-105'
const inactiveClass = 'text-gray-500 dark:text-gray-300 hover:text-primary-400'
const labelActiveClass = 'text-primary-700 dark:text-primary-400'
const labelInactiveClass = 'text-gray-600 dark:text-gray-300 hover:text-primary-400'
const sectionIds = ['hero', 'skills', 'work', 'projects'] as const
type Target = typeof sectionIds[number]
@@ -157,6 +112,37 @@ const isActive = (id: Target) => {
return activeSection.value === id
}
const navItems = computed(() => [
{
value: 'hero',
target: 'hero' as Target,
label: t('nav.home'),
icon: 'i-twemoji-house',
active: isActive('hero')
},
{
value: 'skills',
target: 'skills' as Target,
label: t('sections.skills'),
icon: 'i-twemoji-hammer-and-wrench',
active: isActive('skills')
},
{
value: 'work',
target: 'work' as Target,
label: t('sections.work'),
icon: 'i-twemoji-briefcase',
active: isActive('work')
},
{
value: 'projects',
target: 'projects' as Target,
label: t('sections.projects'),
icon: 'i-twemoji-rocket',
active: isActive('projects')
}
])
function scrollToSection(id: Target) {
if (typeof window === 'undefined') return
+190 -78
View File
@@ -1,55 +1,74 @@
<template>
<section id="hero" class="pt-8 pb-6 scroll-mt-20 flex items-center">
<section id="hero" class="scroll-mt-20 pt-3 pb-8 sm:pt-6 sm:pb-10">
<UContainer>
<div class="flex flex-col-reverse sm:flex-row items-center sm:items-center justify-center gap-4 sm:gap-8">
<div class="flex-1 max-w-2xl text-center sm:text-start">
<h1 class="text-primary text-3xl sm:text-4xl font-bold tracking-tight mb-3 sm:mb-4">
{{ portfolio.profile.name }}
<div class="grid gap-8 lg:grid-cols-[minmax(0,1fr)_280px]">
<div class="text-center sm:text-start lg:col-span-2">
<div class="mb-4 flex flex-wrap items-center justify-center gap-2 sm:justify-start">
<UBadge color="primary" variant="soft" class="rounded-full">
{{ portfolio.profile.name }}
</UBadge>
<UBadge v-if="portfolio.profile.availability" color="emerald" variant="soft" class="rounded-full">
{{ portfolio.profile.availability }}
</UBadge>
</div>
<h1 class="mb-4 max-w-6xl text-3xl font-bold leading-tight tracking-normal text-gray-950 dark:text-gray-50 sm:text-5xl lg:text-6xl">
{{ portfolio.profile.headline || portfolio.profile.title }}
</h1>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 mb-4 sm:mb-3">
<p class="max-w-4xl text-base leading-relaxed text-gray-700 dark:text-gray-300 sm:text-lg">
{{ portfolio.profile.summary }}
</p>
<div v-if="portfolio.profile.location || currentRole"
class="mb-6 flex flex-col items-center gap-3 text-sm text-gray-600 dark:text-gray-300 sm:items-start">
<div v-if="portfolio.profile.location" class="flex items-center gap-2">
<UIcon name="i-twemoji-round-pushpin" class="text-base text-primary-600 me-1 dark:text-primary-300" />
<span class="leading-relaxed">{{ portfolio.profile.location }}</span>
</div>
<div v-if="currentRole"
class="flex flex-col sm:flex-row items-center justify-center sm:justify-between w-full gap-2 text-base text-gray-700 dark:text-gray-200 text-center sm:text-left">
<div class="flex flex-wrap items-center justify-center sm:justify-start gap-1">
<img v-if="currentRole.companyLogo" :src="currentRole.companyLogo" :alt="`${currentRole.company} logo`"
class="h-7 w-7 rounded-md object-contain" loading="lazy" />
<span class="">{{ t('hero.currently') }}</span>
<span class="font-semibold company-name">
<a v-if="currentRole.companyLink" :href="currentRole.companyLink" target="_blank" rel="noopener"
class="hover:underline company-name">
{{ currentRole.company }}
</a>
<span v-else>{{ currentRole.company }}</span>
</span>
</div>
<!-- Resume Button - Desktop only -->
<NuxtLink to="/resume" class="hidden sm:inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold
resume-button
text-white rounded-full
transition-all duration-300 hover:scale-105
animate-pulse hover:animate-none">
<UIcon name="i-heroicons-document-text" class="text-lg" />
<span>View Resume</span>
<UIcon name="i-heroicons-sparkles" class="text-sm opacity-80" />
</NuxtLink>
</div>
<!-- Resume Button - Mobile only -->
<NuxtLink to="/resume" class="sm:hidden inline-flex items-center justify-center gap-2 mt-2 px-4 py-2 text-sm font-medium
resume-button
text-white rounded-full
transition-all duration-300 hover:scale-105">
<UIcon name="i-heroicons-document-text" class="text-base" />
<span>View My Resume</span>
<UIcon name="i-heroicons-arrow-right" class="text-sm" />
</div>
<div class="text-center sm:text-start">
<div v-if="portfolio.profile.focusAreas?.length" class="mb-6 flex flex-wrap justify-center gap-2 sm:justify-start">
<UBadge v-for="area in portfolio.profile.focusAreas" :key="area" color="neutral" variant="soft" class="rounded-full">
{{ area }}
</UBadge>
</div>
<div class="mb-6 flex flex-wrap items-center justify-center gap-3 sm:justify-start">
<UButton icon="i-mdi-email-outline" color="primary" size="lg" class="rounded-lg" @click="copyEmail">
{{ translate('buttons.contactMe', 'Contact Me') }}
</UButton>
<UButton icon="i-mdi-folder-multiple-outline" color="neutral" variant="soft" size="lg" class="rounded-lg" @click="scrollTo('projects')">
{{ translate('buttons.viewProjects', 'View Projects') }}
</UButton>
<NuxtLink to="/resume" class="resume-gradient-button" @click="preserveColorModeForResume">
<UIcon name="i-heroicons-document-text" class="text-lg" />
<span>{{ t('hero.viewResume') }}</span>
<UIcon name="i-heroicons-sparkles" class="text-sm opacity-80" />
</NuxtLink>
</div>
<div v-if="portfolio.profile.location || currentRole" class="mb-5 flex flex-col items-center gap-3 text-sm text-gray-600 dark:text-gray-300 sm:items-start">
<div v-if="currentRole" class="flex flex-wrap items-center justify-center gap-2 sm:justify-start">
<img
v-if="currentRole.companyLogo"
:src="currentRole.companyLogo"
:alt="`${currentRole.company} logo`"
class="h-7 w-7 rounded-md object-contain"
loading="lazy"
/>
<span>{{ t('hero.currently') }}</span>
<a
v-if="currentRole.companyLink"
:href="currentRole.companyLink"
target="_blank"
rel="noopener"
class="font-semibold company-name hover:underline"
>
{{ currentRole.company }}
</a>
<span v-else class="font-semibold company-name">{{ currentRole.company }}</span>
</div>
<div v-if="portfolio.profile.location" class="flex items-center gap-2">
<UIcon name="i-mdi-map-marker-outline" class="text-base text-primary-600 dark:text-primary-300" />
<span class="leading-relaxed">{{ portfolio.profile.location }}</span>
</div>
</div>
<div class="flex flex-wrap items-center justify-center gap-3 sm:justify-start">
<ClientTooltip :text="emailTooltip">
<UButton icon="i-twemoji-e-mail" :square="true" color="gray" variant="ghost"
@@ -103,14 +122,84 @@
</ClientTooltip>
</div>
</div>
<div v-show="portfolio.profile.avatar"
class="block mx-auto sm:mx-0 shrink-0 w-24 h-24 sm:w-32 sm:h-32 md:w-40 md:h-40 ring-4 ring-primary-400/50 dark:ring-primary-300/40 rounded-full overflow-hidden">
<NuxtImg :src="portfolio.profile.avatar || undefined" :alt="portfolio.profile.name"
sizes="96px sm:128px md:160px" width="160" height="160" class="h-full w-full object-cover" format="webp"
preload />
<div v-if="portfolio.profile.avatar" class="mx-auto w-full max-w-[280px]">
<div class="rounded-lg border border-gray-200/70 bg-white/75 p-4 shadow-sm dark:border-gray-700/50 dark:bg-gray-900/45">
<div class="mx-auto h-36 w-36 overflow-hidden rounded-full ring-4 ring-primary-400/35 dark:ring-primary-300/25">
<NuxtImg
:src="portfolio.profile.avatar || undefined"
:alt="portfolio.profile.name"
sizes="144px"
width="144"
height="144"
class="h-full w-full object-cover"
format="webp"
preload
/>
</div>
<div class="mt-4 text-center">
<p class="text-sm font-semibold text-gray-950 dark:text-gray-50">{{ portfolio.profile.title }}</p>
<p class="mt-1 text-xs leading-relaxed text-gray-600 dark:text-gray-400">
{{ translate('hero.cardSummary', 'Backend services, infrastructure, and DevOps systems.') }}
</p>
</div>
</div>
</div>
</div>
</UContainer>
<UModal
v-model:open="emailDialogOpen"
:title="translate('hero.contactTitle', 'Contact Mahdi')"
:description="translate('hero.contactDescription', 'Use this email for infrastructure, backend, or DevOps project inquiries.')"
:ui="{ content: 'sm:max-w-md', body: 'space-y-4', footer: 'justify-between gap-2' }"
>
<template #body>
<div class="rounded-lg border border-gray-200/70 bg-gray-50/80 p-3 dark:border-gray-700/60 dark:bg-gray-900/70">
<div class="mb-2 flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-200">
<UIcon name="i-mdi-email-outline" class="text-base text-primary-500 dark:text-primary-300" />
<span>{{ translate('hero.emailAddress', 'Email address') }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<code class="min-w-0 break-all text-sm font-semibold text-gray-950 dark:text-gray-50">{{ emailAddress }}</code>
<UButton
icon="i-mdi-content-copy"
color="primary"
variant="soft"
size="sm"
class="shrink-0 rounded-lg"
@click="handleModalCopy"
>
{{ translate('hero.copy', 'Copy') }}
</UButton>
</div>
</div>
<p v-if="emailDialogMessage" class="text-sm text-amber-600 dark:text-amber-300">
{{ emailDialogMessage }}
</p>
</template>
<template #footer="{ close }">
<UButton
color="neutral"
variant="ghost"
class="rounded-lg"
@click="close"
>
{{ translate('hero.close', 'Close') }}
</UButton>
<UButton
:to="`mailto:${emailAddress}?subject=Infrastructure%20project%20inquiry`"
icon="i-mdi-send-outline"
color="primary"
class="rounded-lg"
@click="close"
>
{{ translate('hero.openEmailApp', 'Open Email App') }}
</UButton>
</template>
</UModal>
</section>
</template>
@@ -120,9 +209,24 @@ import { computed, ref } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
import type { CompanyExperience, Experience } from '@/types/portfolio.types'
const { t } = useI18n()
const { t, te } = useI18n()
const portfolio = usePortfolio()
const toast = useToast()
const colorMode = useColorMode()
function translate(key: string, fallback: string): string {
return te(key) ? t(key) : fallback
}
function scrollTo(id: string) {
if (typeof window === 'undefined') return
const el = document.getElementById(id)
if (el) {
const offset = 80
const top = el.getBoundingClientRect().top + window.pageYOffset - offset
window.scrollTo({ top, behavior: 'smooth' })
}
}
const currentRole = computed(() => {
const experiences = portfolio.value.experiences as Array<CompanyExperience | Experience>
@@ -164,48 +268,47 @@ const currentRole = computed(() => {
return null
})
/**
* Email copy-to-clipboard for Hero quick action
*/
const emailAddress = 'me@mahdium.ir'
const emailTooltip = ref('Email')
const emailDialogOpen = ref(false)
const emailDialogMessage = ref('')
async function copyEmail() {
const copied = await tryCopyEmail()
if (!copied) {
emailDialogOpen.value = true
}
}
async function tryCopyEmail(): Promise<boolean> {
try {
await navigator.clipboard.writeText(emailAddress)
emailTooltip.value = 'Copied'
emailDialogMessage.value = ''
setTimeout(() => { emailTooltip.value = 'Email' }, 1500)
// Nuxt UI toast: success
toast.add({
title: t('toasts.emailCopied.title'),
description: t('toasts.emailCopied.desc', { email: emailAddress }),
icon: 'i-mdi-clipboard-check',
color: 'emerald'
})
return true
} catch {
// Nuxt UI toast: failure (clipboard not accessible)
toast.add({
title: t('toasts.copyFailed.title'),
description: t('toasts.copyFailed.desc', { email: emailAddress }),
icon: 'i-mdi-clipboard-alert',
color: 'amber'
})
emailDialogMessage.value = translate('hero.clipboardBlocked', 'Clipboard access is blocked by the browser on this address. You can still select the email or open your email app.')
return false
}
}
// Fallback prompt if clipboard API is unavailable
const ok = typeof window !== 'undefined' && window.confirm(`Copy email:\n\n${emailAddress}`)
if (ok) {
emailTooltip.value = 'Copied'
setTimeout(() => { emailTooltip.value = 'Email' }, 1500)
async function handleModalCopy() {
await tryCopyEmail()
}
// Show success toast after manual copy confirmation
toast.add({
title: t('toasts.emailCopied.title'),
description: t('toasts.emailCopied.desc', { email: emailAddress }),
icon: 'i-mdi-clipboard-check',
color: 'emerald'
})
}
function preserveColorModeForResume() {
if (!import.meta.client) return
if (colorMode.value === 'dark') {
colorMode.preference = 'dark'
localStorage.setItem('nuxt-color-mode', 'dark')
}
}
</script>
@@ -219,16 +322,25 @@ async function copyEmail() {
color: var(--ui-color-primary-300);
}
/* Resume button with primary color gradient */
.resume-button {
.resume-gradient-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 600;
color: white;
border-radius: 0.5rem;
background: linear-gradient(to right,
var(--ui-color-primary-600),
var(--ui-color-primary-500),
var(--ui-color-primary-400));
box-shadow: 0 10px 15px -3px color-mix(in oklch, var(--ui-color-primary-500) 25%, transparent);
transition: all 0.3s;
}
.resume-button:hover {
.resume-gradient-button:hover {
transform: scale(1.05);
background: linear-gradient(to right,
var(--ui-color-primary-700),
var(--ui-color-primary-600),
+76
View File
@@ -0,0 +1,76 @@
<template>
<section v-if="cta" id="contact" class="section-spacing scroll-mt-20">
<UContainer>
<div class="rounded-lg border border-primary-300/40 bg-primary-50/80 p-5 shadow-sm dark:border-primary-700/40 dark:bg-primary-950/20 sm:p-6">
<div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-3xl">
<h2 class="text-xl font-semibold leading-tight text-gray-950 dark:text-gray-50 sm:text-2xl">
{{ cta.title }}
</h2>
<p class="mt-2 text-sm leading-relaxed text-gray-700 dark:text-gray-300 sm:text-base">
{{ cta.description }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<UButton
:to="`mailto:${emailAddress}?subject=Infrastructure%20project%20inquiry`"
icon="i-mdi-email-outline"
color="primary"
size="md"
class="rounded-lg"
>
{{ t('buttons.contactMe') }}
</UButton>
<UButton
to="#projects"
icon="i-mdi-folder-multiple-outline"
color="neutral"
variant="soft"
size="md"
class="rounded-lg"
>
{{ t('buttons.viewProjects') }}
</UButton>
<UButton
v-if="socials.linkedin"
:to="socials.linkedin"
target="_blank"
icon="i-logos-linkedin-icon"
color="neutral"
variant="soft"
size="md"
class="rounded-lg"
>
LinkedIn
</UButton>
<UButton
v-if="socials.github"
:to="socials.github"
target="_blank"
icon="i-mdi-github"
color="neutral"
variant="soft"
size="md"
class="rounded-lg"
>
GitHub
</UButton>
</div>
</div>
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
const portfolio = usePortfolio()
const { t } = useI18n()
const cta = computed(() => portfolio.value.cta)
const socials = computed(() => portfolio.value.profile.socials ?? {})
const emailAddress = 'me@mahdium.ir'
</script>
+163 -54
View File
@@ -6,30 +6,46 @@
<h2 class="section-title text-start">{{ t('sections.projects') }}</h2>
</div>
<UAccordion type="single" :unmount-on-hide="false" :items="accordionItems" default-value="projects"
<UAccordion type="single" collapsible :unmount-on-hide="true" :items="accordionItems" default-value="projects"
:ui="accordionUi">
<template #body>
<div v-for="g in nonEmptyCategoryList" :key="g.cat" class="space-y-3 mb-5">
<div class="flex items-center gap-2">
<UIcon name="i-twemoji-open-book" class="text-xl" />
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{{ tCategory(g.cat) }}</h3>
</div>
<div class="grid gap-4 md:grid-cols-2">
<UCard v-for="(p, i) in g.items" :key="`${g.cat}-${i}-${p.name}`"
class="flex h-full flex-col border border-gray-200/60 shadow-none transition hover:-translate-y-1 hover:shadow-lg dark:border-gray-700/40">
<div class="flex h-full flex-col gap-4">
<UAccordion
type="multiple"
collapsible
:unmount-on-hide="true"
:items="categoryAccordionItems"
:default-value="openProjectCategories"
:ui="categoryAccordionUi"
>
<template #leading>
<UIcon name="i-twemoji-open-book" class="text-lg" />
</template>
<template #body="{ item }">
<div class="grid gap-4 lg:grid-cols-2">
<UCard v-for="(p, i) in item.items" :key="`${item.cat}-${i}-${p.name}`"
class="flex h-full flex-col border border-gray-200/60 shadow-none transition hover:-translate-y-0.5 hover:shadow-md dark:border-gray-700/40">
<article class="flex h-full flex-col gap-4">
<div class="flex gap-3">
<NuxtImg v-if="p.thumbnail" :src="p.thumbnail" :alt="`${p.name} logo`"
class="h-12 w-12 rounded-xl border border-gray-200/70 bg-white object-cover shadow-sm dark:border-gray-700/40 dark:bg-slate-900"
width="96" height="96" sizes="96px" format="webp" loading="lazy" />
<div v-else
class="flex h-12 w-12 items-center justify-center rounded-xl border border-gray-200/70 bg-primary-500/10 text-primary-600 shadow-sm dark:border-gray-700/40 dark:bg-primary-400/10 dark:text-primary-200">
<NuxtImg
v-if="p.thumbnail"
:src="p.thumbnail"
:alt="`${p.name} logo`"
class="h-14 w-14 rounded-lg border border-gray-200/70 bg-white object-contain p-1 shadow-sm dark:border-gray-700/40 dark:bg-slate-900"
width="112"
height="112"
sizes="112px"
format="webp"
loading="lazy"
/>
<div
v-else
class="flex h-14 w-14 items-center justify-center rounded-lg border border-gray-200/70 bg-primary-500/10 text-primary-600 shadow-sm dark:border-gray-700/40 dark:bg-primary-400/10 dark:text-primary-200"
>
<UIcon :name="getProjectIcon(p)" class="text-2xl" />
</div>
<div class="flex flex-1 flex-col gap-3">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{{ p.name }}</h3>
<h3 class="text-base font-semibold leading-snug text-gray-950 dark:text-gray-50">{{ p.name }}</h3>
<div v-if="p.status || p.opensource" class="mt-2 flex flex-wrap items-center gap-2">
<UBadge v-if="p.status" color="primary" variant="soft" class="rounded-full capitalize">
{{ p.status }}
@@ -40,27 +56,79 @@
</UBadge>
</div>
</div>
<p class="text-sm text-gray-700 dark:text-gray-200">{{ p.description }}</p>
<div class="flex flex-1 flex-col gap-3 pt-1">
<div v-if="p.icons?.length"
class="flex flex-wrap items-center gap-2 text-primary-500 dark:text-primary-300">
<UIcon v-for="(ic, k) in p.icons" :key="k" :name="ic" class="text-xl" />
</div>
<div v-if="p.links?.length" class="mt-auto flex flex-wrap gap-2">
<UButton v-for="(l, j) in p.links" :key="j" :to="l.to" target="_blank" size="xs"
color="primary" variant="soft" trailing-icon="i-mdi-arrow-top-right-thin"
class="hover-ring-tint" :aria-label="l.label">
<UIcon v-if="l.icon" :name="l.icon" class="mr-1" />
{{ l.label }}
</UButton>
</div>
</div>
<p class="text-sm leading-relaxed text-gray-700 dark:text-gray-200">{{ p.description }}</p>
</div>
</div>
</div>
</UCard>
</div>
</div>
<UAccordion
v-if="hasCaseStudyContent(p)"
type="single"
collapsible
:unmount-on-hide="true"
:items="getProjectDetailItems(p)"
:ui="projectDetailsAccordionUi"
>
<template #body>
<div class="grid gap-3 text-sm">
<div v-if="p.context">
<h4 class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">{{ t('projectLabels.context') }}</h4>
<p class="mt-1 leading-relaxed text-gray-700 dark:text-gray-300">{{ p.context }}</p>
</div>
<div v-if="p.role">
<h4 class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">{{ t('projectLabels.role') }}</h4>
<p class="mt-1 leading-relaxed text-gray-700 dark:text-gray-300">{{ p.role }}</p>
</div>
<div v-if="p.features?.length">
<h4 class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">{{ t('projectLabels.keyFeatures') }}</h4>
<div class="mt-2 flex flex-wrap gap-1.5">
<UBadge v-for="feature in p.features" :key="feature" color="neutral" variant="soft" class="rounded-full">
{{ feature }}
</UBadge>
</div>
</div>
<div v-if="p.stack?.length">
<h4 class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">{{ t('projectLabels.techStack') }}</h4>
<div class="mt-2 flex flex-wrap gap-1.5">
<UBadge v-for="stackItem in p.stack" :key="stackItem" color="primary" variant="soft" class="rounded-full">
{{ stackItem }}
</UBadge>
</div>
</div>
<div v-if="p.outcome">
<h4 class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">{{ t('projectLabels.outcome') }}</h4>
<p class="mt-1 leading-relaxed text-gray-700 dark:text-gray-300">{{ p.outcome }}</p>
</div>
</div>
</template>
</UAccordion>
<div class="mt-auto flex flex-col gap-3 pt-1">
<div v-if="p.icons?.length" class="flex flex-wrap items-center gap-2 text-primary-500 dark:text-primary-300">
<UIcon v-for="(ic, k) in p.icons" :key="k" :name="ic" class="text-xl" />
</div>
<div v-if="p.links?.length" class="flex flex-wrap gap-2">
<UButton
v-for="(l, j) in p.links"
:key="j"
:to="l.to"
target="_blank"
size="xs"
color="primary"
variant="soft"
trailing-icon="i-mdi-arrow-top-right-thin"
class="hover-ring-tint rounded-lg"
:aria-label="l.label"
>
<UIcon v-if="l.icon" :name="l.icon" class="mr-1" />
{{ l.label }}
</UButton>
</div>
</div>
</article>
</UCard>
</div>
</template>
</UAccordion>
</template>
</UAccordion>
</UContainer>
@@ -68,26 +136,16 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { computed } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
import type { Project } from '@/types/portfolio.types'
const portfolio = usePortfolio()
const { t } = useI18n()
const { t, te } = useI18n()
// Detect mobile for accordion behavior (SSR-safe)
const isMobile = ref(true)
onMounted(() => {
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
checkMobile()
window.addEventListener('resize', checkMobile)
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
})
function translate(key: string, fallback: string): string {
return te(key) ? t(key) : fallback
}
// Accordion config
const accordionItems = computed(() => [{
@@ -98,9 +156,9 @@ const accordionItems = computed(() => [{
const accordionUi = {
root: 'flex flex-col',
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm',
header: 'px-4 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
trigger: 'group flex-1 items-center gap-2 py-3 text-left cursor-pointer',
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
header: 'sticky top-[60px] z-30 px-3 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50 rounded-t-2xl bg-white/95 shadow-sm backdrop-blur-md dark:bg-gray-950/95 sm:top-[60px]',
trigger: 'group flex-1 h-9 min-h-0 items-center gap-2 py-0 text-left cursor-pointer',
label: 'text-xs font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300 sm:text-sm',
leadingIcon: 'shrink-0',
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
content: 'px-4 pb-4 pt-3 data-[state=closed]:hidden',
@@ -127,6 +185,17 @@ const nonEmptyCategoryList = computed<Array<{ cat: Category; items: Project[] }>
return categoryList.value.filter(g => g.items.length > 0)
})
const categoryAccordionItems = computed(() =>
nonEmptyCategoryList.value.map(group => ({
label: tCategory(group.cat),
value: group.cat,
cat: group.cat,
items: group.items,
}))
)
const openProjectCategories = computed(() => categoryAccordionItems.value.map(item => item.value))
function tCategory(cat: Category): string {
return t(`projectCategories.${cat}`)
}
@@ -146,4 +215,44 @@ function getProjectIcon(project: Project): string {
return 'i-twemoji-rocket'
}
function hasCaseStudyContent(project: Project): boolean {
return Boolean(
project.context ||
project.role ||
project.features?.length ||
project.stack?.length ||
project.outcome
)
}
function getProjectDetailItems(project: Project) {
return [{
label: translate('projectLabels.caseStudyDetails', 'Case study details'),
value: `${project.name}-details`,
}]
}
const categoryAccordionUi = {
root: 'flex flex-col gap-3',
item: 'flex flex-col rounded-xl border border-gray-200/70 dark:border-gray-700/50 bg-white/60 dark:bg-gray-900/35 shadow-sm',
header: 'px-4 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
trigger: 'group flex-1 items-center gap-2 py-3 text-left cursor-pointer',
label: 'text-sm font-semibold text-gray-900 dark:text-gray-100',
leadingIcon: 'shrink-0',
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
content: 'px-4 pb-4 pt-4 data-[state=closed]:hidden',
body: 'pt-0'
} as const
const projectDetailsAccordionUi = {
root: 'flex flex-col',
item: 'rounded-lg border border-gray-200/70 dark:border-gray-700/50 bg-gray-50/70 dark:bg-gray-950/30',
header: 'px-3 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
trigger: 'group flex-1 items-center gap-2 py-2 text-left cursor-pointer',
label: 'text-xs font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-300',
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
content: 'px-3 pb-3 pt-3 data-[state=closed]:hidden',
body: 'pt-0'
} as const
</script>
@@ -0,0 +1,41 @@
<template>
<section v-if="reactExpansion" id="react-next" class="section-spacing scroll-mt-20">
<UContainer>
<div class="rounded-lg border border-gray-200/70 bg-white/75 p-4 shadow-sm dark:border-gray-700/50 dark:bg-gray-900/45 sm:p-5">
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div class="max-w-3xl">
<div class="mb-2 flex items-center gap-2 text-sm font-semibold text-primary-600 dark:text-primary-300">
<UIcon name="i-mdi-progress-wrench" class="text-lg" />
<span>{{ t('sections.currentlyExpanding') }}</span>
</div>
<h2 class="text-lg font-semibold leading-tight text-gray-950 dark:text-gray-50">
{{ reactExpansion.title }}
</h2>
<p class="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
{{ reactExpansion.description }}
</p>
</div>
<div class="flex shrink-0 flex-wrap gap-1.5 md:max-w-sm md:justify-end">
<UBadge v-for="item in reactExpansion.items" :key="item.label" variant="soft" class="chip-base">
<span class="inline-flex items-center gap-1.5">
<UIcon v-if="item.icon" :name="item.icon" class="h-4 w-4 text-base" />
<span class="text-xs">{{ item.label }}</span>
</span>
</UBadge>
</div>
</div>
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
const portfolio = usePortfolio()
const { t } = useI18n()
const reactExpansion = computed(() => portfolio.value.reactExpansion)
</script>
+43
View File
@@ -0,0 +1,43 @@
<template>
<section v-if="services.length" id="services" class="section-spacing scroll-mt-20">
<UContainer>
<div class="section-header">
<UIcon name="i-mdi-briefcase-check-outline" class="text-2xl text-primary-500 dark:text-primary-300" />
<div>
<h2 class="section-title">{{ t('sections.services') }}</h2>
<p class="mt-1 max-w-2xl text-sm text-gray-600 dark:text-gray-300">
{{ t('services.subtitle') }}
</p>
</div>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<article
v-for="service in services"
:key="service.title"
class="rounded-lg border border-gray-200/70 bg-white/75 p-4 shadow-sm dark:border-gray-700/50 dark:bg-gray-900/45"
>
<div class="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-primary-500/10 text-primary-600 dark:bg-primary-400/10 dark:text-primary-300">
<UIcon :name="service.icon || 'i-mdi-check-circle-outline'" class="text-xl" />
</div>
<h3 class="text-base font-semibold leading-snug text-gray-950 dark:text-gray-50">
{{ service.title }}
</h3>
<p class="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
{{ service.description }}
</p>
</article>
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
const portfolio = usePortfolio()
const { t } = useI18n()
const services = computed(() => portfolio.value.services ?? [])
</script>
+186 -55
View File
@@ -4,18 +4,43 @@
<div class="section-header flex-nowrap justify-between">
<div class="flex items-center gap-3 min-w-0">
<UIcon name="i-twemoji-hammer-and-wrench" class="text-2xl" />
<h2 class="section-title">{{ t('sections.skills') }}</h2>
<div>
<h2 class="section-title">{{ t('sections.skills') }}</h2>
<p class="mt-1 max-w-2xl text-sm text-gray-600 dark:text-gray-300">
{{ translate('skills.subtitle', 'Grouped by how the tools support backend, infrastructure, and DevOps work.') }}
</p>
</div>
</div>
<SkillFilters v-model="selectedTypes" />
</div>
<UAccordion type="multiple" :unmount-on-hide="false" :items="skillSections" :default-value="openSkillSections"
:ui="accordionUi" :disabled="!isMobile">
<template #leading="{ item }">
<UIcon v-if="item.icon" :name="item.icon" class="text-base text-primary-500 dark:text-primary-300" />
</template>
<template #body="{ item }">
<SkillGrid :items="sectionItems[item.value as SkillSectionKey] || []" />
<UAccordion
type="single"
collapsible
:unmount-on-hide="true"
:items="skillsAccordionItems"
default-value="skills"
:ui="outerAccordionUi"
>
<template #body>
<UAccordion
:model-value="openSkillValues"
type="multiple"
collapsible
:unmount-on-hide="true"
:items="skillSections"
:ui="accordionUi"
@update:model-value="syncOpenSkillRows"
>
<template #leading="{ item }">
<UIcon v-if="item.icon" :name="item.icon" class="text-base text-primary-500 dark:text-primary-300" />
</template>
<template #body="{ item }">
<p v-if="item.description" class="mb-3 text-sm leading-relaxed text-gray-600 dark:text-gray-300">
{{ item.description }}
</p>
<SkillGrid :items="sectionItems[item.value as string] || []" />
</template>
</UAccordion>
</template>
</UAccordion>
</UContainer>
@@ -23,65 +48,171 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { Tag, SkillType } from '@/types/portfolio.types'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { Tag } from '@/types/portfolio.types'
import { expert, proficient, usedBefore } from '@/data/skills'
import { usePortfolio } from '@/composables/usePortfolio'
import SkillGrid from '@/components/portfolio/SkillGrid.vue'
import SkillFilters from '@/components/portfolio/SkillFilters.vue'
const { t } = useI18n()
const { t, te } = useI18n()
const portfolio = usePortfolio()
// Detect mobile for accordion behavior (SSR-safe)
const isMobile = ref(true)
function translate(key: string, fallback: string): string {
return te(key) ? t(key) : fallback
}
onMounted(() => {
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
}
checkMobile()
window.addEventListener('resize', checkMobile)
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
})
})
type StackGroupView = {
title: string
value: string
icon: string
description?: string
items: Tag[]
}
type SkillSectionKey = 'expert' | 'proficient' | 'usedBefore'
// Multi-select filters for Skill types
const selectedTypes = ref<SkillType[]>([])
const filterByType = (items: Tag[]) =>
selectedTypes.value.length === 0
? items
: items.filter(i => i.type && selectedTypes.value.includes(i.type))
const filteredExpert = computed(() => filterByType(expert))
const filteredProficient = computed(() => filterByType(proficient))
const filteredUsedBefore = computed(() => filterByType(usedBefore))
const sectionItems = computed<Record<SkillSectionKey, Tag[]>>(() => ({
expert: filteredExpert.value,
proficient: filteredProficient.value,
usedBefore: filteredUsedBefore.value
}))
const skillSections = computed(() => ([
{ label: t('skills.expert'), value: 'expert', icon: 'i-twemoji-military-medal' },
{ label: t('skills.proficient'), value: 'proficient', icon: 'i-twemoji-rocket' },
{ label: t('skills.usedBefore'), value: 'usedBefore', icon: 'i-twemoji-toolbox' }
const fallbackGroups = computed<StackGroupView[]>(() => ([
{ title: t('skills.expert'), value: 'expert', icon: 'i-twemoji-military-medal', items: expert },
{ title: t('skills.proficient'), value: 'proficient', icon: 'i-twemoji-rocket', items: proficient },
{ title: t('skills.usedBefore'), value: 'usedBefore', icon: 'i-twemoji-toolbox', items: usedBefore }
]))
const groups = computed<StackGroupView[]>(() => {
if (portfolio.value.stackGroups?.length) {
return portfolio.value.stackGroups.map((group, index) => ({
title: group.title,
value: `stack-${index}`,
icon: iconForStackGroup(group.title),
description: group.description,
items: group.items,
}))
}
return fallbackGroups.value
})
const sectionItems = computed<Record<string, Tag[]>>(() =>
Object.fromEntries(groups.value.map(group => [group.value, group.items]))
)
const skillSections = computed(() =>
groups.value.map(group => ({
label: group.title,
value: group.value,
icon: group.icon,
description: group.description,
}))
)
const openSkillSections = computed(() => skillSections.value.map(section => section.value))
const openSkillValues = ref<string[]>([])
const skillGridColumns = ref(1)
const skillsAccordionItems = computed(() => [{
label: translate('skills.accordionLabel', 'Purpose-grouped stack for backend, infrastructure, and DevOps work.'),
value: 'skills'
}])
function iconForStackGroup(title: string): string {
const lower = title.toLowerCase()
if (lower.includes('backend')) return 'i-mdi-server-network'
if (lower.includes('infrastructure') || lower.includes('devops')) return 'i-mdi-cog-outline'
if (lower.includes('observability') || lower.includes('monitoring')) return 'i-mdi-chart-box-outline'
if (lower.includes('frontend')) return 'i-mdi-monitor-dashboard'
if (lower.includes('testing')) return 'i-mdi-source-branch-check'
if (lower.includes('expanding')) return 'i-mdi-progress-wrench'
return 'i-twemoji-hammer-and-wrench'
}
function updateSkillGridColumns() {
if (!import.meta.client) return
const width = window.innerWidth
if (width >= 1280) {
skillGridColumns.value = 3
} else if (width >= 768) {
skillGridColumns.value = 2
} else {
skillGridColumns.value = 1
}
}
function syncOpenSkillRows(value: string | string[] | undefined) {
const nextValues = Array.isArray(value) ? value : value ? [value] : []
if (skillGridColumns.value <= 1) {
openSkillValues.value = nextValues
return
}
const openedValues = nextValues.filter(value => !openSkillValues.value.includes(value))
const collapsedValues = openSkillValues.value.filter(value => !nextValues.includes(value))
if (!openedValues.length && !collapsedValues.length) {
openSkillValues.value = nextValues
return
}
const normalizedValues = new Set(nextValues)
for (const value of openedValues) {
const index = skillSections.value.findIndex(section => section.value === value)
if (index === -1) continue
const rowStart = Math.floor(index / skillGridColumns.value) * skillGridColumns.value
const rowEnd = rowStart + skillGridColumns.value
skillSections.value.slice(rowStart, rowEnd).forEach(section => normalizedValues.add(section.value))
}
const valuesToClose = new Set<string>()
for (const value of collapsedValues) {
const index = skillSections.value.findIndex(section => section.value === value)
if (index === -1) continue
const rowStart = Math.floor(index / skillGridColumns.value) * skillGridColumns.value
const rowEnd = rowStart + skillGridColumns.value
skillSections.value.slice(rowStart, rowEnd).forEach(section => valuesToClose.add(section.value))
}
openSkillValues.value = skillSections.value
.map(section => section.value)
.filter(value => normalizedValues.has(value) && !valuesToClose.has(value))
}
watch(
openSkillSections,
(values) => {
openSkillValues.value = [...values]
},
{ immediate: true }
)
onMounted(() => {
updateSkillGridColumns()
window.addEventListener('resize', updateSkillGridColumns)
})
onUnmounted(() => {
if (!import.meta.client) return
window.removeEventListener('resize', updateSkillGridColumns)
})
const accordionUi = {
root: 'flex flex-col gap-3 md:grid md:grid-cols-3 md:gap-4 md:items-stretch',
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm md:self-stretch data-[state=closed]:md:self-start md:h-full data-[state=open]:md:h-full data-[state=closed]:md:h-auto data-[state=open]:md:min-h-[320px] data-[state=closed]:md:min-h-[64px]',
header: 'px-4 border-b border-gray-200/70 dark:border-gray-700/50',
trigger: 'group flex-1 items-center gap-2 py-3 text-left md:cursor-default',
label: 'text-sm font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300',
root: 'flex flex-col gap-3 md:grid md:grid-cols-2 xl:grid-cols-3 md:gap-4 md:items-stretch',
item: 'flex flex-col rounded-lg border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm data-[state=open]:md:min-h-[240px]',
header: 'px-4 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50',
trigger: 'group flex-1 items-center gap-2 py-3 text-left cursor-pointer',
label: 'text-sm font-semibold text-slate-700 dark:text-slate-200',
leadingIcon: 'shrink-0',
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180 md:hidden',
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
content: 'px-4 pb-4 pt-1 data-[state=closed]:hidden',
body: 'pt-1'
} as const
const outerAccordionUi = {
root: 'flex flex-col',
item: 'flex flex-col rounded-2xl border border-gray-200/70 dark:border-gray-700/50 bg-white/70 dark:bg-gray-900/40 shadow-sm',
header: 'sticky top-[60px] z-30 px-3 data-[state=open]:border-b border-gray-200/70 dark:border-gray-700/50 rounded-t-2xl bg-white/95 shadow-sm backdrop-blur-md dark:bg-gray-950/95 sm:top-[60px]',
trigger: 'group flex-1 h-9 min-h-0 items-center gap-2 py-0 text-left cursor-pointer',
label: 'text-xs font-semibold uppercase tracking-wider text-slate-600 dark:text-slate-300 sm:text-sm',
leadingIcon: 'shrink-0',
trailingIcon: 'ms-auto text-gray-500 dark:text-gray-400 transition-transform duration-200 group-data-[state=open]:rotate-180',
content: 'px-4 pb-4 pt-3 data-[state=closed]:hidden',
body: 'pt-1'
} as const
</script>