Initial commit (secrets removed)

This commit is contained in:
Ali Arghyani
2025-11-04 09:09:29 +03:30
commit e1bc3d8adf
182 changed files with 17578 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
<template>
<ClientOnly>
<!-- Nuxt UI Select-based language picker -->
<USelect v-model="model" :items="items" value-key="value" size="sm" color="primary" variant="soft"
:highlight="false" arrow :trailing="true" placeholder="Language"
class="px-1 w-[64px] sm:w-[76px] rounded-full ring-1 ring-gray-200/70 dark:ring-gray-700/60 backdrop-blur-md shadow-sm h-[25px]"
:ui="{
base: 'rounded-full',
value: 'sr-only',
trailingIcon: 'text-dimmed group-data-[state=open]:rotate-180 transition-transform duration-200',
content: 'min-w-fit'
}" aria-label="Language selector">
<!-- Leading icon in trigger (already provided by :icon via selectedIcon) -->
<template #leading="{ ui }">
<UIcon :name="selectedIcon" class="text-[16px]" />
</template>
<template #item-leading="{ item }">
<UIcon :name="item.icon" class="text-[16px]" />
</template>
<template #item-label="{ item }">
<span>{{ item.label }}</span>
</template>
</USelect>
</ClientOnly>
</template>
<script setup lang="ts">
import { ref, computed, watch } from '#imports'
import { useLocaleSwitching, useLoadingIndicator } from '#imports'
const { locale, setLocale } = useI18n()
type LangValue = 'en' | 'fa'
type Item = { label: string; value: LangValue; icon: string }
const items = ref<Item[]>([
{ label: 'en', value: 'en', icon: 'i-twemoji-flag-united-states' },
{ label: 'fa', value: 'fa', icon: 'i-twemoji-flag-iran' }
])
const model = ref<LangValue>(locale.value as LangValue)
// Keep model in sync if locale changes elsewhere
watch(locale, (val) => {
if ((val as LangValue) !== model.value) {
model.value = val as LangValue
}
})
const selectedIcon = computed<string>(() => items.value.find(i => i.value === model.value)?.icon ?? 'i-twemoji-flag-united-states')
const { startLocaleSwitching } = useLocaleSwitching()
const loading = useLoadingIndicator()
// On selection change, run visual feedback and update i18n
watch(model, (val, oldVal) => {
if (val === oldVal) return
startLocaleSwitching(600)
if (loading) {
loading.start()
setTimeout(() => loading.finish(), 600)
}
setLocale(val)
})
</script>
+57
View File
@@ -0,0 +1,57 @@
<template>
<ClientOnly>
<UTooltip v-if="!colorMode.unknown" :text="tooltip">
<UButton :class="btnClass" class="cursor-pointer" square variant="soft" color="primary" :icon="icon"
:title="label" :aria-label="label" :size="btnSize" @click="toggle($event)" />
</UTooltip>
<UButton v-else :class="btnClass" class="cursor-pointer" square variant="soft" color="primary" :icon="icon"
:title="label" :aria-label="label" :size="btnSize" @click="toggle($event)" />
</ClientOnly>
</template>
<script setup lang="ts">
import { useViewTransitionRipple } from '#imports'
const props = defineProps<{ mobileVisible?: boolean; size?: 'sm' | 'md' | 'lg' }>()
const colorMode = useColorMode()
const modeOrder = ['system', 'light', 'dark'] as const
type Mode = (typeof modeOrder)[number]
const currentPref = computed<Mode>(() => {
const pref = colorMode.preference as string | undefined
return (pref === 'light' || pref === 'dark' || pref === 'system') ? (pref as Mode) : 'system'
})
const nextMap: Record<Mode, Mode> = {
system: 'light',
light: 'dark',
dark: 'system'
}
const nextPref = computed<Mode>(() => nextMap[currentPref.value])
const { runRipple } = useViewTransitionRipple()
const toggle = (ev?: MouseEvent) => {
runRipple(ev ?? null, () => {
colorMode.preference = nextPref.value
}, { duration: 500, easing: 'ease-in-out' })
}
const icon = computed(() => {
switch (currentPref.value) {
// Use colorful Twemoji icons for better visual appeal on mobile
case 'dark': return 'i-twemoji-crescent-moon'
case 'light': return 'i-twemoji-sun'
default: return 'i-twemoji-desktop-computer'
}
})
const label = computed(() => `Switch to ${nextPref.value}`)
const tooltip = computed(() => `Theme: ${currentPref.value} — Click to switch to ${nextPref.value}`)
// Visibility and sizing — ThemeSelector itself always renders; page decides mobile/desktop visibility
const btnClass = computed(() => 'inline-flex')
const btnSize = computed(() => props.size ?? 'md')
</script>
+61
View File
@@ -0,0 +1,61 @@
<template>
<div class="bg-blue-950 backdrop-blur-md hover:cursor-pointer" v-show="open">
<div
class="plausible-event-name=VueSchoolBanner+click flex h-16 shrink-0 items-center justify-between px-4 text-white"
@click="navigate"
>
<div></div>
<div class="hidden lg:block">
<img src="/img/vueschool/vueschool.svg" alt="VueSchool Logo" class="h-6" />
</div>
<div
class="hidden bg-gradient-to-r from-amber-400 to-pink-500 bg-clip-text text-lg font-semibold tracking-wide text-transparent sm:inline-block"
>
New Vue Masterclass
</div>
<div class="hidden lg:flex lg:gap-x-2">
<img src="/img/vueschool/vue-with-hat.svg" alt="VueJS Logo" class="h-6" />
<img src="/img/vueschool/pinia.svg" alt="Pinia Logo" class="h-6" />
<img src="/img/vueschool/vite.svg" alt="Vite Logo" class="h-6" />
<img src="/img/vueschool/vitest.svg" alt="Vitest Logo" class="h-6" />
</div>
<div class="text-center leading-5">
<span
class="inline-block bg-gradient-to-r from-emerald-300 to-sky-300 bg-clip-text font-semibold tracking-wide text-transparent"
>
Vue.js Masterclass 2024 Edition
</span>
<span class="ml-2 hidden font-semibold sm:inline">LAUNCHED</span>
<br />
<span class="text-sm text-gray-300 sm:text-base">
Code a real world app using the latest Vue.js tech
</span>
</div>
<UButton
class="plausible-event-name=VueSchoolBanner+click hidden px-4 font-bold md:inline-flex rounded-full"
label="Buy Now"
icon="i-tabler-caret-right-filled"
variant="solid"
trailing
@click="navigate"
/>
<UIcon
name="i-ph-x"
class="size-8 p-2 hover:opacity-80"
@click.stop="open = false"
/>
</div>
</div>
</template>
<script setup lang="ts">
const open = ref(true)
const affiliateId = "UILIBPIC"
const fullUrl = computed(
() => `https://vueschool.io/the-vuejs-3-master-class?friend=${affiliateId}`
)
const navigate = () => {
window.open(fullUrl.value, "_blank")
}
</script>
+112
View File
@@ -0,0 +1,112 @@
<template>
<div v-if="!hideNav" class="fixed inset-x-0 bottom-0 z-60 md:hidden pointer-events-none">
<div class="pointer-events-auto mx-auto max-w-6xl px-4 pb-2" style="padding-bottom: env(safe-area-inset-bottom);">
<div
class="rounded-2xl bg-white/80 dark:bg-slate-900/70 backdrop-blur-md shadow-lg border border-white/30 dark:border-slate-700/50">
<nav role="navigation" aria-label="Primary bottom navigation"
class="flex items-center justify-between gap-1 sm:gap-2 px-2 sm:px-3 py-1 sm:py-2 overflow-x-auto no-scrollbar flex-nowrap snap-x snap-mandatory">
<UButton :class="[homeActive ? activePillClass : inactivePillClass]" color="emerald" variant="soft" size="md"
icon="i-twemoji-house"
class="h-10 sm:h-11 px-3 sm:px-4 rounded-full focus-visible:ring-2 focus-visible:ring-emerald-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 snap-center"
aria-label="Home" :aria-current="homeActive ? 'page' : undefined" @click="goHome">
<span class="hidden sm:inline text-sm">{{ t('nav.home') }}</span>
</UButton>
<UButton :class="[inactivePillClass]" color="emerald" variant="soft" size="md"
icon="i-twemoji-hammer-and-wrench"
class="h-10 sm:h-11 px-3 sm:px-4 rounded-full focus-visible:ring-2 focus-visible:ring-emerald-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 snap-center"
aria-label="Skills" aria-controls="skills" @click="goSkills">
<span class="hidden sm:inline text-sm">{{ t('nav.skills') }}</span>
</UButton>
<UButton :class="[blogActive ? activePillClass : inactivePillClass]" color="emerald" variant="soft" size="md"
icon="i-twemoji-newspaper"
class="h-10 sm:h-11 px-3 sm:px-4 rounded-full focus-visible:ring-2 focus-visible:ring-emerald-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 snap-center"
aria-label="Blog" :aria-current="blogActive ? 'page' : undefined" @click="goBlog">
<span class="hidden sm:inline text-sm">{{ t('nav.blog') }}</span>
</UButton>
<UButton :class="[inactivePillClass]" color="emerald" variant="soft" size="md" icon="i-twemoji-e-mail"
class="h-10 sm:h-11 px-3 sm:px-4 rounded-full focus-visible:ring-2 focus-visible:ring-emerald-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 snap-center"
aria-label="Contact" @click="goContact">
<span class="hidden sm:inline text-sm">{{ t('nav.contact') }}</span>
</UButton>
</nav>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useSectionObserver } from '@/composables/useSectionObserver'
const { t, locale } = useI18n()
const appConfig = useAppConfig()
const route = useRoute()
const router = useRouter()
const localePath = useLocalePath()
const blogIndexPath = computed(() => localePath('/blog'))
const homePath = computed(() => localePath('/'))
const isHome = computed(() => route.path === homePath.value)
const { scrollToSection } = useSectionObserver({
enabled: isHome,
headerSelector: 'nav[data-section-header]'
})
// Hide on blog detail pages, show elsewhere
const hideNav = computed(() => {
const p = route.path
// If route path contains '/blog/' segment, it's a detail page.
// This works regardless of locale prefix.
return p.includes('/blog/') && p !== blogIndexPath.value
})
// Active states
const homeActive = computed(() => route.path === homePath.value)
const blogActive = computed(() => route.path.startsWith(blogIndexPath.value) && !hideNav.value)
// Navigation actions
const goHome = () => {
if (!homeActive.value) {
router.push(homePath.value)
}
}
const goBlog = () => {
if (!blogActive.value) {
router.push(blogIndexPath.value)
}
}
const goContact = () => {
// External contact URL in new tab
if (import.meta.client) {
window.open(appConfig.myContactUrl, '_blank', 'noopener')
}
}
const goSkills = async () => {
const targetId = 'skills'
const scrollToSkills = () => {
scrollToSection(targetId)
}
if (route.path === homePath.value) {
// Already on home: smooth scroll
if (import.meta.client) scrollToSkills()
} else {
await router.push(homePath.value)
// Ensure DOM updated before scroll
if (import.meta.client) requestAnimationFrame(scrollToSkills)
}
}
// Styles
const activePillClass = 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 ring-1 ring-emerald-400/40'
const inactivePillClass = 'text-slate-700 dark:text-slate-200 hover:bg-emerald-500/10'
</script>
+37
View File
@@ -0,0 +1,37 @@
<template>
<ClientOnly>
<UTooltip :delay-duration="0" v-bind="tooltipProps">
<slot />
<template
v-for="slotName in namedSlots"
:key="slotName"
v-slot:[slotName]
>
<slot :name="slotName" />
</template>
</UTooltip>
<template #fallback>
<slot />
</template>
</ClientOnly>
</template>
<script setup lang="ts">
import { computed, useAttrs, useSlots } from 'vue'
import type { TooltipProps } from '@nuxt/ui/components/Tooltip.vue'
const props = defineProps<TooltipProps>()
const attrs = useAttrs()
const slots = useSlots()
const tooltipProps = computed(() => ({
...attrs,
...props,
}) as TooltipProps & Record<string, unknown>)
const namedSlots = computed(() =>
Object.keys(slots).filter((name) => name !== 'default'),
)
</script>
+34
View File
@@ -0,0 +1,34 @@
<template>
<footer class="py-10">
<UContainer>
<div class="flex flex-col items-center gap-4 text-center text-sm text-gray-600 dark:text-gray-400">
<NuxtImg :src="logoSrc" alt="Ali Arghyani logo" width="64" height="64" class="h-12 w-12" format="png"
loading="lazy" />
<p>&copy; {{ currentYear }}, <span class="font-semibold text-gray-900 dark:text-gray-100">AliArghyani</span> -
All rights reserved.</p>
<a href="https://github.com/aliarghyani" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-sm font-medium text-primary-600 transition hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
<UIcon name="i-mdi-github" class="text-base" />
<span>GitHub</span>
<UIcon name="i-mdi-open-in-new" class="text-sm opacity-80" />
</a>
</div>
</UContainer>
</footer>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const colorMode = useColorMode()
const currentYear = computed(() => new Date().getFullYear())
const logoSrc = computed(() => {
if (colorMode.unknown) {
return '/favicon/android-chrome-192x192.png'
}
return colorMode.value === 'dark'
? '/favicon/android-chrome-192x192-dark.png'
: '/favicon/android-chrome-192x192.png'
})
</script>
+177
View File
@@ -0,0 +1,177 @@
<template>
<ClientOnly>
<UPopover v-model:open="isOpen" :teleport="true" :popper="{ strategy: 'fixed', placement: 'bottom-end', offset: 8 }"
:ui="{ content: 'z-[120]' }">
<template #default>
<UButton variant="soft" color="primary" square :icon="triggerIcon" :aria-label="t('theme.customizer')"
class="transition-colors duration-200 cursor-pointer" @click="openCustomizer" />
</template>
<template #content>
<div class="p-3 w-80 md:w-96 lg:w-[18rem] space-y-3">
<div class="text-sm font-medium">{{ t('theme.customizer') }}</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">
{{ t('nav.theme') }}
</div>
<div class="flex items-center gap-2">
<UTooltip :text="t('theme.mode.system')">
<UButton :color="isModeActive('system') ? 'primary' : 'neutral'" variant="soft" size="sm" square
:class="isModeActive('system') ? 'ring-1 ring-primary-400/40 bg-primary-500/10 dark:bg-primary-400/10' : ''"
icon="i-twemoji-desktop-computer" :aria-label="t('theme.mode.system')"
@click="setMode($event, 'system')" />
</UTooltip>
<UTooltip :text="t('theme.mode.light')">
<UButton :color="isModeActive('light') ? 'primary' : 'neutral'" variant="soft" size="sm" square
:class="isModeActive('light') ? 'ring-1 ring-primary-400/40 bg-primary-500/10 dark:bg-primary-400/10' : ''"
icon="i-twemoji-sun" :aria-label="t('theme.mode.light')" @click="setMode($event, 'light')" />
</UTooltip>
<UTooltip :text="t('theme.mode.dark')">
<UButton :color="isModeActive('dark') ? 'primary' : 'neutral'" variant="soft" size="sm" square
:class="isModeActive('dark') ? 'ring-1 ring-primary-400/40 bg-primary-500/10 dark:bg-primary-400/10' : ''"
icon="i-twemoji-crescent-moon" :aria-label="t('theme.mode.dark')" @click="setMode($event, 'dark')" />
</UTooltip>
</div>
</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">
{{ t('theme.primary.label') }}
</div>
<div class="grid grid-cols-3 gap-2">
<UButton v-for="p in primaryItems" :key="p.key" :color="isPrimaryActive(p.key) ? 'primary' : 'neutral'"
variant="soft" size="sm"
:class="['justify-start', isPrimaryActive(p.key) ? 'ring-1 ring-primary-400/40 bg-primary-500/10 dark:bg-primary-400/10' : '']"
:aria-label="`Primary: ${p.label}`" @click="setPrimary($event, p.key)">
<span class="inline-flex items-center gap-2">
<span class="size-3 rounded-full" :class="colorClassMap[p.key]" />
<span class="text-xs capitalize">{{ p.label }}</span>
</span>
</UButton>
</div>
</div>
</div>
</template>
</UPopover>
</ClientOnly>
</template>
<script setup lang="ts">
import { useI18n, useColorMode, useAppConfig, computed, onMounted, ref, nextTick, useViewTransitionRipple } from '#imports'
const { t } = useI18n()
const colorMode = useColorMode()
const appConfig = useAppConfig()
const isOpen = ref(false)
const openCustomizer = async () => {
isOpen.value = true
await nextTick()
}
const primaryItems: Array<{ key: PrimaryColor; label: string }> = [
{ key: 'red', label: 'Red' },
{ key: 'orange', label: 'Orange' },
{ key: 'amber', label: 'Amber' },
{ key: 'yellow', label: 'Yellow' },
{ key: 'lime', label: 'Lime' },
{ key: 'green', label: 'Green' },
{ key: 'emerald', label: 'Emerald' },
{ key: 'teal', label: 'Teal' },
{ key: 'cyan', label: 'Cyan' },
{ key: 'sky', label: 'Sky' },
{ key: 'blue', label: 'Blue' },
{ key: 'indigo', label: 'Indigo' },
{ key: 'violet', label: 'Violet' },
{ key: 'purple', label: 'Purple' },
{ key: 'fuchsia', label: 'Fuchsia' },
{ key: 'pink', label: 'Pink' },
{ key: 'rose', label: 'Rose' }
]
const triggerIcon = computed(() => 'i-twemoji-artist-palette')
type Mode = 'system' | 'light' | 'dark'
const isModeActive = (m: Mode) => (colorMode.preference as Mode) === m
const { runRipple } = useViewTransitionRipple()
const setMode = (ev: MouseEvent, m: Mode) => {
runRipple(ev, () => {
colorMode.preference = m
}, { duration: 500, easing: 'ease-in-out' })
}
const primaryOptions = [
'black',
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'blue',
'indigo',
'violet',
'purple',
'fuchsia',
'pink',
'rose'
] as const
type PrimaryColor = typeof primaryOptions[number]
const STORAGE_KEY = 'ui:primary'
const colorClassMap: Record<PrimaryColor, string> = {
black: 'bg-black',
red: 'bg-red-500',
orange: 'bg-orange-500',
amber: 'bg-amber-500',
yellow: 'bg-yellow-500',
lime: 'bg-lime-500',
green: 'bg-green-500',
emerald: 'bg-emerald-500',
teal: 'bg-teal-500',
cyan: 'bg-cyan-500',
sky: 'bg-sky-500',
blue: 'bg-blue-500',
indigo: 'bg-indigo-500',
violet: 'bg-violet-500',
purple: 'bg-purple-500',
fuchsia: 'bg-fuchsia-500',
pink: 'bg-pink-500',
rose: 'bg-rose-500',
}
const setPrimary = (ev: MouseEvent | null, p: PrimaryColor) => {
runRipple(ev, () => {
// Update both shortcuts supported by Nuxt UI
; (appConfig.ui as any).primary = p
; (appConfig.ui as any).colors = {
...((appConfig.ui as any).colors || {}),
primary: p,
}
if (import.meta.client) {
localStorage.setItem(STORAGE_KEY, p)
}
}, { duration: 500, easing: 'ease-in-out' })
}
onMounted(() => {
if (import.meta.client) {
const saved = (localStorage.getItem(STORAGE_KEY) as PrimaryColor | null)
if (saved && primaryOptions.includes(saved)) {
setPrimary(null, saved)
}
}
})
const isPrimaryActive = (p: PrimaryColor) =>
((appConfig.ui as any)?.primary === p) || ((appConfig.ui as any)?.colors?.primary === p)
const activeClass = 'bg-primary-500/10 text-primary-600 dark:text-primary-400'
const inactiveClass = 'text-gray-500 dark:text-gray-300 hover:text-primary-400'
</script>
+111
View File
@@ -0,0 +1,111 @@
<template>
<ClientOnly>
<nav class="fixed inset-x-0 top-0 z-50 pointer-events-auto" data-section-header>
<div class="mx-auto max-w-6xl px-4 pt-2">
<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">
<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" :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 text-sm font-medium transition-colors"
:class="[isActive('hero') ? labelActiveClass : labelInactiveClass]" @click="goTo('hero')">
{{ t('nav.home') }}
</button>
</div>
<!-- Skills -->
<div class="flex items-center gap-1.5">
<UTooltip :text="t('sections.skills')">
<UButton class="cursor-pointer" :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 text-sm font-medium transition-colors"
: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" :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 text-sm font-medium transition-colors"
: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" :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 text-sm font-medium transition-colors"
:class="[isActive('projects') ? labelActiveClass : labelInactiveClass]" @click="goTo('projects')">
{{ t('sections.projects') }}
</button>
</div>
</div>
<div class="flex items-center gap-2">
<LanguageSwitcher />
<ThemeCustomizer />
</div>
</div>
</div>
</div>
</nav>
</ClientOnly>
</template>
<script setup lang="ts">
import ThemeCustomizer from '@/components/common/ThemeCustomizer.vue'
import LanguageSwitcher from '@/components/LanguageSwitcher.vue'
import { useSectionObserver, type SectionId } from '@/composables/useSectionObserver'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
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]
const isHome = computed(() => route.path === localePath('/'))
const { activeSection, scrollToSection } = useSectionObserver({
ids: [...sectionIds] as SectionId[],
headerSelector: 'nav[data-section-header]',
offset: 80,
enabled: isHome
})
const isActive = (id: Target) => activeSection.value === id
async function goTo(id: Target) {
const homePath = localePath('/')
if (route.path !== homePath) {
await router.push(homePath)
await nextTick()
requestAnimationFrame(() => scrollToSection(id))
} else {
scrollToSection(id)
}
}
</script>
+190
View File
@@ -0,0 +1,190 @@
<template>
<section id="ai-stack" class="py-6 scroll-mt-20">
<UContainer>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
<div class="flex items-center gap-3">
<UIcon name="twemoji:robot" class="text-2xl" />
<h2 class="text-lg font-semibold">{{ t('skills.aiStack') }}</h2>
</div>
<div
class="ms-auto w-full md:w-auto flex flex-row-reverse items-center gap-1 overflow-x-auto no-scrollbar whitespace-nowrap px-1 py-1"
role="toolbar" aria-label="AI Stack group filters">
<UButton :icon="filterButtonIcon" size="xs" :variant="filterButtonVariant" :color="filterButtonColor"
class="filter-toggle rounded-full shrink-0 mx-1"
:class="{ 'is-open': filtersOpen, 'has-selection': hasSelection }" :aria-label="filterButtonLabel"
:aria-expanded="hasSelection ? undefined : filtersOpen" aria-controls="ai-group-filter-group"
@click="handlePrimaryAction" />
<Transition name="filter-panel">
<div v-if="filtersOpen" id="ai-group-filter-group" class="flex items-center gap-1">
<TransitionGroup name="filter-chip" tag="div" class="flex flex-row-reverse items-center gap-1">
<div v-for="(opt, index) in groupOptions" :key="opt.value" class="filter-chip"
:style="transitionVars(index, groupOptions.length)">
<UTooltip :text="opt.label" :arrow="true" :delay-duration="0.5"
:content="{ side: 'bottom', sideOffset: 8, collisionPadding: 12 }">
<UButton :icon="opt.icon" size="xs" :variant="isSelected(opt.value) ? 'solid' : 'soft'"
color="primary" class="rounded-full shrink-0" :aria-pressed="isSelected(opt.value)"
:aria-label="`Filter by ${opt.label}`" :title="opt.label" @click="toggleGroup(opt.value)" />
</UTooltip>
</div>
</TransitionGroup>
</div>
</Transition>
</div>
</div>
<div class="grid gap-4 md:grid-cols-3">
<UCard class="md:col-span-3">
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
{{ headerTitle }}
</h3>
</div>
</template>
<div class="flex flex-wrap gap-1.5">
<div v-for="item in filtered" :key="item.id" class="inline-flex items-stretch">
<UTooltip :arrow="true" :delay-duration="0.5" :text="item.shortWhy || item.name"
:content="{ side: 'top', sideOffset: 8, collisionPadding: 12 }">
<UBadge 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 min-h-4 min-w-4 text-base" />
<span class="text-xs font-medium cursor-default">{{ item.name }}</span>
<!-- <UBadge size="xs" color="neutral" variant="subtle" class="ml-1">{{ groupLabel(item.group) }}
</UBadge> -->
</span>
</UBadge>
</UTooltip>
</div>
</div>
</UCard>
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { AI_GROUPS, aiStackItems, type AiGroup } from '@/data/aiStack'
const { t } = useI18n()
type GroupMeta = { labelKey: string; icon: string }
const GROUP_META: Record<AiGroup, GroupMeta> = {
ide_dev: { labelKey: 'ai_stack.group.ide_dev', icon: 'i-mdi-laptop' },
protocols: { labelKey: 'ai_stack.group.protocols', icon: 'i-mdi-server-network' },
concepts: { labelKey: 'ai_stack.group.concepts', icon: 'i-mdi-lightbulb-outline' },
approaches: { labelKey: 'ai_stack.group.approaches', icon: 'i-mdi-compass-outline' }
}
const groupOptions = computed(() => AI_GROUPS.map(g => ({
value: g,
label: t(GROUP_META[g].labelKey),
icon: GROUP_META[g].icon
})))
const selectedGroups = ref<AiGroup[]>([])
const filtersOpen = ref(true)
const isSelected = (g: AiGroup) => selectedGroups.value.includes(g)
const toggleGroup = (g: AiGroup) => {
const cur = selectedGroups.value
selectedGroups.value = isSelected(g) ? cur.filter(x => x !== g) : [...cur, g]
}
const clear = () => {
selectedGroups.value = []
filtersOpen.value = false
}
const hasSelection = computed(() => selectedGroups.value.length > 0)
const filterButtonIcon = computed(() => hasSelection.value ? 'i-mdi-filter-remove' : 'i-mdi-filter-variant')
const filterButtonLabel = computed(() =>
hasSelection.value
? 'Clear AI stack filters'
: filtersOpen.value
? 'Hide AI stack filters'
: 'Show AI stack filters'
)
const filterButtonVariant = computed(() => hasSelection.value ? 'solid' : filtersOpen.value ? 'soft' : 'ghost')
const filterButtonColor = computed(() => hasSelection.value ? 'error' : 'neutral')
const handlePrimaryAction = () => {
if (hasSelection.value) {
clear()
return
}
filtersOpen.value = !filtersOpen.value
}
const transitionVars = (index: number, total: number) =>
({
'--filter-index': `${index}`,
'--filter-order': `${Math.max(total - index, 0)}`
}) as Record<string, string>
const filtered = computed(() => {
if (selectedGroups.value.length === 0) return aiStackItems
return aiStackItems.filter(i => selectedGroups.value.includes(i.group))
})
const groupLabel = (g: AiGroup) => t(GROUP_META[g].labelKey)
const headerTitle = computed(() => {
if (selectedGroups.value.length === 1) return groupLabel(selectedGroups.value[0]!)
return t('ai_stack.subtitle', 'Methods, tools, rules, and MCPs that power my AI workflow')
})
</script>
<style scoped>
/* Adopt SkillFilters transition styles for smooth group filter UX */
.filter-toggle {
transition: transform 200ms ease, box-shadow 220ms ease, filter 220ms ease;
}
.filter-toggle.has-selection {
box-shadow: 0 12px 28px -12px rgba(220, 38, 38, 0.45);
}
.filter-toggle.is-open:not(.has-selection) :deep([class*='i-mdi']) {
transform: rotate(180deg);
transition: transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.filter-panel-enter-active,
.filter-panel-leave-active {
transition: opacity 200ms ease, transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: right center;
}
.filter-panel-enter-from,
.filter-panel-leave-to {
opacity: 0;
transform: translateX(0.75rem) scale(0.92);
}
.filter-chip-enter-active,
.filter-chip-leave-active {
transition: opacity 200ms ease, transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: right center;
}
.filter-chip-enter-active {
transition-delay: calc(var(--filter-index, 0) * 45ms);
}
.filter-chip-leave-active {
transition-delay: calc(var(--filter-order, 0) * 35ms);
}
.filter-chip-enter-from,
.filter-chip-leave-to {
opacity: 0;
transform: translateX(0.5rem) scale(0.85);
}
.filter-chip-move {
transition: transform 200ms ease;
}
</style>
@@ -0,0 +1,38 @@
<template>
<section class="py-6">
<UContainer>
<div class="flex items-center gap-3 mb-3">
<UIcon name="i-twemoji-graduation-cap" class="text-2xl" />
<h2 class="text-lg font-semibold text-start">{{ t('sections.education') }}</h2>
</div>
<div class="space-y-4">
<UCard v-for="(edu, i) in portfolio.education" :key="i"
class="border border-gray-200/60 shadow-none dark:border-gray-700/40">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start gap-3 space-y-1">
<div class="w-12 h-12 rounded-lg object-contain shrink-0 flex items-center justify-center">
<ClientOnly>
<NuxtImg v-if="edu.logo" :src="edu.logo" :alt="edu.school" class="max-w-full max-h-full" />
</ClientOnly>
</div>
<div>
<h3 class="text-base font-semibold">{{ edu.school }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-300">{{ edu.degree }}</p>
<p class="text-xs text-gray-500">{{ edu.start }} - {{ edu.end }}</p>
</div>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-1 text-primary-500 dark:text-primary-300">
<UIcon v-for="(ic, k) in edu.icons" :key="k" :name="ic" class="text-xl" />
</div>
</div>
</UCard>
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { usePortfolio } from '@/composables/usePortfolio'
const portfolio = usePortfolio()
const { t } = useI18n()
</script>
+115
View File
@@ -0,0 +1,115 @@
<template>
<section class="py-6">
<UContainer>
<div class="flex items-center gap-3 mb-3">
<UIcon name="i-twemoji-briefcase" class="text-2xl" />
<h2 class="text-lg font-semibold text-start">{{ t('sections.work') }}</h2>
</div>
<div class="space-y-6">
<!-- Company cards -->
<UCard v-for="(company, i) in companies" :key="i"
class="border border-gray-200/60 shadow-none dark:border-gray-700/40">
<!-- Header: company name + meta -->
<div class="flex items-start gap-4">
<div v-if="company.logo"
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200/70 dark:bg-slate-900 dark:ring-gray-700/60">
<img :src="company.logo" :alt="`${company.company} logo`" class="h-10 w-10 object-contain" loading="lazy" />
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 text-gray-800 dark:text-gray-100">
<a v-if="company.link" :href="company.link" target="_blank" :aria-label="company.company"
class="text-base font-semibold link-hover-clean">
{{ company.company }}
</a>
<h3 v-else class="text-base font-semibold">{{ company.company }}</h3>
<span v-if="company.type" class="text-xs text-gray-400">•</span>
<p v-if="company.type" class="text-xs text-gray-500">{{ company.type }}</p>
</div>
<p v-if="company.location" class="mt-1 text-xs text-gray-500">{{ company.location }}</p>
</div>
</div>
<!-- Timeline -->
<div class="relative mt-4">
<div :class="[
'absolute top-2 bottom-2 w-px bg-gray-200 dark:bg-gray-700',
]" />
<div v-for="(pos, j) in company.positions" :key="j" class="relative flex items-start gap-4">
<div :class="['shrink-0', isRTL ? 'translate-x-[50%]' : 'translate-x-[-50%]']">
<div class="mt-2 h-2 w-2 rounded-full bg-primary-500 ring-4 ring-primary-100 dark:ring-primary-950">
</div>
</div>
<div :class="['min-w-0 flex-1', j !== company.positions.length - 1 ? 'pb-6' : '']">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ pos.title }}</p>
</div>
<p class="mt-0.5 text-xs text-gray-500">
{{ pos.start }}
<template v-if="pos.ongoing"> - {{ presentText }}</template>
<template v-else-if="pos.end"> - {{ pos.end }}</template>
</p>
<ul :class="[
'mt-2 list-disc space-y-1 text-sm text-gray-700 dark:text-gray-200',
isRTL ? 'pr-5 text-right marker:text-primary-300' : 'pl-5 text-left marker:text-primary-500'
]">
<li v-for="(line, k) in pos.description" :key="k">{{ line }}</li>
</ul>
<div v-if="pos.icons?.length"
class="mt-2 flex flex-wrap items-center gap-1 text-primary-500 dark:text-primary-300">
<UIcon v-for="(ic, k) in pos.icons" :key="k" :name="ic" class="text-xl" />
</div>
</div>
</div>
</div>
</UCard>
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { usePortfolio } from '@/composables/usePortfolio'
import type { CompanyExperience, Experience } from '@/types/portfolio.types'
const { t, locale } = useI18n()
const portfolio = usePortfolio()
const isRTL = computed(() => locale.value === 'fa')
const presentText = computed(() => t('common.present'))
// Normalize experiences into company groups so we can render a timeline per company
type Normalized = Required<Pick<CompanyExperience, 'company' | 'positions'>> &
Pick<CompanyExperience, 'link' | 'logo' | 'location' | 'type'>
const companies = computed<Normalized[]>(() => {
const list = portfolio.value.experiences as Array<CompanyExperience | Experience>
return list.map((exp: any) => {
if (Array.isArray(exp.positions)) {
return exp as Normalized
}
const single = exp as Experience
return {
company: single.company,
location: single.location,
type: single.type,
logo: single.logo,
positions: [
{
title: single.role,
start: single.start,
end: single.end,
ongoing: single.ongoing,
description: single.description,
icons: single.icons,
location: single.location,
type: single.type,
link: single.link,
},
],
}
})
})
</script>
@@ -0,0 +1,16 @@
<template>
<div class="pointer-events-none hidden lg:block">
<div class="fixed left-4 bottom-4 z-40 pointer-events-auto">
<UButton :to="localePath('/blog')" color="primary" icon="i-twemoji-newspaper" variant="soft"
class="shadow-lg cursor-pointer">
{{ t('buttons.blog') }}
</UButton>
</div>
</div>
</template>
<script setup lang="ts">
const { t } = useI18n()
const localePath = useLocalePath()
</script>
+189
View File
@@ -0,0 +1,189 @@
<template>
<section id="hero" class="pt-8 pb-6 scroll-mt-20 flex items-center">
<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 }}
</h1>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 mb-4 sm:mb-3">
{{ portfolio.profile.summary }}
</p>
<div v-if="portfolio.profile.location || currentRole"
class="mb-6 flex flex-col items-center gap-2 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 items-center gap-1 text-base text-gray-700 dark:text-gray-200">
<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 text-primary-600 dark:text-primary-300">
<a v-if="currentRole.companyLink" :href="currentRole.companyLink" target="_blank" rel="noopener"
class="hover:underline text-primary-600 dark:text-primary-300">
{{ currentRole.company }}
</a>
<span v-else>{{ currentRole.company }}</span>
</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"
class="chip-button cursor-pointer" aria-label="Email" title="Email" @click="copyEmail" />
</ClientTooltip>
<ClientTooltip text="GitHub" v-if="portfolio.profile.socials?.github">
<UButton :to="portfolio.profile.socials.github" target="_blank" icon="i-mdi-github" :square="true"
color="gray" variant="ghost" class="chip-button" aria-label="GitHub" title="GitHub" />
</ClientTooltip>
<ClientTooltip text="LinkedIn" v-if="portfolio.profile.socials?.linkedin">
<UButton :to="portfolio.profile.socials.linkedin" target="_blank" icon="i-logos-linkedin-icon"
:square="true" color="gray" variant="ghost" class="chip-button" aria-label="LinkedIn"
title="LinkedIn" />
</ClientTooltip>
<ClientTooltip text="X" v-if="portfolio.profile.socials?.x || portfolio.profile.socials?.twitter">
<UButton :to="portfolio.profile.socials?.x || portfolio.profile.socials?.twitter" target="_blank"
icon="i-logos-twitter" :square="true" color="gray" variant="ghost" class="chip-button" aria-label="X"
title="X" />
</ClientTooltip>
<ClientTooltip text="Telegram" v-if="portfolio.profile.socials?.telegram">
<UButton :to="portfolio.profile.socials.telegram" target="_blank" icon="i-logos-telegram" :square="true"
color="gray" variant="ghost" class="chip-button" aria-label="Telegram" title="Telegram" />
</ClientTooltip>
<ClientTooltip text="WhatsApp" v-if="portfolio.profile.socials?.whatsapp">
<UButton :to="portfolio.profile.socials.whatsapp" target="_blank" icon="i-logos-whatsapp-icon"
:square="true" color="gray" variant="ghost" class="chip-button" aria-label="WhatsApp"
title="WhatsApp" />
</ClientTooltip>
<ClientTooltip text="Spotify" v-if="portfolio.profile.socials?.spotify">
<UButton :to="portfolio.profile.socials.spotify" target="_blank" :square="true" color="gray"
variant="ghost" class="chip-button" aria-label="Spotify" title="Spotify">
<UIcon name="i-simple-icons-spotify" class="text-[#1DB954] text-lg" />
</UButton>
</ClientTooltip>
<ClientTooltip text="Bento" v-if="portfolio.profile.socials?.bento">
<UButton :to="portfolio.profile.socials.bento" target="_blank" icon="i-simple-icons-bento" :square="true"
color="gray" variant="ghost" class="chip-button" aria-label="Bento" title="Bento" />
</ClientTooltip>
<ClientTooltip text="Instagram" v-if="portfolio.profile.socials?.instagram">
<UButton :to="portfolio.profile.socials.instagram" target="_blank" :square="true" color="gray"
variant="ghost" class="chip-button" aria-label="Instagram" title="Instagram">
<span
class="inline-flex h-5 w-5 items-center justify-center overflow-hidden rounded-[6px] bg-gradient-to-br from-[#f9ce34] via-[#ee2a7b] to-[#6228d7]">
<UIcon name="i-mdi-instagram" class="text-white text-sm" />
</span>
</UButton>
</ClientTooltip>
<ClientTooltip text="Email" v-if="portfolio.profile.socials?.email">
<UButton :to="portfolio.profile.socials.email" icon="i-twemoji-e-mail" :square="true" color="gray"
variant="ghost" class="chip-button" aria-label="Email" title="Email" />
</ClientTooltip>
</div>
</div>
<div
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>
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
import type { CompanyExperience, Experience } from '@/types/portfolio.types'
const { t } = useI18n()
const portfolio = usePortfolio()
const toast = useToast()
const currentRole = computed(() => {
const experiences = portfolio.value.experiences as Array<CompanyExperience | Experience>
const formatLinkLabel = (url?: string) => {
if (!url) return undefined
try {
return new URL(url).hostname.replace(/^www\./, '')
} catch {
return undefined
}
}
for (const exp of experiences) {
if ('positions' in exp) {
const active = exp.positions.find(pos => pos.ongoing)
if (active) {
const companyLink = exp.link || active.link
const projectLink = active.link || undefined
return {
company: exp.company,
title: active.title,
companyLink,
companyLogo: active.logo || exp.logo,
projectLink,
projectLabel: active.linkLabel || formatLinkLabel(projectLink),
}
}
} else if (exp.ongoing) {
return {
company: exp.company,
title: exp.role,
companyLink: exp.link,
companyLogo: exp.logo,
}
}
}
return null
})
/**
* Email copy-to-clipboard for Hero quick action
*/
const emailAddress = 'aliarghyani@gmail.com'
const emailTooltip = ref('Email')
async function copyEmail() {
try {
await navigator.clipboard.writeText(emailAddress)
emailTooltip.value = 'Copied'
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'
})
} 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'
})
// 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)
// 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'
})
}
}
}
</script>
@@ -0,0 +1,85 @@
<template>
<section class="py-6">
<UContainer>
<div class="flex items-center gap-3 mb-4">
<UIcon name="i-twemoji-globe-showing-europe-africa" class="text-2xl" />
<h2 class="text-lg font-semibold">{{ t('sections.language') }}</h2>
</div>
<UCard>
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4">
<p class="text-sm text-gray-600 dark:text-gray-300 max-w-3xl">
{{ t('languageSection.tagline') }}
</p>
<UBadge
size="lg"
color="primary"
variant="soft"
class="inline-flex items-center gap-2"
>
<UIcon
name="simple-icons:duolingo"
class="text-xl text-[#58CC02] dark:text-[#58CC02]"
/>
<span>
{{ t('languageSection.duolingo.label') }}: {{ t('languageSection.duolingo.value') }}
</span>
</UBadge>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div
v-for="item in items"
:key="item.key"
class="flex flex-col gap-2 rounded-lg border border-slate-200/80 p-3 dark:border-slate-700/60"
>
<div class="flex items-center gap-2">
<img
v-if="item.iconType === 'image'"
:src="item.icon"
:alt="item.title"
class="h-6 w-6 object-contain"
/>
<UIcon
v-else
:name="item.icon"
class="text-lg text-primary-500 dark:text-primary-400"
/>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ item.title }}
</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
{{ item.description }}
</p>
</div>
</div>
</div>
</UCard>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { t } = useI18n()
const items = computed(() => [
{
key: 'ielts',
iconType: 'image' as const,
icon: '/img/icons8-ielts-48.png',
title: t('languageSection.items.ielts.title'),
description: t('languageSection.items.ielts.desc'),
},
{
key: 'huawei',
iconType: 'icon' as const,
icon: 'i-twemoji-globe-with-meridians',
title: t('languageSection.items.huawei.title'),
description: t('languageSection.items.huawei.desc'),
},
])
</script>
+110
View File
@@ -0,0 +1,110 @@
<template>
<section id="projects" class="py-6 scroll-mt-20">
<UContainer>
<div class="flex items-center gap-3 mb-3">
<UIcon name="i-twemoji-rocket" class="text-2xl" />
<h2 class="text-lg font-semibold text-start">{{ t('sections.projects') }}</h2>
</div>
<div v-for="g in nonEmptyCategoryList" :key="g.cat" class="space-y-3 mb-6">
<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">
<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">
<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>
<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 }}
</UBadge>
<UBadge v-if="p.opensource" color="emerald" variant="soft" class="rounded-full">
<UIcon name="i-mdi-source-branch" class="mr-1" />
{{ t('projectLabels.openSource') }}
</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>
</div>
</div>
</div>
</UCard>
</div>
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
import type { Project } from '@/types/portfolio.types'
const portfolio = usePortfolio()
const { t } = useI18n()
type Category = NonNullable<Project['category']>
const categories: Category[] = ['current', 'freelance', 'public']
const projectsByCategory = computed<Record<Category, Project[]>>(() => {
// Initialize with all categories to preserve order and allow skipping empty ones
const acc: Record<Category, Project[]> = { current: [], freelance: [], public: [] }
for (const p of portfolio.value.projects) {
const cat = (p.category ?? 'freelance') as Category
acc[cat].push(p)
}
return acc
})
const categoryList = computed<Array<{ cat: Category; items: Project[] }>>(() => {
return categories.map((c) => ({ cat: c as Category, items: projectsByCategory.value[c as Category] }))
})
const nonEmptyCategoryList = computed<Array<{ cat: Category; items: Project[] }>>(() => {
return categoryList.value.filter(g => g.items.length > 0)
})
function tCategory(cat: Category): string {
return t(`projectCategories.${cat}`)
}
function getProjectIcon(project: Project): string {
if (project.thumbnail) return ''
const hasGitLink = project.links?.some(link =>
link.icon === 'i-mdi-github' ||
/github/i.test(link.label) ||
/github\.com/i.test(link.to)
)
if (project.opensource || hasGitLink) {
return 'i-mdi-github'
}
return 'i-twemoji-rocket'
}
</script>
@@ -0,0 +1,190 @@
<template>
<section ref="sectionEl" class="py-8">
<UContainer>
<div class="flex flex-wrap items-center gap-3 mb-8">
<!-- <div
class="p-2 rounded-lg bg-gradient-to-br from-blue-500/10 to-indigo-500/10 dark:from-blue-400/20 dark:to-indigo-400/20">
</div> -->
<div class="flex items-center gap-3 flex-1 min-w-0">
<UIcon name="i-twemoji-left-speech-bubble" class="text-2xl" />
<h2 class="text-2xl font-bold flex items-center gap-2 truncate">
<span>{{ t('sections.recommendations') }}</span>
</h2>
</div>
</div>
<ClientOnly>
<template v-if="shouldRenderCarousel">
<div class="relative">
<UCarousel ref="carouselRef" v-slot="{ item }" dots :auto-scroll="autoScrollOptions" loop
:align="rtl ? 'end' : 'start'" :items="recs" :ui="carouselUi" :breakpoints="carouselBreakpoints">
<div
class="w-full flex flex-col bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-xl p-6 select-none cursor-grab active:cursor-grabbing transition-all duration-300 hover:shadow-xl hover:border-primary dark:hover:border-primary">
<!-- Header -->
<div class="flex items-start gap-3 mb-4">
<div
class="shrink-0 w-12 h-12 rounded-lg bg-gradient-to-br from-[#0A66C2] to-[#004182] flex items-center justify-center shadow-md">
<UIcon name="i-simple-icons-linkedin" class="text-2xl text-white" />
</div>
<div class="min-w-0 flex-1">
<p class="font-semibold text-sm leading-tight text-gray-900 dark:text-gray-100">{{ item.author }}
</p>
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">{{ item.author_title }}</p>
</div>
</div>
<!-- Meta Info -->
<div
class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-gray-500 dark:text-gray-400 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-1">
<UIcon name="i-twemoji-briefcase" class="text-sm" />
<span>{{ item.relationship }}</span>
</div>
<span class="text-gray-300 dark:text-gray-600">|</span>
<div class="flex items-center gap-1">
<UIcon name="i-twemoji-calendar" class="text-sm" />
<span>{{ formatDate(item.date) }}</span>
</div>
</div>
<!-- Quote Content -->
<div class="flex-1 mb-4 min-h-[120px]">
<blockquote class="text-sm leading-relaxed text-gray-700 dark:text-gray-300 line-clamp-6">
{{ item.text }}
</blockquote>
</div>
<!-- Footer Buttons -->
<div class="flex items-center gap-2 pt-3 mt-auto border-t border-gray-200 dark:border-gray-700">
<UButton :to="item.linkedin_url" target="_blank" size="xs"
class="flex-1 justify-center bg-[#0A66C2] hover:bg-[#004182] text-white border-0 font-medium">
<UIcon name="i-simple-icons-linkedin" class="text-base text-white" />
<span class="ml-1.5">{{ linkedinText(item.author) }}</span>
</UButton>
<UButton :to="item.recommendation_url" target="_blank" color="gray" variant="soft" size="xs"
icon="i-lucide-external-link" class="flex-1 justify-center hover-ring-tint">
View Full
</UButton>
</div>
</div>
</UCarousel>
<!-- <div class="absolute inset-0 z-50 flex items-center justify-between pointer-events-none px-2 sm:px-4">
<UButton icon="i-lucide-chevron-left" variant="soft" color="gray" size="sm"
class="pointer-events-auto rounded-full bg-white/90 dark:bg-gray-900/70 ring-1 ring-gray-300/60 dark:ring-gray-700/60 shadow-md"
aria-label="Previous recommendation" @click="goToPrev" />
<UButton icon="i-lucide-chevron-right" variant="soft" color="gray" size="sm"
class="pointer-events-auto rounded-full bg-white/90 dark:bg-gray-900/70 ring-1 ring-gray-300/60 dark:ring-gray-700/60 shadow-md"
aria-label="Next recommendation" @click="goToNext" />
</div> -->
</div>
</template>
<template #fallback>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="placeholder in 3" :key="placeholder"
class="h-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white/80 dark:bg-gray-800/40 p-6 space-y-4 animate-pulse">
<div class="h-4 w-24 rounded bg-gray-200/80 dark:bg-gray-700/60" />
<div class="h-3 w-full rounded bg-gray-200/70 dark:bg-gray-700/50" />
<div class="h-3 w-3/4 rounded bg-gray-200/70 dark:bg-gray-700/50" />
<div class="h-3 w-2/4 rounded bg-gray-200/70 dark:bg-gray-700/50" />
<div class="pt-3 border-t border-gray-200 dark:border-gray-700 flex gap-2">
<div class="h-8 flex-1 rounded bg-gray-200/70 dark:bg-gray-700/50" />
<div class="h-8 flex-1 rounded bg-gray-200/70 dark:bg-gray-700/50" />
</div>
</div>
</div>
</template>
</ClientOnly>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { recommendations as recs } from '@/data/recommendations'
import { useSocialText } from '@/composables/useSocialText'
import { useElementVisibility, usePreferredReducedMotion } from '@vueuse/core'
const { t, locale } = useI18n()
const rtl = computed(() => locale.value === 'fa')
const { linkedinText } = useSocialText()
const sectionEl = ref<HTMLElement | null>(null)
const hasEntered = ref(false)
const reduceMotion = import.meta.client ? usePreferredReducedMotion() : ref<'no-preference'>('no-preference')
const carouselUi = {
root: 'relative overflow-hidden pb-12 select-none',
viewport: 'overflow-visible',
container: 'py-2 !ms-0 gap-3 ps-3 sm:ps-4 pe-3 sm:pe-4 items-stretch',
item: 'basis-full sm:basis-1/2 lg:basis-1/3 flex',
/* Position left/right, centered vertically, above content */
prev: 'hidden',
next: 'hidden',
/* Ensure buttons are always visible and touch-friendly on all sizes */
prevButton: 'opacity-100 pointer-events-auto flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-white/90 dark:bg-gray-900/70 ring-1 ring-gray-300/60 dark:ring-gray-700/60 shadow-md hover:bg-white dark:hover:bg-gray-900 transition',
nextButton: 'opacity-100 pointer-events-auto flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-white/90 dark:bg-gray-900/70 ring-1 ring-gray-300/60 dark:ring-gray-700/60 shadow-md hover:bg-white dark:hover:bg-gray-900 transition',
dots: '!static mt-6 flex justify-center gap-2'
} as const
type CarouselInstance = {
emblaApi?: {
scrollPrev: () => void
scrollNext: () => void
}
}
const carouselRef = ref<CarouselInstance | null>(null)
if (import.meta.client) {
const visibility = useElementVisibility(sectionEl, { threshold: 0.25 })
watch(
visibility,
(visible) => {
if (visible) {
hasEntered.value = true
}
},
{ immediate: true }
)
}
const shouldRenderCarousel = computed(() => import.meta.client && hasEntered.value)
const autoScrollOptions = computed(() => {
if (!import.meta.client || reduceMotion.value === 'reduce') {
return false
}
return {
speed: rtl.value ? -0.55 : 0.55,
stopOnInteraction: true,
stopOnMouseEnter: true
}
})
const carouselBreakpoints = computed<Record<string, any>>(() => {
return rtl.value
? { '(min-width: 0px)': { direction: 'rtl' } }
: { '(min-width: 0px)': { direction: 'ltr' } }
})
watch([rtl, shouldRenderCarousel], async ([isRtl, canMount]) => {
if (!import.meta.client || !canMount) return
await nextTick()
const api = carouselRef.value?.emblaApi as any
if (api && typeof api.reInit === 'function') {
api.reInit({ direction: isRtl ? 'rtl' : 'ltr' })
}
}, { immediate: true })
const formatDate = (d: string) => {
try {
const dt = new Date(d)
const opts: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short' }
return new Intl.DateTimeFormat(locale.value === 'fa' ? 'fa-IR' : 'en-US', opts).format(dt)
} catch {
return d
}
}
const goToPrev = () => carouselRef.value?.emblaApi?.scrollPrev()
const goToNext = () => carouselRef.value?.emblaApi?.scrollNext()
</script>
+170
View File
@@ -0,0 +1,170 @@
<template>
<div
class="ms-auto w-full md:w-auto flex flex-row-reverse items-center gap-1 overflow-x-auto no-scrollbar whitespace-nowrap px-1 py-1"
role="toolbar" aria-label="Skill filters">
<UButton :icon="filterButtonIcon" size="xs" :variant="filterButtonVariant" :color="filterButtonColor"
class="filter-toggle rounded-full shrink-0 mx-1"
:class="{ 'is-open': filtersOpen, 'has-selection': hasSelection }" :aria-label="filterButtonLabel"
:aria-expanded="hasSelection ? undefined : filtersOpen" aria-controls="skill-filter-group"
@click="handlePrimaryAction" />
<Transition name="filter-panel">
<div v-if="filtersOpen" id="skill-filter-group" class="flex items-center gap-1">
<TransitionGroup name="filter-chip" tag="div" class="flex flex-row-reverse items-center gap-1">
<div v-for="(type, index) in resolvedOptions" :key="type" class="filter-chip"
:style="transitionVars(index, totalVisible)">
<UTooltip :text="meta(type).label" :arrow="true" :delay-duration="0.5"
:content="{ side: 'bottom', sideOffset: 8, collisionPadding: 12 }">
<UButton :icon="meta(type).icon" size="xs" :variant="isSelected(type) ? 'solid' : 'soft'" color="primary"
class="rounded-full shrink-0" :aria-pressed="isSelected(type)"
:aria-label="`Filter by ${meta(type).label}`" :title="meta(type).label" @click="toggle(type)" />
</UTooltip>
</div>
</TransitionGroup>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { SkillType } from '@/types/portfolio.types'
import { SKILL_TYPES } from '@/types/portfolio.types'
// Map each SkillType to an icon and label for tooltips
const TYPE_META: Record<SkillType, { icon: string; label: string }> = {
Language: { icon: 'i-mdi-code-tags', label: 'Language' },
Framework: { icon: 'i-mdi-layers', label: 'Framework' },
Library: { icon: 'i-mdi-book-outline', label: 'Library' },
Tool: { icon: 'i-mdi-wrench', label: 'Tool' },
Service: { icon: 'i-mdi-cloud-outline', label: 'Service' },
DevOps: { icon: 'i-mdi-cogs', label: 'DevOps' },
Testing: { icon: 'i-mdi-flask-outline', label: 'Testing' },
Data: { icon: 'i-mdi-database-outline', label: 'Data' }
}
const meta = (type: SkillType) => TYPE_META[type]
const props = defineProps<{
modelValue: SkillType[]
options?: readonly SkillType[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: SkillType[]): void
}>()
const resolvedOptions = computed(() => props.options ?? SKILL_TYPES)
const hasSelection = computed(() => (props.modelValue?.length ?? 0) > 0)
const filtersOpen = ref(true)
const totalVisible = computed(() => resolvedOptions.value.length)
const isSelected = (type: SkillType) => props.modelValue?.includes(type)
const toggle = (type: SkillType) => {
const current = props.modelValue ?? []
if (current.includes(type)) {
emit('update:modelValue', current.filter(t => t !== type))
} else {
emit('update:modelValue', [...current, type])
}
}
const clear = () => {
emit('update:modelValue', [])
filtersOpen.value = false
}
const handlePrimaryAction = () => {
if (hasSelection.value) {
clear()
return
}
filtersOpen.value = !filtersOpen.value
}
watch(
hasSelection,
value => {
if (value) {
filtersOpen.value = true
}
}
)
const filterButtonIcon = computed(() =>
hasSelection.value ? 'i-mdi-filter-remove' : 'i-mdi-filter-variant'
)
const filterButtonLabel = computed(() =>
hasSelection.value
? 'Clear skill filters'
: filtersOpen.value
? 'Hide skill filters'
: 'Show skill filters'
)
const filterButtonVariant = computed(() =>
hasSelection.value ? 'solid' : filtersOpen.value ? 'soft' : 'ghost'
)
const filterButtonColor = computed(() =>
hasSelection.value ? 'error' : 'neutral'
)
const transitionVars = (index: number, total: number) =>
({
'--filter-index': `${index}`,
'--filter-order': `${Math.max(total - index, 0)}`
}) as Record<string, string>
</script>
<style scoped>
/* On small screens show a single horizontal row with scroll.
On md+ the header already has enough space and this container is auto-sized. */
.filter-toggle {
transition: transform 200ms ease, box-shadow 220ms ease, filter 220ms ease;
}
.filter-toggle.has-selection {
box-shadow: 0 12px 28px -12px rgba(220, 38, 38, 0.45);
}
.filter-toggle.is-open:not(.has-selection) :deep([class*='i-mdi']) {
transform: rotate(180deg);
transition: transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.filter-panel-enter-active,
.filter-panel-leave-active {
transition: opacity 200ms ease, transform 240ms cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: right center;
}
.filter-panel-enter-from,
.filter-panel-leave-to {
opacity: 0;
transform: translateX(0.75rem) scale(0.92);
}
.filter-chip-enter-active,
.filter-chip-leave-active {
transition: opacity 200ms ease, transform 260ms cubic-bezier(0.34, 1.56, 0.64, 1);
transform-origin: right center;
}
.filter-chip-enter-active {
transition-delay: calc(var(--filter-index, 0) * 45ms);
}
.filter-chip-leave-active {
transition-delay: calc(var(--filter-order, 0) * 35ms);
}
.filter-chip-enter-from,
.filter-chip-leave-to {
opacity: 0;
transform: translateX(0.5rem) scale(0.85);
}
.filter-chip-move {
transition: transform 200ms ease;
}
</style>
+33
View File
@@ -0,0 +1,33 @@
<template>
<div class="flex flex-wrap gap-1.5">
<component
v-for="item in items"
:key="item.label"
:is="item.to ? 'a' : 'span'"
:href="item.to || undefined"
target="_blank"
rel="noopener noreferrer"
class="inline-block"
:aria-label="item.to ? `Open ${item.label}` : undefined"
>
<UBadge
variant="soft"
:class="[
'chip-base',
item.to ? 'cursor-pointer transition-colors hover:bg-primary-500/20 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 dark:hover:bg-primary-400/10' : ''
]"
>
<span class="inline-flex items-center gap-1.5">
<UIcon v-if="item.icon" :name="item.icon" class="h-4 w-4 min-h-4 min-w-4 text-base" />
<span class="text-xs">{{ item.label }}</span>
</span>
</UBadge>
</component>
</div>
</template>
<script setup lang="ts">
import type { Tag } from '@/types/portfolio.types'
defineProps<{ items: Tag[] }>()
</script>
+70
View File
@@ -0,0 +1,70 @@
<template>
<section id="skills" class="py-6 scroll-mt-20">
<UContainer>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4">
<div class="flex items-center gap-3">
<UIcon name="i-twemoji-hammer-and-wrench" class="text-2xl" />
<h2 class="text-lg font-semibold">{{ t('sections.skills') }}</h2>
</div>
<SkillFilters v-model="selectedTypes" />
</div>
<div class="grid gap-4 md:grid-cols-3">
<UCard>
<template #header>
<h3 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{{
t('skills.expert') }}</h3>
</template>
<SkillGrid :items="filteredExpert" />
</UCard>
<UCard>
<template #header>
<h3 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{{
t('skills.proficient') }}</h3>
</template>
<SkillGrid :items="filteredProficient" />
</UCard>
<UCard>
<template #header>
<h3 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{{
t('skills.usedBefore') }}</h3>
</template>
<SkillGrid :items="filteredUsedBefore" />
</UCard>
<UCard v-if="showAiStack" class="md:col-span-3">
<template #header>
<h3 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{{
t('skills.aiStack') }}</h3>
</template>
<SkillGrid :items="aiStack" />
</UCard>
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Tag, SkillType } from '@/types/portfolio.types'
import { expert, proficient, usedBefore, aiStack } from '@/data/skills'
import SkillGrid from '@/components/portfolio/SkillGrid.vue'
import SkillFilters from '@/components/portfolio/SkillFilters.vue'
const { t } = useI18n()
// 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 showAiStack = false
</script>
+63
View File
@@ -0,0 +1,63 @@
<template>
<section class="py-6">
<UContainer>
<div class="flex items-center gap-3 mb-4">
<UIcon name="i-twemoji-brain" class="text-2xl" />
<h2 class="text-lg font-semibold">{{ t('sections.softSkills') }}</h2>
</div>
<UCard>
<div class="flex flex-wrap gap-1.5">
<template v-for="(s, i) in resolved" :key="s.key">
<UTooltip :arrow="true" :delay-duration="0.5" :text="s.description">
<UBadge variant="soft" class="chip-base select-none" :class="chipClass(i)">
<span class="inline-flex items-center gap-1.5">
<UIcon :name="s.icon" class="text-sm" />
<span class="text-xs">{{ s.label }}</span>
</span>
</UBadge>
</UTooltip>
</template>
</div>
</UCard>
</UContainer>
</section>
</template>
<script setup lang="ts">
type SoftKey =
| 'problemSolving'
| 'attentionToDetail'
| 'teamwork'
| 'teamLeadership'
| 'communication'
| 'teamPerformance'
| 'customerFocus'
| 'projectManagement'
| 'adaptability'
import { chipTones } from '@/utils/chipTones'
const base: { key: SoftKey; icon: string }[] = [
{ key: 'problemSolving', icon: 'i-twemoji-light-bulb' },
{ key: 'attentionToDetail', icon: 'i-twemoji-magnifying-glass-tilted-left' },
{ key: 'teamwork', icon: 'i-twemoji-people-holding-hands' },
{ key: 'teamLeadership', icon: 'i-twemoji-crown' },
{ key: 'communication', icon: 'i-twemoji-speech-balloon' },
{ key: 'teamPerformance', icon: 'i-twemoji-chart-increasing' },
{ key: 'customerFocus', icon: 'i-twemoji-handshake' },
{ key: 'projectManagement', icon: 'i-twemoji-spiral-calendar' },
{ key: 'adaptability', icon: 'i-twemoji-counterclockwise-arrows-button' },
]
const { t } = useI18n()
const resolved = computed(() =>
base.map(s => ({
...s,
label: t(`softSkills.${s.key}.label`),
description: t(`softSkills.${s.key}.desc`),
}))
)
const chipClass = (i: number) => chipTones[i % chipTones.length]
</script>
+48
View File
@@ -0,0 +1,48 @@
<template>
<UCard>
<template #header>
<div class="flex items-center gap-2">
<UIcon v-if="iconName" :name="iconName" class="text-lg" />
<h3 class="text-sm font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{{ title ||
group.title }}</h3>
</div>
</template>
<div class="flex flex-wrap gap-2">
<template v-for="(item, idx) in group.items" :key="idx">
<ClientTooltip v-if="item.description" :text="item.description">
<UBadge variant="soft" class="chip-base cursor-default"
:class="colorful ? chipClass(idx) : neutralChipTone">
<span class="inline-flex items-center gap-1.5">
<UIcon v-if="item.icon" :name="item.icon" class="text-base sm:text-lg" />
{{ item.label }}
</span>
</UBadge>
</ClientTooltip>
<UBadge v-else variant="soft" class="chip-base cursor-default"
:class="colorful ? chipClass(idx) : neutralChipTone">
<span class="inline-flex items-center gap-1.5">
<UIcon v-if="item.icon" :name="item.icon" class="text-base sm:text-lg" />
{{ item.label }}
</span>
</UBadge>
</template>
</div>
</UCard>
</template>
<script setup lang="ts">
import type { TagGroup } from '@/types/portfolio.types'
import { chipTones, neutralChipTone } from '@/utils/chipTones'
const props = defineProps<{ group: TagGroup; title?: string; colorful?: boolean; icon?: string }>()
const iconName = computed(() => {
if (props.icon) return props.icon
const title = (props.title || props.group.title).toLowerCase()
if (title.includes('main') && title.includes('tool')) return 'i-twemoji-toolbox'
if (title.includes('role')) return 'i-twemoji-briefcase'
if (title.includes('value')) return 'i-twemoji-sparkles'
return ''
})
const chipClass = (idx: number) => chipTones[idx % chipTones.length]
</script>
+19
View File
@@ -0,0 +1,19 @@
<template>
<section class="py-4">
<UContainer>
<div class="grid gap-4 md:grid-cols-3">
<TagGroupCard :group="portfolio.mainTools" :title="t('sections.mainTools')" />
<TagGroupCard :group="portfolio.roles" :title="t('sections.roles')" />
<TagGroupCard :group="portfolio.values" :title="t('sections.values')" />
</div>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { usePortfolio } from '@/composables/usePortfolio'
import TagGroupCard from '@/components/portfolio/TagGroupCard.vue'
const portfolio = usePortfolio()
const { t } = useI18n()
</script>
+124
View File
@@ -0,0 +1,124 @@
<template>
<section id="work" class="py-6 scroll-mt-20">
<UContainer>
<div class="flex items-center gap-3 mb-6">
<UIcon name="i-twemoji-briefcase" class="text-2xl" />
<h2 class="text-lg font-semibold">{{ t('sections.work') }}</h2>
</div>
<UTimeline :items="experiences" :default-value="0" color="primary" size="md" class="max-w-3xl">
<template #indicator="{ item }">
<img v-if="item.logo" :src="item.logo" :alt="`${item.company} logo`"
class="h-10 w-10 object-contain" loading="lazy" />
</template>
<template #title="{ item }">
<div class="flex flex-col gap-1">
<span class="font-semibold">{{ item.title }}</span>
<span class="text-sm text-gray-600 dark:text-gray-400">{{ item.company }}</span>
</div>
</template>
<template #description="{ item }">
<ul v-if="item.descriptions?.length"
class="mt-2 list-disc space-y-1 text-sm text-gray-700 dark:text-gray-300 pl-5">
<li v-for="(desc, i) in item.descriptions" :key="i">{{ desc }}</li>
</ul>
<div v-if="item.icons?.length" class="mt-3 flex flex-wrap gap-2">
<UIcon v-for="(icon, i) in item.icons" :key="i" :name="icon"
class="text-xl text-primary-500 dark:text-primary-400" />
</div>
</template>
</UTimeline>
</UContainer>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { usePortfolio } from '@/composables/usePortfolio'
import type { TimelineItem } from '@nuxt/ui'
import type { CompanyExperience, Experience } from '@/types/portfolio.types'
const { t } = useI18n()
const portfolio = usePortfolio()
const presentText = computed(() => t('common.present'))
type RichTimelineItem = TimelineItem & {
company: string
descriptions?: string[]
icons?: string[]
logo?: string
}
const experiences = computed<RichTimelineItem[]>(() => {
const list = portfolio.value.experiences as Array<CompanyExperience | Experience>
const items: RichTimelineItem[] = []
list.forEach((exp: any, index: number) => {
if (Array.isArray(exp.positions)) {
// CompanyExperience with multiple positions
const company = exp as CompanyExperience
company.positions.forEach((pos, posIndex) => {
items.push({
date: `${pos.start}${pos.ongoing ? ` - ${presentText.value}` : pos.end ? ` - ${pos.end}` : ''}`,
title: pos.title,
company: company.company,
descriptions: pos.description,
icons: pos.icons,
icon: getIconForRole(pos.title),
logo: pos.logo || company.logo || getCompanyLogo(company.company),
value: `${index}-${posIndex}`
})
})
} else {
// Single Experience
const single = exp as Experience
items.push({
date: `${single.start}${single.ongoing ? ` - ${presentText.value}` : single.end ? ` - ${single.end}` : ''}`,
title: single.role,
company: single.company,
descriptions: single.description,
icons: single.icons,
icon: getIconForRole(single.role),
logo: single.logo || getCompanyLogo(single.company),
value: index
})
}
})
return items
})
function getCompanyLogo(name: string): string | undefined {
const lower = name.toLowerCase()
if (lower.includes('huawei')) return '/img/huawei.svg'
if (lower.includes('nexaportal')) return '/img/NexaPortal1.png'
return undefined
}
function getIconForRole(position: string): string {
const role = position.toLowerCase()
if (role.includes('senior') || role.includes('lead')) {
return 'i-twemoji-crown'
}
if (role.includes('frontend') || role.includes('front-end')) {
return 'i-twemoji-laptop'
}
if (role.includes('backend') || role.includes('back-end')) {
return 'i-twemoji-server'
}
if (role.includes('fullstack') || role.includes('full-stack')) {
return 'i-twemoji-gear'
}
if (role.includes('developer') || role.includes('engineer')) {
return 'i-twemoji-technologist'
}
if (role.includes('designer')) {
return 'i-twemoji-artist-palette'
}
if (role.includes('manager')) {
return 'i-twemoji-people-holding-hands'
}
return 'i-twemoji-briefcase'
}
</script>