318 lines
		
	
	
		
			8.2 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			318 lines
		
	
	
		
			8.2 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <article class="box box-scene z-10" ref="scene">
 | |
|     <div class="box-object" ref="box">
 | |
|       <div class="face front relative" ref="frontFace">
 | |
|         <img class="cover absolute" :src="`/${compilation.id}/cover.jpg`" alt="">
 | |
|       </div>
 | |
|       <div class="face back" ref="backFace">
 | |
|         {{ compilation.description }}
 | |
|       </div>
 | |
|       <div class="face right" ref="rightFace" />
 | |
|       <div class="face left" ref="leftFace" />
 | |
|       <div class="face top" ref="topFace">
 | |
|         <img class="logo h-full p-1" src="/logo.svg" alt="">
 | |
|         <img class="absolute block h-1/2" style="left:5%;" :src="`/${compilation.id}/title.svg`" alt="">
 | |
|       </div>
 | |
|       <div class="face bottom" ref="bottomFace" />
 | |
|     </div>
 | |
|     <OrganismCompilationPage :compilation="compilation" class="box-page" v-if="props.BoxState === 'selected'" />
 | |
|   </article>
 | |
| </template>
 | |
| 
 | |
| <script setup lang="ts">
 | |
| import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
 | |
| import type { Compilation, BoxState } from '~~/types/types'
 | |
| 
 | |
| const props = withDefaults(
 | |
|   defineProps<{
 | |
|     compilation: Compilation
 | |
|     BoxState?: BoxState
 | |
|   }>(),
 | |
|   { BoxState: 'list' }
 | |
| )
 | |
| 
 | |
| const isDraggable = computed(() => !['list', 'hidden'].includes(BoxState.value()))
 | |
| 
 | |
| // --- Réfs ---
 | |
| const scene = ref<HTMLElement>()
 | |
| const box = ref<HTMLElement>()
 | |
| const frontFace = ref<HTMLElement>()
 | |
| const backFace = ref<HTMLElement>()
 | |
| const rightFace = ref<HTMLElement>()
 | |
| const leftFace = ref<HTMLElement>()
 | |
| const topFace = ref<HTMLElement>()
 | |
| const bottomFace = ref<HTMLElement>()
 | |
| 
 | |
| // --- Angles ---
 | |
| const rotateX = ref(0)
 | |
| const rotateY = ref(0)
 | |
| const rotateZ = ref(0)
 | |
| 
 | |
| // --- Drag + inertie ---
 | |
| let dragging = false
 | |
| let lastPointer = { x: 0, y: 0, time: 0 }
 | |
| let velocity = { x: 0, y: 0 }
 | |
| let raf: number | null = null
 | |
| 
 | |
| const sensitivity = 0.3
 | |
| const friction = 0.95
 | |
| const minVelocity = 0.02
 | |
| const enableInertia = true
 | |
| 
 | |
| // --- Transformations ---
 | |
| function applyTransform(duration = 0.5) {
 | |
|   if (!box.value) return
 | |
|   rotateX.value = Math.round(rotateX.value)
 | |
|   rotateY.value = Math.round(rotateY.value)
 | |
|   rotateZ.value = Math.round(rotateZ.value)
 | |
| 
 | |
|   box.value.style.transition = `transform ${duration}s ease`
 | |
|   box.value.style.transform = `rotateX(${rotateX.value}deg) rotateY(${rotateY.value}deg) rotateZ(${rotateZ.value}deg)`
 | |
| }
 | |
| 
 | |
| // --- Gestion BoxState ---
 | |
| function applyBoxState() {
 | |
|   switch (props.BoxState) {
 | |
|     case 'list':
 | |
|       rotateX.value = 76
 | |
|       rotateY.value = 0
 | |
|       rotateZ.value = 150
 | |
|       break
 | |
|     case 'selected':
 | |
|       rotateX.value = -20
 | |
|       rotateY.value = 20
 | |
|       rotateZ.value = 0
 | |
|       break
 | |
|     case 'hide':
 | |
|       rotateX.value = 76
 | |
|       rotateY.value = 0
 | |
|       rotateZ.value = 150
 | |
|       break
 | |
|   }
 | |
|   applyTransform(0.8) // transition fluide
 | |
| }
 | |
| 
 | |
| // --- Couleurs ---
 | |
