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,27 @@
|
||||
import { useState } from '#imports'
|
||||
|
||||
export function useLocaleSwitching() {
|
||||
const isLocaleSwitching = useState<boolean>('is-locale-switching', () => false)
|
||||
|
||||
let timer: number | null = null
|
||||
|
||||
const startLocaleSwitching = (duration = 600) => {
|
||||
if (!import.meta.client) return
|
||||
isLocaleSwitching.value = true
|
||||
document.documentElement.classList.add('locale-switching')
|
||||
if (timer) {
|
||||
window.clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
timer = window.setTimeout(() => {
|
||||
isLocaleSwitching.value = false
|
||||
document.documentElement.classList.remove('locale-switching')
|
||||
timer = null
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return {
|
||||
isLocaleSwitching,
|
||||
startLocaleSwitching
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { PortfolioData } from '@/types/portfolio.types'
|
||||
import en from '@/data/portfolio.en'
|
||||
import fa from '@/data/portfolio.fa'
|
||||
|
||||
export function usePortfolio(): Ref<PortfolioData> {
|
||||
const { locale } = useI18n()
|
||||
return computed(() => (locale.value === 'fa' ? fa : en))
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { computed, isRef, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||
import { useEventListener, useIntersectionObserver } from '@vueuse/core'
|
||||
|
||||
const DEFAULT_SECTION_IDS = ['hero', 'skills', 'work', 'projects'] as const
|
||||
|
||||
export type SectionId = (typeof DEFAULT_SECTION_IDS)[number] | string
|
||||
|
||||
type EnabledSource = boolean | Ref<boolean> | (() => boolean)
|
||||
|
||||
export interface SectionObserverOptions {
|
||||
ids?: SectionId[]
|
||||
offset?: number
|
||||
headerSelector?: string
|
||||
enabled?: EnabledSource
|
||||
}
|
||||
|
||||
type ResolvedOptions = {
|
||||
ids: SectionId[]
|
||||
offset: number
|
||||
headerSelector?: string
|
||||
}
|
||||
|
||||
const activeState = () => useState<SectionId | null>('section-observer:active', () => null)
|
||||
const usersState = () => useState<number>('section-observer:users', () => 0)
|
||||
const manualScrollState = () => useState<boolean>('section-observer:manual', () => false)
|
||||
const optionsState = () =>
|
||||
useState<ResolvedOptions>('section-observer:options', () => ({
|
||||
ids: [...DEFAULT_SECTION_IDS],
|
||||
offset: 80,
|
||||
headerSelector: undefined
|
||||
}))
|
||||
|
||||
function computeOffset(headerSelector: string | undefined, fallback: number): number {
|
||||
if (!import.meta.client) return fallback
|
||||
if (!headerSelector) return fallback
|
||||
const el = document.querySelector<HTMLElement>(headerSelector)
|
||||
const height = el?.getBoundingClientRect().height
|
||||
return height && !Number.isNaN(height) ? Math.round(height) : fallback
|
||||
}
|
||||
|
||||
function updateActiveFromScroll(options: ResolvedOptions, active: Ref<SectionId | null>) {
|
||||
if (!import.meta.client) return
|
||||
const offset = computeOffset(options.headerSelector, options.offset)
|
||||
const topAnchor = offset + 8
|
||||
|
||||
let closest: { id: SectionId; distance: number } | null = null
|
||||
|
||||
for (const id of options.ids) {
|
||||
const el = document.getElementById(id)
|
||||
if (!el) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
const distance = Math.abs(rect.top - offset)
|
||||
const aboveTop = rect.top <= topAnchor
|
||||
const notPassed = rect.bottom > topAnchor
|
||||
if (aboveTop && notPassed) {
|
||||
if (!closest || distance < closest.distance) {
|
||||
closest = { id, distance }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!closest) {
|
||||
// Fallback: choose the first section below the offset when none intersect
|
||||
for (const id of options.ids) {
|
||||
const el = document.getElementById(id)
|
||||
if (!el) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.top >= offset - 40) {
|
||||
closest = { id, distance: Math.abs(rect.top - offset) }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closest) {
|
||||
active.value = closest.id
|
||||
}
|
||||
}
|
||||
|
||||
function refreshObserver(options: ResolvedOptions) {
|
||||
if (!import.meta.client) return
|
||||
const active = activeState()
|
||||
// Stop observing previous targets
|
||||
const disposables = useState<(() => void)[]>('section-observer:disposables', () => [])
|
||||
disposables.value.forEach((stop) => stop())
|
||||
disposables.value = []
|
||||
|
||||
const offset = computeOffset(options.headerSelector, options.offset)
|
||||
|
||||
options.ids.forEach((id) => {
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
const observer = useIntersectionObserver(
|
||||
el,
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
if (!entry) return
|
||||
if (entry.isIntersecting) {
|
||||
active.value = id
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: 0.1,
|
||||
rootMargin: `-${offset}px 0px -55% 0px`
|
||||
}
|
||||
)
|
||||
disposables.value.push(observer.stop)
|
||||
})
|
||||
|
||||
requestAnimationFrame(() => updateActiveFromScroll(options, active))
|
||||
}
|
||||
|
||||
function teardownObserver() {
|
||||
if (!import.meta.client) return
|
||||
const disposables = useState<(() => void)[]>('section-observer:disposables', () => [])
|
||||
disposables.value.forEach((stop) => stop())
|
||||
disposables.value = []
|
||||
}
|
||||
|
||||
export function useSectionObserver(options: SectionObserverOptions = {}) {
|
||||
const active = activeState()
|
||||
const users = usersState()
|
||||
const manual = manualScrollState()
|
||||
const storedOptions = optionsState()
|
||||
|
||||
const ids = computed<SectionId[]>(() => options.ids ?? storedOptions.value.ids ?? [...DEFAULT_SECTION_IDS])
|
||||
const offset = computed(() => options.offset ?? storedOptions.value.offset ?? 80)
|
||||
const headerSelector = computed(() => options.headerSelector ?? storedOptions.value.headerSelector)
|
||||
const resolveEnabled = (source?: EnabledSource): boolean => {
|
||||
if (isRef(source)) return !!source.value
|
||||
if (typeof source === 'function') return !!(source as () => unknown)()
|
||||
return (source ?? true) === true
|
||||
}
|
||||
|
||||
const enabled = computed(() => resolveEnabled(options.enabled))
|
||||
|
||||
const localEnabled = ref(false)
|
||||
|
||||
const scrollStop = ref<(() => void) | null>(null)
|
||||
|
||||
function setup() {
|
||||
if (!import.meta.client) return
|
||||
storedOptions.value = {
|
||||
ids: [...ids.value],
|
||||
offset: offset.value,
|
||||
headerSelector: headerSelector.value
|
||||
}
|
||||
refreshObserver(storedOptions.value)
|
||||
scrollStop.value = useEventListener(
|
||||
window,
|
||||
'scroll',
|
||||
() => {
|
||||
if (manual.value) return
|
||||
requestAnimationFrame(() => updateActiveFromScroll(optionsState().value, activeState()))
|
||||
},
|
||||
{ passive: true }
|
||||
)
|
||||
}
|
||||
|
||||
function disable() {
|
||||
if (!import.meta.client) return
|
||||
teardownObserver()
|
||||
active.value = null
|
||||
scrollStop.value?.()
|
||||
scrollStop.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
watch(
|
||||
[enabled, ids, offset, headerSelector],
|
||||
([isEnabled]) => {
|
||||
if (!import.meta.client) return
|
||||
if (isEnabled) {
|
||||
if (!localEnabled.value) {
|
||||
localEnabled.value = true
|
||||
if (users.value === 0) {
|
||||
setup()
|
||||
} else {
|
||||
storedOptions.value = {
|
||||
ids: [...ids.value],
|
||||
offset: offset.value,
|
||||
headerSelector: headerSelector.value
|
||||
}
|
||||
refreshObserver(storedOptions.value)
|
||||
}
|
||||
users.value += 1
|
||||
} else {
|
||||
storedOptions.value = {
|
||||
ids: [...ids.value],
|
||||
offset: offset.value,
|
||||
headerSelector: headerSelector.value
|
||||
}
|
||||
refreshObserver(storedOptions.value)
|
||||
}
|
||||
} else if (localEnabled.value) {
|
||||
localEnabled.value = false
|
||||
users.value = Math.max(0, users.value - 1)
|
||||
if (users.value === 0) {
|
||||
disable()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!import.meta.client) return
|
||||
if (localEnabled.value) {
|
||||
localEnabled.value = false
|
||||
users.value = Math.max(0, users.value - 1)
|
||||
if (users.value === 0) {
|
||||
disable()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function scrollToSection(id: SectionId, behavior: ScrollBehavior = 'smooth') {
|
||||
if (!import.meta.client) return
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
manual.value = true
|
||||
active.value = id
|
||||
el.scrollIntoView({ behavior, block: 'start', inline: 'nearest' })
|
||||
window.setTimeout(() => {
|
||||
manual.value = false
|
||||
updateActiveFromScroll(storedOptions.value, active)
|
||||
}, behavior === 'auto' ? 50 : 650)
|
||||
}
|
||||
|
||||
function setActive(id: SectionId | null) {
|
||||
active.value = id
|
||||
}
|
||||
|
||||
return {
|
||||
activeSection: active,
|
||||
scrollToSection,
|
||||
setActiveSection: setActive
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Social text utilities for localized, consistent labels.
|
||||
* Generates LinkedIn button labels like:
|
||||
* - en: "amir's linkedin" (lowercased first name with apostrophe)
|
||||
* - fa: "لینکدین امیر"
|
||||
*/
|
||||
|
||||
export function getFirstName(fullName: string): string {
|
||||
if (!fullName) return ''
|
||||
// Take first token, strip trailing punctuation and leading @
|
||||
const first = (fullName.trim().split(/\s+/)[0] || '')
|
||||
.replace(/[.,\-]+$/g, '')
|
||||
.replace(/^@/, '')
|
||||
|
||||
return first
|
||||
}
|
||||
|
||||
export function useSocialText() {
|
||||
// i18n is auto-imported by Nuxt (via #imports)
|
||||
const { locale } = useI18n()
|
||||
|
||||
/**
|
||||
* Build a localized LinkedIn label using first name.
|
||||
* - en: "{firstname}'s linkedin" all lowercase
|
||||
* - fa: "لینکدین {firstname}"
|
||||
*/
|
||||
const linkedinText = (author: string): string => {
|
||||
const first = getFirstName(author)
|
||||
if (locale.value === 'fa') {
|
||||
// Persian phrasing with provided first name as-is (data uses Latin names)
|
||||
return `لینکدین ${first}`
|
||||
}
|
||||
// English lowercased per spec and with apostrophe
|
||||
return `${first.toLowerCase()}'s linkedin`
|
||||
}
|
||||
|
||||
return {
|
||||
linkedinText
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* View Transitions ripple utility (best-practice).
|
||||
* - Uses document.startViewTransition when available
|
||||
* - Ripple originates from click position
|
||||
* - Duration defaults to 500ms, easing to 'ease-in-out'
|
||||
* - Respects prefers-reduced-motion
|
||||
* - Graceful fallback: runs update without animation
|
||||
*
|
||||
* Usage:
|
||||
* const { runRipple } = useViewTransitionRipple()
|
||||
* await runRipple(mouseEvent, () => { // change theme or primary here })
|
||||
*/
|
||||
|
||||
export type RippleOptions = {
|
||||
duration?: number
|
||||
easing?: string
|
||||
}
|
||||
|
||||
function isClient(): boolean {
|
||||
return typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||
}
|
||||
|
||||
function supportsViewTransitions(): boolean {
|
||||
return isClient() && 'startViewTransition' in document
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
if (!isClient()) return false
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the end radius needed to cover the viewport from an origin point.
|
||||
*/
|
||||
function computeEndRadius(x: number, y: number, w: number, h: number): number {
|
||||
const topLeft = Math.hypot(x, y)
|
||||
const topRight = Math.hypot(w - x, y)
|
||||
const bottomLeft = Math.hypot(x, h - y)
|
||||
const bottomRight = Math.hypot(w - x, h - y)
|
||||
return Math.max(topLeft, topRight, bottomLeft, bottomRight)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set CSS variables on :root to drive the ripple animation in CSS:
|
||||
* --vtx-x: origin X (px)
|
||||
* --vtx-y: origin Y (px)
|
||||
* --vtx-end: final circle radius (px)
|
||||
* --vtx-duration: animation duration (ms)
|
||||
* --vtx-easing: animation easing function
|
||||
*/
|
||||
function setRippleCSSVars(x: number, y: number, end: number, opts: Required<RippleOptions>) {
|
||||
const root = document.documentElement
|
||||
root.style.setProperty('--vtx-x', `${x}px`)
|
||||
root.style.setProperty('--vtx-y', `${y}px`)
|
||||
root.style.setProperty('--vtx-end', `${end}px`)
|
||||
root.style.setProperty('--vtx-duration', `${opts.duration}ms`)
|
||||
root.style.setProperty('--vtx-easing', opts.easing)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the CSS variables used by the ripple.
|
||||
*/
|
||||
function clearRippleCSSVars() {
|
||||
const root = document.documentElement
|
||||
root.style.removeProperty('--vtx-x')
|
||||
root.style.removeProperty('--vtx-y')
|
||||
root.style.removeProperty('--vtx-end')
|
||||
root.style.removeProperty('--vtx-duration')
|
||||
root.style.removeProperty('--vtx-easing')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the update inside a View Transition with a ripple.
|
||||
* If unsupported or reduced motion is preferred, runs update immediately.
|
||||
*/
|
||||
export function useViewTransitionRipple() {
|
||||
async function runRipple(ev: MouseEvent | null, update: () => void, options?: RippleOptions): Promise<void> {
|
||||
if (!isClient()) {
|
||||
update()
|
||||
return
|
||||
}
|
||||
|
||||
const duration = options?.duration ?? 500
|
||||
const easing = options?.easing ?? 'ease-in-out'
|
||||
const w = window.innerWidth
|
||||
const h = window.innerHeight
|
||||
const x = ev?.clientX ?? Math.floor(w / 2)
|
||||
const y = ev?.clientY ?? Math.floor(h / 2)
|
||||
const end = computeEndRadius(x, y, w, h)
|
||||
|
||||
// Fallback: simple fade if unsupported or reduced motion is preferred
|
||||
if (!supportsViewTransitions() || prefersReducedMotion()) {
|
||||
const root = document.documentElement
|
||||
// set vars for timing and easing used by CSS
|
||||
root.style.setProperty('--vtx-duration', `${duration}ms`)
|
||||
root.style.setProperty('--vtx-easing', easing)
|
||||
root.classList.add('vtx-fade')
|
||||
// Force reflow to ensure class applies before update
|
||||
void (root as any).offsetWidth
|
||||
update()
|
||||
requestAnimationFrame(() => {
|
||||
root.classList.remove('vtx-fade')
|
||||
// cleanup
|
||||
root.style.removeProperty('--vtx-duration')
|
||||
root.style.removeProperty('--vtx-easing')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare CSS vars for the ripple animation
|
||||
setRippleCSSVars(x, y, end, { duration, easing })
|
||||
|
||||
// Start the view transition and perform the update
|
||||
const transition = (document as any).startViewTransition(() => {
|
||||
update()
|
||||
})
|
||||
|
||||
// Wait for the transition to finish then cleanup
|
||||
try {
|
||||
await transition.finished
|
||||
} finally {
|
||||
clearRippleCSSVars()
|
||||
}
|
||||
}
|
||||
|
||||
async function runRippleFromCenter(update: () => void, options?: RippleOptions): Promise<void> {
|
||||
return runRipple(null, update, options)
|
||||
}
|
||||
|
||||
return {
|
||||
runRipple,
|
||||
runRippleFromCenter,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user