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