mirror of
https://github.com/ArthurDanjou/artchat.git
synced 2026-01-14 15:54:03 +01:00
feat(infinite-canvas): add infinite canvas component with drag and zoom functionality
- Implemented InfiniteCanvas.vue for rendering an infinite canvas with drag and zoom capabilities. - Created useInfiniteCanvas composable for managing canvas state and interactions. - Added useImagePreloader composable for preloading images and videos. - Introduced constants for physics, touch interactions, viewport settings, and zoom defaults. - Developed utility functions for touch handling and media type detection. - Defined TypeScript types for canvas items, grid items, and composables. - Registered components and composables in the Nuxt module. - Added screenshot generation functionality for content files. - Updated package.json to include capture-website dependency.
This commit is contained in:
@@ -45,10 +45,15 @@ defineShortcuts({
|
||||
t: () => toggleDark({ clientX: window.innerWidth / 2, clientY: window.innerHeight }),
|
||||
})
|
||||
|
||||
const isMobile = computed(() => {
|
||||
if (!import.meta.client)
|
||||
return false
|
||||
return isMobileDevice(navigator.userAgent, window.innerWidth)
|
||||
})
|
||||
const activeElement = useActiveElement()
|
||||
watch(openMessageModal, async () => {
|
||||
await nextTick()
|
||||
if (activeElement.value instanceof HTMLElement) {
|
||||
if (activeElement.value instanceof HTMLElement && isMobile.value) {
|
||||
activeElement.value.blur()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,12 +10,12 @@ const { t } = useI18n()
|
||||
</h1>
|
||||
<p class="text-center flex gap-0.5">
|
||||
{{ t('error.main') }}
|
||||
<NuxtLink href="/" class="sofia flex items-center group">
|
||||
<span class="duration-300 underline-offset-2 font-semibold text-md text-black dark:text-white underline decoration-gray-300 dark:decoration-neutral-700 group-hover:decoration-black dark:group-hover:decoration-white">
|
||||
{{ t('error.redirect') }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</p>
|
||||
<NuxtLink href="/" class="sofia flex items-center group">
|
||||
<span class="duration-300 underline-offset-2 font-semibold text-md text-black dark:text-white underline decoration-gray-300 dark:decoration-neutral-700 group-hover:decoration-black dark:group-hover:decoration-white">
|
||||
{{ t('error.redirect') }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,263 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CanvasItem } from '~~/modules/infinite-canvas/types'
|
||||
|
||||
const { data, error } = await useAsyncData(`canva`, async () => {
|
||||
const items: CanvasItem[] = []
|
||||
|
||||
const projects = await queryCollection('projects')
|
||||
.select('title', 'description', 'cover', 'tags', 'slug', 'canva')
|
||||
.all()
|
||||
|
||||
projects.forEach((project) => {
|
||||
if (!project.cover)
|
||||
return
|
||||
|
||||
items.push({
|
||||
title: project.title,
|
||||
description: project.description || '',
|
||||
image: `/projects/${project.cover}`,
|
||||
link: `/projects/${project.slug}`,
|
||||
tags: project.tags || [],
|
||||
height: project.canva?.height ?? 270,
|
||||
width: project.canva?.width ?? 480,
|
||||
})
|
||||
})
|
||||
|
||||
const writings = await queryCollection('writings')
|
||||
.select('title', 'description', 'cover', 'slug', 'tags', 'canva')
|
||||
.all()
|
||||
|
||||
writings.forEach((writing) => {
|
||||
if (!writing.cover)
|
||||
return
|
||||
|
||||
items.push({
|
||||
title: writing.title,
|
||||
description: writing.description || '',
|
||||
image: `/writings/${writing.cover}`,
|
||||
link: `/writings/${writing.slug}`,
|
||||
tags: writing.tags || [],
|
||||
height: writing.canva?.height ?? 270,
|
||||
width: writing.canva?.width ?? 480,
|
||||
})
|
||||
})
|
||||
|
||||
return items
|
||||
})
|
||||
const { t } = useI18n()
|
||||
|
||||
const isMobile = computed(() => {
|
||||
if (!import.meta.client)
|
||||
return false
|
||||
return isMobileDevice(navigator.userAgent, window.innerWidth)
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
function handleItemClick(item: CanvasItem) {
|
||||
if (import.meta.client) {
|
||||
router.push(item.link)
|
||||
}
|
||||
}
|
||||
|
||||
const imageUrls = computed(() => data.value?.map(item => item.cover))
|
||||
|
||||
const loaderProgress = ref(0)
|
||||
const showLoader = ref(true)
|
||||
const isImagesLoaded = ref(false)
|
||||
|
||||
const hoveredItemIndex = ref<number | null>(null)
|
||||
|
||||
const { startPreloading } = useImagePreloader({
|
||||
images: imageUrls.value || [],
|
||||
onProgress: (newProgress) => {
|
||||
loaderProgress.value = newProgress
|
||||
},
|
||||
onComplete: () => {
|
||||
isImagesLoaded.value = true
|
||||
setTimeout(() => {
|
||||
showLoader.value = false
|
||||
}, 500)
|
||||
},
|
||||
})
|
||||
|
||||
const canvasRef = ref<any>(null)
|
||||
|
||||
onMounted(() => {
|
||||
startPreloading()
|
||||
nextTick(() => {
|
||||
if (canvasRef.value?.updateDimensions) {
|
||||
canvasRef.value.updateDimensions()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Prevent browser navigation gestures
|
||||
if (import.meta.client) {
|
||||
document.documentElement.style.overscrollBehavior = 'none'
|
||||
document.body.style.overscrollBehavior = 'none'
|
||||
document.body.style.touchAction = 'manipulation'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
<main v-if="data" class="canvas-page relative h-screen w-screen overflow-hidden" style="touch-action: none; overscroll-behavior: none;">
|
||||
<Canvas
|
||||
ref="canvasRef"
|
||||
:items="data || []"
|
||||
:base-gap="50"
|
||||
:zoom-options="{
|
||||
minZoom: 0.4,
|
||||
maxZoom: 2.2,
|
||||
zoomFactor: 1.08,
|
||||
enableCtrl: true,
|
||||
enableMeta: true,
|
||||
enableAlt: true,
|
||||
}"
|
||||
class="absolute inset-0"
|
||||
@item-click="handleItemClick"
|
||||
>
|
||||
<template #default="{ item, index, onItemClick }">
|
||||
<Motion
|
||||
:initial="{
|
||||
opacity: 0,
|
||||
filter: 'blur(20px)',
|
||||
scale: 0.8,
|
||||
}"
|
||||
:animate="isImagesLoaded ? {
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)',
|
||||
scale: 1,
|
||||
} : {
|
||||
opacity: 0,
|
||||
filter: 'blur(20px)',
|
||||
scale: 0.8,
|
||||
}"
|
||||
:transition="{
|
||||
duration: 0.8,
|
||||
delay: isImagesLoaded ? Math.random() * 0.8 : 0,
|
||||
ease: 'easeOut',
|
||||
}"
|
||||
class="group relative size-full select-none overflow-hidden hover:scale-105 active:scale-95 transition-all duration-300"
|
||||
:class="[
|
||||
isMobile ? 'cursor-default' : 'cursor-pointer',
|
||||
index % 2 === 0 ? 'rotate-1' : '-rotate-1',
|
||||
]"
|
||||
data-canvas-item
|
||||
@click="onItemClick"
|
||||
@mouseenter="hoveredItemIndex = index"
|
||||
@mouseleave="hoveredItemIndex = null"
|
||||
>
|
||||
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br p-1 border-2 border-default/50">
|
||||
<div class="relative size-full overflow-hidden rounded-xl">
|
||||
<video
|
||||
v-if="item && isVideo(item.image)"
|
||||
:src="item.image"
|
||||
class="size-full object-cover"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
:draggable="false"
|
||||
/>
|
||||
<img
|
||||
v-else-if="item"
|
||||
:src="item.image"
|
||||
:alt="item.title"
|
||||
class="size-full object-cover"
|
||||
:draggable="false"
|
||||
>
|
||||
|
||||
<!-- Item details overlay -->
|
||||
<Motion
|
||||
v-if="!isMobile"
|
||||
:initial="{ opacity: 0, scale: 0.95 }"
|
||||
:animate="hoveredItemIndex === index ? {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transformOrigin: 'bottom left',
|
||||
} : {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
transformOrigin: 'bottom left',
|
||||
}"
|
||||
:transition="{
|
||||
duration: 0.2,
|
||||
ease: 'easeOut',
|
||||
}"
|
||||
class="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/90 via-black/60 to-transparent backdrop-blur-[2px] p-4 rounded-xl"
|
||||
>
|
||||
<Motion
|
||||
:initial="{ y: 20, opacity: 0 }"
|
||||
:animate="hoveredItemIndex === index ? {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
} : {
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
}"
|
||||
:transition="{
|
||||
duration: 0.25,
|
||||
delay: 0.05,
|
||||
ease: 'easeOut',
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium text-white mb-1 line-clamp-2 leading-tight">
|
||||
{{ item?.title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="item?.description"
|
||||
class="text-sm text-white/85 line-clamp-2"
|
||||
>
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
<UIcon name="i-heroicons-arrow-top-right-on-square" class="size-4 text-white/70 flex-shrink-0 mt-0.5" />
|
||||
</div>
|
||||
</Motion>
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
</Motion>
|
||||
</template>
|
||||
</Canvas>
|
||||
|
||||
<div v-if="canvasRef" class="fixed bottom-2 right-2 sm:bottom-4 sm:right-4 pointer-events-none">
|
||||
<CanvasMinimap
|
||||
:items="data || []"
|
||||
:grid-items="canvasRef.gridItems || []"
|
||||
:offset="canvasRef.offset || { x: 0, y: 0 }"
|
||||
:zoom="canvasRef.zoom || 1"
|
||||
:container-dimensions="canvasRef.containerDimensions || { width: 0, height: 0 }"
|
||||
:canvas-bounds="canvasRef.canvasBounds || { width: 0, height: 0 }"
|
||||
class="scale-85 sm:scale-100 origin-bottom-right"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute bottom-2 left-2 sm:bottom-4 sm:left-4 z-40 flex flex-col gap-2">
|
||||
<div class="rounded-lg bg-default/80 px-3 py-2 text-highlighted backdrop-blur-sm">
|
||||
<p class="text-xs opacity-75">
|
||||
<span class="sm:hidden">{{ data?.length }} items</span><span class="hidden sm:inline">Click items to open links • {{ data?.length }} items</span>
|
||||
<span v-if="canvasRef?.zoom" class="ml-2 opacity-60">
|
||||
• {{ Math.round((canvasRef.zoom || 1) * 100) }}%
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="hidden sm:block rounded-lg bg-default/80 px-3 py-2 text-highlighted backdrop-blur-sm">
|
||||
<p class="text-xs opacity-75">
|
||||
Hold Ctrl/⌘/Alt + scroll to zoom (40%-220%)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CanvasLoader
|
||||
:progress="loaderProgress"
|
||||
:is-visible="showLoader"
|
||||
:title="t('canva.title')"
|
||||
/>
|
||||
</main>
|
||||
<main v-else>
|
||||
{{ error }}
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,13 @@ const route = useRoute()
|
||||
const { data: project } = await useAsyncData(`projects/${route.params.slug}`, () =>
|
||||
queryCollection('projects').path(`/projects/${route.params.slug}`).first())
|
||||
|
||||
if (!project.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: `Project "${route.params.slug}" not found`,
|
||||
})
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: project.value?.title,
|
||||
description: project.value?.description,
|
||||
@@ -11,7 +18,7 @@ useSeoMeta({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="project" class="mt-8 md:mt-16 md:mb-36 mb-20">
|
||||
<UContainer v-if="project" class="mt-8 md:mt-16 md:mb-36 mb-20">
|
||||
<PostAlert class="mb-8" />
|
||||
<div>
|
||||
<div class="flex items-end justify-between gap-2 flex-wrap">
|
||||
@@ -51,7 +58,7 @@ useSeoMeta({
|
||||
/>
|
||||
</ClientOnly>
|
||||
<PostFooter />
|
||||
</main>
|
||||
</UContainer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,6 +3,13 @@ const route = useRoute()
|
||||
const { data: writing } = await useAsyncData(`writings/${route.params.slug}`, () =>
|
||||
queryCollection('writings').path(`/writings/${route.params.slug}`).first())
|
||||
|
||||
if (!writing.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: `Writing "${route.params.slug}" not found`,
|
||||
})
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: writing.value?.title,
|
||||
description: writing.value?.description,
|
||||
|
||||
Reference in New Issue
Block a user