| function applyColor() {
 | |
|   if (!frontFace.value || !backFace.value || !leftFace.value || !topFace.value || !bottomFace.value) return
 | |
| 
 | |
|   frontFace.value.style.background = props.compilation.color2
 | |
|   backFace.value.style.background = `linear-gradient(to top, ${props.compilation.color1}, ${props.compilation.color2})`
 | |
|   leftFace.value.style.background = `linear-gradient(to top, ${props.compilation.color1}, ${props.compilation.color2})`
 | |
|   rightFace.value.style.background = `linear-gradient(to top, ${props.compilation.color1}, ${props.compilation.color2})`
 | |
|   topFace.value.style.background = `linear-gradient(to top, ${props.compilation.color2}, ${props.compilation.color2})`
 | |
|   bottomFace.value.style.background = props.compilation.color1
 | |
| }
 | |
| 
 | |
| // --- Inertie ---
 | |
| function tickInertia() {
 | |
|   if (!enableInertia) return
 | |
| 
 | |
|   velocity.x *= friction
 | |
|   velocity.y *= friction
 | |
| 
 | |
|   rotateX.value += velocity.y
 | |
|   rotateY.value += velocity.x
 | |
|   rotateX.value = Math.max(-80, Math.min(80, rotateX.value))
 | |
| 
 | |
|   applyTransform(0.05) // court duration pour inertie fluide
 | |
| 
 | |
|   if (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity) {
 | |
|     raf = requestAnimationFrame(tickInertia)
 | |
|   } else {
 | |
|     raf = null
 | |
|   }
 | |
| }
 | |
| 
 | |
| // --- Pointer events ---
 | |
| let listenersAttached = false
 | |
| 
 | |
| const down = (ev: PointerEvent) => {
 | |
|   ev.preventDefault()
 | |
|   dragging = true
 | |
|   box.value?.setPointerCapture(ev.pointerId)
 | |
|   lastPointer = { x: ev.clientX, y: ev.clientY, time: performance.now() }
 | |
|   velocity = { x: 0, y: 0 }
 | |
|   if (raf) { cancelAnimationFrame(raf); raf = null }
 | |
| }
 | |
| 
 | |
| const move = (ev: PointerEvent) => {
 | |
|   if (!dragging) return
 | |
|   ev.preventDefault()
 | |
|   const now = performance.now()
 | |
|   const dx = ev.clientX - lastPointer.x
 | |
|   const dy = ev.clientY - lastPointer.y
 | |
|   const dt = Math.max(1, now - lastPointer.time)
 | |
| 
 | |
|   rotateY.value += dx * sensitivity
 | |
|   rotateX.value -= dy * sensitivity
 | |
|   rotateX.value = Math.max(-80, Math.min(80, rotateX.value))
 | |
| 
 | |
|   velocity.x = (dx / dt) * 16 * sensitivity
 | |
|   velocity.y = (-dy / dt) * 16 * sensitivity
 | |
| 
 | |
|   lastPointer = { x: ev.clientX, y: ev.clientY, time: now }
 | |
|   applyTransform(0) // immédiat pendant drag
 | |
| }
 | |
| 
 | |
| const end = (ev: PointerEvent) => {
 | |
|   if (!dragging) return
 | |
|   dragging = false
 | |
|   try { box.value?.releasePointerCapture(ev.pointerId) } catch { }
 | |
|   if (enableInertia && (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity)) {
 | |
|     if (!raf) raf = requestAnimationFrame(tickInertia)
 | |
|   }
 | |
| }
 | |
| 
 | |
| function addListeners() {
 | |
|   if (!box.value || listenersAttached) return
 | |
|   box.value.addEventListener('pointerdown', down)
 | |
|   box.value.addEventListener('pointermove', move)
 | |
|   box.value.addEventListener('pointerup', end)
 | |
|   box.value.addEventListener('pointercancel', end)
 | |
|   box.value.addEventListener('pointerleave', end)
 | |
|   listenersAttached = true
 | |
| }
 | |
| 
 | |
| function removeListeners() {
 | |
|   if (!box.value || !listenersAttached) return
 | |
|   box.value.removeEventListener('pointerdown', down)
 | |
|   box.value.removeEventListener('pointermove', move)
 | |
|   box.value.removeEventListener('pointerup', end)
 | |
|   box.value.removeEventListener('pointercancel', end)
 | |
|   box.value.removeEventListener('pointerleave', end)
 | |
|   listenersAttached = false
 | |
| }
 | |
| 
 | |
