372 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			372 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | |
|   <article class="box box-scene z-10" ref="scene">
 | |
|     <div ref="domBox" class="box-object" :class="{ 'is-draggable': isDraggable }">
 | |
|       <div class="face front relative" ref="frontFace">
 | |
|         <img v-if="box.duration" class="cover absolute" :src="`/${box.id}/cover.jpg`" alt="">
 | |
|         <div class="size-full flex flex-col justify-center items-center text-7xl" v-html="box.description" v-else />
 | |
|       </div>
 | |
|       <div class="face back flex flex-col flex-wrap items-start p-4 overflow-hidden" ref="backFace">
 | |
|         <li class="list-none text-xxs w-1/2 flex flex-row"
 | |
|           v-for="track in dataStore.getTracksByboxId(box.id).slice(0, -1)" :key="track.id" :track="track">
 | |
|           <span class="" v-if="isNotManifesto">
 | |
|             {{ track.order }}.
 | |
|           </span>
 | |
|           <p class="text-left text-slate-700">
 | |
|             <i class="text-slate-950">
 | |
|               {{ track.title }}
 | |
|             </i> <br /> {{ track.artist.name }}
 | |
|           </p>
 | |
|         </li>
 | |
|       </div>
 | |
|       <div class="face right" ref="rightFace" />
 | |
|       <div class="face left" ref="leftFace" />
 | |
|       <div class="face top" ref="topFace">
 | |
|         <template v-if="box.duration !== 0">
 | |
|           <img class="logo h-full p-1" src="/logo.svg" alt="">
 | |
|           <img class="absolute block h-1/2" style="left:5%;" :src="`/${box.id}/title.svg`" alt="">
 | |
|         </template>
 | |
|         <template v-else>
 | |
|           <span class="absolute block h-1/2 right-6">
 | |
|             playlist
 | |
|           </span>
 | |
|           <img class="logo h-full p-1" src="/favicon.svg" alt="">
 | |
|           <span class="absolute block h-1/2" style="left:5%;">
 | |
|             {{ box.name }}
 | |
|           </span>
 | |
|         </template>
 | |
|       </div>
 | |
|       <div class="face bottom" ref="bottomFace" />
 | |
|     </div>
 | |
|     <slot></slot>
 | |
|   </article>
 | |
| </template>
 | |
| 
 | |
| <script setup lang="ts">
 | |
| import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
 | |
| import type { Box, BoxState } from '~~/types/types'
 | |
| import { useDataStore } from '~/store/data'
 | |
| 
 | |
| const props = defineProps<{
 | |
|   box: Box
 | |
| }>();
 | |
| 
 | |
| const { $isMobile } = useNuxtApp()
 | |
| 
 | |
| const dataStore = useDataStore()
 | |
| const isDraggable = computed(() => !['box-list', 'box-hidden'].includes(props.box.state))
 | |
| const isNotManifesto = computed(() => !props.box.id.startsWith('ES00'))
 | |
| 
 | |
| // --- Réfs ---
 | |
| const scene = ref<HTMLElement>()
 | |
| const domBox = 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 = $isMobile ? 0.5 : 0.15
 | |
| const friction = 0.95
 | |
| const minVelocity = 0.02
 | |
| const enableInertia = true
 | |
| 
 | |
| // --- Transformations ---
 | |
| function applyTransform(duration = 0.5) {
 | |
|   if (!domBox.value) return
 | |
|   rotateX.value = Math.round(rotateX.value)
 | |
|   rotateY.value = Math.round(rotateY.value)
 | |
|   rotateZ.value = Math.round(rotateZ.value)
 | |
| 
 | |
|   domBox.value.style.transition = `transform ${duration}s ease`
 | |
|   domBox.value.style.transform = `rotateX(${rotateX.value}deg) rotateY(${rotateY.value}deg) rotateZ(${rotateZ.value}deg)`
 | |
| }
 | |
| 
 | |
| // --- Gestion BoxState ---
 | |
