mirror of
https://github.com/mmahdium/portfolio.git
synced 2026-09-30 11:31:42 +03:30
implement ssg config for blog posts in project ,
This commit is contained in:
@@ -3,23 +3,22 @@
|
||||
<!-- 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]"
|
||||
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] hover:ring-primary-500/50 hover:shadow-md transition-all duration-200"
|
||||
: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">
|
||||
content: 'min-w-fit scale-fade-in'
|
||||
}" :aria-label="t('nav.languageSelector')">
|
||||
|
||||
<!-- Leading icon in trigger (already provided by :icon via selectedIcon) -->
|
||||
<template #leading="{ ui }">
|
||||
<!-- Leading icon in trigger -->
|
||||
<template #leading>
|
||||
<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>
|
||||
<!-- <span>{{ item.label }}</span> -->
|
||||
</template>
|
||||
</USelect>
|
||||
</ClientOnly>
|
||||
@@ -29,7 +28,9 @@
|
||||
import { ref, computed, watch } from '#imports'
|
||||
import { useLocaleSwitching, useLoadingIndicator } from '#imports'
|
||||
|
||||
const { locale, setLocale } = useI18n()
|
||||
const { locale, setLocale, t } = useI18n()
|
||||
const switchLocalePath = useSwitchLocalePath()
|
||||
const router = useRouter()
|
||||
|
||||
type LangValue = 'en' | 'fa'
|
||||
type Item = { label: string; value: LangValue; icon: string }
|
||||
@@ -53,14 +54,40 @@ const selectedIcon = computed<string>(() => items.value.find(i => i.value === mo
|
||||
const { startLocaleSwitching } = useLocaleSwitching()
|
||||
const loading = useLoadingIndicator()
|
||||
|
||||
// On selection change, run visual feedback and update i18n
|
||||
watch(model, (val, oldVal) => {
|
||||
// On selection change, update locale and navigate
|
||||
watch(model, async (val, oldVal) => {
|
||||
if (val === oldVal) return
|
||||
|
||||
// Preserve scroll position
|
||||
const scrollY = window.scrollY
|
||||
|
||||
startLocaleSwitching(600)
|
||||
if (loading) {
|
||||
loading.start()
|
||||
}
|
||||
|
||||
// Update locale
|
||||
await setLocale(val)
|
||||
|
||||
// Get the current route path without locale prefix
|
||||
const currentPath = router.currentRoute.value.path
|
||||
const pathWithoutLocale = currentPath.replace(/^\/(en|fa)/, '')
|
||||
|
||||
// Build new path with new locale
|
||||
const newLocalePrefix = val === 'en' ? '' : `/${val}`
|
||||
const newPath = `${newLocalePrefix}${pathWithoutLocale || '/'}`
|
||||
|
||||
// Navigate to new path
|
||||
if (newPath !== currentPath) {
|
||||
await router.push(newPath)
|
||||
}
|
||||
|
||||
// Restore scroll position after navigation
|
||||
await nextTick()
|
||||
window.scrollTo(0, scrollY)
|
||||
|
||||
if (loading) {
|
||||
setTimeout(() => loading.finish(), 600)
|
||||
}
|
||||
setLocale(val)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -59,7 +59,7 @@ const handleImageError = () => {
|
||||
|
||||
// Convert collection path to route path (remove locale prefix)
|
||||
const getRoutePath = (path: string) => {
|
||||
// Remove locale prefix from path: /en/blog/... -> /blog/...
|
||||
return path.replace(`/${locale.value}`, '')
|
||||
// Remove any locale prefix from path: /en/blog/... or /fa/blog/... -> /blog/...
|
||||
return path.replace(/^\/(en|fa)/, '')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,8 +12,8 @@ const router = useRouter()
|
||||
|
||||
// Convert collection path to route path (remove locale prefix)
|
||||
const getRoutePath = (path: string) => {
|
||||
// Remove locale prefix from path: /en/blog/... -> /blog/...
|
||||
return path.replace(`/${locale.value}`, '')
|
||||
// Remove any locale prefix from path: /en/blog/... or /fa/blog/... -> /blog/...
|
||||
return path.replace(/^\/(en|fa)/, '')
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
@@ -39,9 +39,11 @@ onUnmounted(() => {
|
||||
<!-- Previous Post -->
|
||||
<div class="flex-1">
|
||||
<NuxtLink v-if="prev" :to="localePath(getRoutePath((prev as any).path))" class="group block">
|
||||
<UButton color="neutral" variant="ghost" size="lg" class="w-full justify-start">
|
||||
<UButton color="neutral" variant="ghost" size="lg"
|
||||
class="w-full justify-start hover:scale-105 transition-transform duration-200">
|
||||
<template #leading>
|
||||
<UIcon name="i-heroicons-arrow-left" class="w-5 h-5" />
|
||||
<UIcon name="i-heroicons-arrow-left"
|
||||
class="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" />
|
||||
</template>
|
||||
<div class="text-left">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
@@ -59,7 +61,8 @@ onUnmounted(() => {
|
||||
<!-- Next Post -->
|
||||
<div class="flex-1">
|
||||
<NuxtLink v-if="next" :to="localePath(getRoutePath((next as any).path))" class="group block">
|
||||
<UButton color="neutral" variant="ghost" size="lg" class="w-full justify-end">
|
||||
<UButton color="neutral" variant="ghost" size="lg"
|
||||
class="w-full justify-end hover:scale-105 transition-transform duration-200">
|
||||
<div class="text-right">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||
{{ t('blog.nextPost') }}
|
||||
@@ -70,7 +73,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<template #trailing>
|
||||
<UIcon name="i-heroicons-arrow-right" class="w-5 h-5" />
|
||||
<UIcon name="i-heroicons-arrow-right"
|
||||
class="w-5 h-5 group-hover:translate-x-1 transition-transform duration-200" />
|
||||
</template>
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
<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" />
|
||||
<ClientOnly>
|
||||
<NuxtImg :src="logoSrc" alt="Ali Arghyani logo" width="64" height="64" class="h-12 w-12" format="png"
|
||||
loading="lazy" />
|
||||
<template #fallback>
|
||||
<NuxtImg src="/favicon/android-chrome-192x192.png" alt="Ali Arghyani logo" width="64" height="64"
|
||||
class="h-12 w-12" format="png" loading="lazy" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
<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"
|
||||
@@ -24,9 +30,6 @@ 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'
|
||||
|
||||
@@ -1,94 +1,93 @@
|
||||
<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>
|
||||
<nav class="fixed inset-x-0 top-0 z-50 pointer-events-auto transition-transform duration-300" 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 transition-all duration-300">
|
||||
<div class="flex items-center justify-between px-2 py-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Home -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('nav.home')">
|
||||
<UButton class="cursor-pointer transition-all duration-200"
|
||||
:class="[isActive('hero') ? activeClass : inactiveClass]" variant="soft" square icon="i-twemoji-house"
|
||||
:aria-label="t('nav.home')" @click="goTo('hero')" />
|
||||
</UTooltip>
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
|
||||
: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>
|
||||
<!-- Skills -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.skills')">
|
||||
<UButton class="cursor-pointer transition-all duration-200"
|
||||
:class="[isActive('skills') ? activeClass : inactiveClass]" variant="soft" square
|
||||
icon="i-twemoji-hammer-and-wrench" :aria-label="t('sections.skills')" @click="goTo('skills')" />
|
||||
</UTooltip>
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
|
||||
:class="[isActive('skills') ? labelActiveClass : labelInactiveClass]" @click="goTo('skills')">
|
||||
{{ t('sections.skills') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Work -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.work')">
|
||||
<UButton class="cursor-pointer" :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>
|
||||
<!-- Work -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.work')">
|
||||
<UButton class="cursor-pointer transition-all duration-200"
|
||||
:class="[isActive('work') ? activeClass : inactiveClass]" variant="soft" square
|
||||
icon="i-twemoji-briefcase" :aria-label="t('sections.work')" @click="goTo('work')" />
|
||||
</UTooltip>
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
|
||||
:class="[isActive('work') ? labelActiveClass : labelInactiveClass]" @click="goTo('work')">
|
||||
{{ t('sections.work') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Projects -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.projects')">
|
||||
<UButton class="cursor-pointer" :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>
|
||||
<!-- Projects -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.projects')">
|
||||
<UButton class="cursor-pointer transition-all duration-200"
|
||||
:class="[isActive('projects') ? activeClass : inactiveClass]" variant="soft" square
|
||||
icon="i-twemoji-rocket" :aria-label="t('sections.projects')" @click="goTo('projects')" />
|
||||
</UTooltip>
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
|
||||
:class="[isActive('projects') ? labelActiveClass : labelInactiveClass]" @click="goTo('projects')">
|
||||
{{ t('sections.projects') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Blog -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.blog')">
|
||||
<NuxtLink :to="localePath('/blog')">
|
||||
<UButton class="cursor-pointer" :class="[isBlogActive ? activeClass : inactiveClass]" variant="soft"
|
||||
square icon="i-twemoji-memo" :aria-label="t('sections.blog')" />
|
||||
</NuxtLink>
|
||||
</UTooltip>
|
||||
<!-- Blog -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UTooltip :text="t('sections.blog')">
|
||||
<NuxtLink :to="localePath('/blog')">
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors"
|
||||
:class="[isBlogActive ? labelActiveClass : labelInactiveClass]">
|
||||
{{ t('sections.blog') }}
|
||||
</button>
|
||||
<UButton class="cursor-pointer transition-all duration-200"
|
||||
:class="[isBlogActive ? activeClass : inactiveClass]" variant="soft" square icon="i-twemoji-memo"
|
||||
:aria-label="t('sections.blog')" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</UTooltip>
|
||||
<NuxtLink :to="localePath('/blog')">
|
||||
<button type="button" class="hidden lg:inline-flex text-sm font-medium transition-colors duration-200"
|
||||
:class="[isBlogActive ? labelActiveClass : labelInactiveClass]">
|
||||
{{ t('sections.blog') }}
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<ThemeCustomizer />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<ThemeCustomizer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</nav>
|
||||
</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()
|
||||
@@ -106,21 +105,80 @@ type Target = typeof sectionIds[number]
|
||||
const isHome = computed(() => route.path === localePath('/'))
|
||||
const isBlogActive = computed(() => route.path.includes('/blog'))
|
||||
|
||||
const { activeSection, scrollToSection } = useSectionObserver({
|
||||
ids: [...sectionIds] as SectionId[],
|
||||
headerSelector: 'nav[data-section-header]',
|
||||
offset: 80,
|
||||
enabled: isHome
|
||||
// Active section tracking (client-side only)
|
||||
const activeSection = ref<Target | null>(null)
|
||||
const isMounted = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true
|
||||
|
||||
// Only setup intersection observer on homepage
|
||||
if (!isHome.value) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const id = entry.target.id as Target
|
||||
if (sectionIds.includes(id)) {
|
||||
activeSection.value = id
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
rootMargin: '-80px 0px -80% 0px',
|
||||
threshold: 0
|
||||
}
|
||||
)
|
||||
|
||||
// Observe all sections
|
||||
sectionIds.forEach((id) => {
|
||||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
observer.observe(element)
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup
|
||||
onUnmounted(() => {
|
||||
observer.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
const isActive = (id: Target) => activeSection.value === id
|
||||
const isActive = (id: Target) => {
|
||||
// During SSR or before mount, no section is active
|
||||
if (!isMounted.value) return false
|
||||
return activeSection.value === id
|
||||
}
|
||||
|
||||
function scrollToSection(id: Target) {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
const headerOffset = 80
|
||||
const elementPosition = element.getBoundingClientRect().top
|
||||
const offsetPosition = elementPosition + window.pageYOffset - headerOffset
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function goTo(id: Target) {
|
||||
const homePath = localePath('/')
|
||||
if (route.path !== homePath) {
|
||||
await router.push(homePath)
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => scrollToSection(id))
|
||||
// Wait for next frame to ensure DOM is ready
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
requestAnimationFrame(() => scrollToSection(id))
|
||||
} else {
|
||||
scrollToSection(id)
|
||||
}
|
||||
} else {
|
||||
scrollToSection(id)
|
||||
}
|
||||
|
||||
@@ -50,41 +50,151 @@ const languageLabel = computed(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-block-wrapper {
|
||||
position: relative;
|
||||
margin: 2rem 0;
|
||||
border-radius: 0.875rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
background: linear-gradient(to bottom, #1e293b, #0f172a);
|
||||
}
|
||||
|
||||
:global(.dark) .code-block-wrapper {
|
||||
border-color: rgba(71, 85, 105, 0.3);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.code-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-bottom: 1px solid rgba(71, 85, 105, 0.3);
|
||||
backdrop-filter: blur(8px);
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.code-filename {
|
||||
font-size: 0.9375rem;
|
||||
color: #e2e8f0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-language {
|
||||
font-size: 0.8125rem;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.code-block-content {
|
||||
position: relative;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.code-pre {
|
||||
overflow-x: auto;
|
||||
padding: 1.75rem 1.5rem;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.8;
|
||||
margin: 0;
|
||||
background: transparent !important;
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.code-pre::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.code-pre::-webkit-scrollbar-track {
|
||||
background: rgba(30, 41, 59, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-pre::-webkit-scrollbar-thumb {
|
||||
background: rgba(71, 85, 105, 0.8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-pre::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(100, 116, 139, 0.9);
|
||||
}
|
||||
|
||||
.code-copy-button {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
padding: 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(30, 41, 59, 0.95);
|
||||
border: 1px solid rgba(71, 85, 105, 0.5);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
color: #cbd5e1;
|
||||
backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.code-copy-button.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.code-copy-button:hover {
|
||||
background: rgba(51, 65, 85, 0.95);
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
color: #e2e8f0;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.code-copy-button.is-copied {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.code-copy-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div style="position: relative; margin: 1.5rem 0;" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
|
||||
<div class="code-block-wrapper" @mouseenter="isHovered = true" @mouseleave="isHovered = false">
|
||||
<!-- Header with filename and language -->
|
||||
<div v-if="filename || language"
|
||||
style="display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 1rem; background-color: #1f2937; border-bottom: 1px solid #374151; border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
||||
<span v-if="filename" style="font-size: 0.875rem; color: #d1d5db; font-family: monospace;">
|
||||
<div v-if="filename || language" class="code-block-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<span v-if="filename" class="code-filename">
|
||||
{{ filename }}
|
||||
</span>
|
||||
<span v-if="language && !filename"
|
||||
style="font-size: 0.75rem; color: #9ca3af; text-transform: uppercase; font-weight: 600;">
|
||||
<span v-if="language && !filename" class="code-language">
|
||||
{{ languageLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code block -->
|
||||
<div style="position: relative;">
|
||||
<pre :class="props.class" style="overflow-x: auto; padding: 1rem; font-size: 0.875rem; margin: 0;"><slot /></pre>
|
||||
<div class="code-block-content">
|
||||
<pre :class="props.class" class="code-pre"><slot /></pre>
|
||||
|
||||
<!-- Copy button -->
|
||||
<button type="button" :aria-label="copied ? t('blog.codeCopied') : t('blog.copyCode')" :style="{
|
||||
position: 'absolute',
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '0.375rem',
|
||||
backgroundColor: 'rgba(55, 65, 81, 0.5)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
opacity: isHovered || copied ? '1' : '0'
|
||||
}" @click="copyCode">
|
||||
<UIcon :name="copied ? 'i-heroicons-check' : 'i-heroicons-clipboard-document'"
|
||||
style="width: 1rem; height: 1rem; color: rgb(209, 213, 219);" />
|
||||
<button type="button" :aria-label="copied ? t('blog.codeCopied') : t('blog.copyCode')" class="code-copy-button"
|
||||
:class="{ 'is-visible': isHovered || copied, 'is-copied': copied }" @click="copyCode">
|
||||
<UIcon :name="copied ? 'i-heroicons-check' : 'i-heroicons-clipboard-document'" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user