mirror of
https://github.com/mmahdium/portfolio.git
synced 2026-09-30 11:31:42 +03:30
Initial commit (secrets removed)
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>© {{ 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user