| function applyBoxState() {
 | |
|   switch (props.box.state) {
 | |
|     case 'box-list':
 | |
|       rotateX.value = 76
 | |
|       rotateY.value = 0
 | |
|       rotateZ.value = 150
 | |
|       break
 | |
|     case 'box-selected':
 | |
|       rotateX.value = -20
 | |
|       rotateY.value = 20
 | |
|       rotateZ.value = 0
 | |
|       break
 | |
|     case 'box-hidden':
 | |
|       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.box.color2
 | |
|   backFace.value.style.background = `linear-gradient(to top, ${props.box.color1}, ${props.box.color2})`
 | |
|   leftFace.value.style.background = `linear-gradient(to top, ${props.box.color1}, ${props.box.color2})`
 | |
|   rightFace.value.style.background = `linear-gradient(to top, ${props.box.color1}, ${props.box.color2})`
 | |
|   topFace.value.style.background = `linear-gradient(to top, ${props.box.color2}, ${props.box.color2})`
 | |
|   bottomFace.value.style.background = props.box.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
 | |
|   domBox.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 { domBox.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 (!domBox.value || listenersAttached) return
 | |
|   domBox.value.addEventListener('pointerdown', down)
 | |
|   domBox.value.addEventListener('pointermove', move)
 | |
|   domBox.value.addEventListener('pointerup', end)
 | |
|   domBox.value.addEventListener('pointercancel', end)
 | |
|   domBox.value.addEventListener('pointerleave', end)
 | |
|   listenersAttached = true
 | |
| }
 | |
| 
 | |
| function removeListeners() {
 | |
|   if (!domBox.value || !listenersAttached) return
 | |
|   domBox.value.removeEventListener('pointerdown', down)
 | |
|   domBox.value.removeEventListener('pointermove', move)
 | |
|   domBox.value.removeEventListener('pointerup', end)
 | |
|   domBox.value.removeEventListener('pointercancel', end)
 | |
|   domBox.value.removeEventListener('pointerleave', end)
 | |
|   listenersAttached = false
 | |
| }
 | |
| 
 | |
| onMounted(() => {
 | |
|   applyColor()
 | |
|   applyBoxState()
 | |
|   if (isDraggable.value) addListeners()
 | |
| })
 | |
| 
 | |
| onBeforeUnmount(() => {
 | |
|   cancelAnimationFrame(raf!)
 | |
|   removeListeners()
 | |
| })
 | |
| 
 | |
| // --- Watchers ---
 | |
| watch(() => props.box.state, () => applyBoxState())
 | |
| watch(() => props.box, () => 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: height .5s ease, opacity .5s ease;
 | |
| 
 | |
|   &.box-list {
 | |
|     height: calc(var(--size) * 20);
 | |
|     @apply hover:scale-105;
 | |
|     transition: all .5s ease;
 | |
|     will-change: transform;
 | |
|   }
 | |
| 
 | |
|   &.box-selected {
 | |
|     height: calc(var(--size) * 34);
 | |
|   }
 | |
| 
 | |
|   &-scene {
 | |
|     perspective: 1000px;
 | |
|   }
 | |
| 
 | |
|   &.box-hidden {
 | |
|     height: 0;
 | |
|     opacity: 0;
 | |
|     z-index: 0;
 | |
|   }
 | |
| 
 | |
|   &-object {
 | |
|     width: var(--width);
 | |
|     height: var(--height);
 | |
|     position: relative;
 | |
|     transform-style: preserve-3d;
 | |
|     margin: auto;
 | |
|     user-select: none;
 | |
| 
 | |
|     .box-list & {
 | |
|       cursor: pointer;
 | |
|     }
 | |
| 
 | |
|     .box-selected & {
 | |
|       cursor: grab;
 | |
|     }
 | |
| 
 | |
| 
 | |
|     &:active {
 | |
|       cursor: grabbing;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   .face {
 | |
|     position: absolute;
 | |
|     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);
 | |
|     display: flex;
 | |
|     align-items: center;
 | |
|     justify-content: center;
 | |
|     font-size: 20px;
 | |
|   }
 | |
| 
 | |
|   .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;
 | |
|   }
 | |
| 
 | |
|   /* Deck fade in/out purely in CSS */
 | |
|   .box-page {
 | |
|     opacity: 0;
 | |
|     transition: opacity .25s ease;
 | |
|     pointer-events: none;
 | |
|   }
 | |
| 
 | |
|   &.box-selected .box-page {
 | |
|     opacity: 1;
 | |
|     pointer-events: auto;
 | |
|   }
 | |
| 
 | |
|   /* for tabindex */
 | |
|   &:focus-visible {
 | |
|     outline: 0;
 | |
|     @apply scale-105 outline-none;
 | |
| 
 | |
|     .face {
 | |
|       border: 4px solid rgba(0, 0, 0, 0.5);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   :deep(.indice) {
 | |
|     @apply text-xl p-2 relative bg-black/50 rounded-full backdrop-blur-md;
 | |
|   }
 | |
| }
 | |
| </style>
 |