| onMounted(() => {
 | |
|   applyColor()
 | |
|   applyBoxState()
 | |
|   if (isDraggable) addListeners()
 | |
| })
 | |
| 
 | |
| onBeforeUnmount(() => {
 | |
|   cancelAnimationFrame(raf!)
 | |
|   removeListeners()
 | |
| })
 | |
| 
 | |
| // --- Watchers ---
 | |
| watch(() => props.BoxState, () => applyBoxState())
 | |
| watch(() => props.compilation, () => applyColor(), { deep: true })
 | |
| watch(() => isDraggable, (enabled) => enabled ? addListeners() : removeListeners())
 | |
| 
 | |
| </script>
 | |
| 
 | |
| <style lang="scss" scoped>
 | |
| .box {
 | |
|   --size: 6px;
 | |
|   --height: calc(var(--size) * (100 / 3));
 | |
|   --width: calc(var(--size) * 50);
 | |
|   --depth: calc(var(--size) * 10);
 | |
|   transition: all .5s;
 | |
| 
 | |
|   &.hide {
 | |
|     height: 0;
 | |
|     opacity: 0;
 | |
|     z-index: 0;
 | |
|   }
 | |
| 
 | |
|   &.list {
 | |
|     @apply hover:scale-105;
 | |
|   }
 | |
| 
 | |
|   &-scene {
 | |
|     height: calc(var(--size) * 20);
 | |
|     perspective: 1000px;
 | |
|     touch-action: none;
 | |
|   }
 | |
| 
 | |
|   &-object {
 | |
|     width: var(--width);
 | |
|     height: var(--height);
 | |
|     position: relative;
 | |
|     transform-style: preserve-3d;
 | |
|     margin: auto;
 | |
|     user-select: none;
 | |
| 
 | |
|     .list & {
 | |
|       cursor: pointer;
 | |
|     }
 | |
| 
 | |
|     .selected & {
 | |
|       cursor: grab;
 | |
|     }
 | |
| 
 | |
| 
 | |
|     &:active {
 | |
|       cursor: grabbing;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   .face {
 | |
|     position: absolute;
 | |
|     display: flex;
 | |
|     align-items: center;
 | |
|     justify-content: center;
 | |
|     font-size: 20px;
 | |
|     color: white;
 | |
|     font-weight: 600;
 | |
|     backface-visibility: hidden;
 | |
|     box-sizing: border-box;
 | |
|     border: 1px solid black;
 | |
|   }
 | |
| 
 | |
|   .front,
 | |
|   .back {
 | |
|     width: 100%;
 | |
|     height: 100%;
 | |
|   }
 | |
| 
 | |
|   .face.top,
 | |
|   .face.bottom {
 | |
|     width: var(--width);
 | |
|     height: var(--depth);
 | |
|   }
 | |
| 
 | |
|   .face.left,
 | |
|   .face.right {
 | |
|     width: var(--depth);
 | |
|     height: var(--height);
 | |
|   }
 | |
| 
 | |
|   .face.front {
 | |
|     transform: translateX(0) translateY(0) translateZ(var(--depth));
 | |
|   }
 | |
| 
 | |
|   .face.back {
 | |
|     transform: rotateY(180deg) translateX(0) translateY(0) translateZ(0);
 | |
|   }
 | |
| 
 | |
|   .face.right {
 | |
|     transform: rotateY(90deg) translateX(calc(var(--depth)*-1)) translateY(0px) translateZ(var(--width));
 | |
|     transform-origin: top left;
 | |
|   }
 | |
| 
 | |
|   .face.left {
 | |
|     transform: rotateY(-90deg) translateX(calc(var(--depth)/2)) translateY(0) translateZ(calc(var(--depth)/2));
 | |
|   }
 | |
| 
 | |
|   .face.top {
 | |
|     transform: rotateX(90deg) translateX(0px) translateY(calc(var(--depth)/2)) translateZ(calc(var(--depth)/2));
 | |
|   }
 | |
| 
 | |
|   .face.top>* {
 | |
|     @apply rotate-180;
 | |
|   }
 | |
| 
 | |
|   .face.bottom {
 | |
|     transform: rotateX(-90deg) translateX(0px) translateY(calc(var(--depth)* -0.5)) translateZ(calc(var(--height) - var(--depth)/2));
 | |
|   }
 | |
| 
 | |
|   .cover {
 | |
|     width: 100%;
 | |
|     height: 100%;
 | |
|     object-fit: cover;
 | |
|   }
 | |
| }
 | |
| </style>
 |