338 lines
		
	
	
		
			8.8 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			338 lines
		
	
	
		
			8.8 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| <template>
 | ||
|   <!-- scène 3D -->
 | ||
|   <div ref="scene" class="scene z-10">
 | ||
|     <div ref="box" class="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" />
 | ||
|       <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 left-0 w-16 pr-4" :src="`/${compilation.id}/title.svg`" alt="">
 | ||
|       </div>
 | ||
|       <div class="face bottom" ref="bottomFace" />
 | ||
|     </div>
 | ||
|   </div>
 | ||
| 
 | ||
|   <div class="devtool  absolute right-4 text-white bg-black rounded-2xl px-4 py-2">
 | ||
|     <button @click="poser">poser</button>
 | ||
|     <button @click="face">face</button>
 | ||
|     <button @click="dos">dos</button>
 | ||
|     <div>
 | ||
|       <label class="block">
 | ||
|         X: {{ angleX }}
 | ||
|         <input v-model.number="angleX" type="range" step="1" min="-180" max="180" @input="applyTransform">
 | ||
|       </label>
 | ||
|       <label class="block">
 | ||
|         Y: {{ angleY }}
 | ||
|         <input v-model.number="angleY" type="range" step="1" min="-180" max="180" @input="applyTransform">
 | ||
|       </label>
 | ||
|       <label class="block">
 | ||
|         Z: {{ angleZ }}
 | ||
|         <input v-model.number="angleZ" type="range" step="1" min="-180" max="180" @input="applyTransform">
 | ||
|       </label>
 | ||
|     </div>
 | ||
|   </div>
 | ||
| </template>
 | ||
| 
 | ||
| <script setup lang="ts">
 | ||
| import compilations from '~~/server/api/compilations';
 | ||
| import type { Compilation } from '~~/types/types';
 | ||
| 
 | ||
| const props = defineProps<{ compilation: Compilation }>()
 | ||
| 
 | ||
| // States
 | ||
| const position = ref('')
 | ||
| const angleX = ref(76)
 | ||
| const angleY = ref(0)
 | ||
| const angleZ = ref(150)
 | ||
| 
 | ||
| const frontFace = ref()
 | ||
| const backFace = ref()
 | ||
| const rightFace = ref()
 | ||
| const leftFace = ref()
 | ||
| const topFace = ref()
 | ||
| const bottomFace = ref()
 | ||
| 
 | ||
| function poser() {
 | ||
|   rotateBox(76, 0, 150)
 | ||
| }
 | ||
| 
 | ||
| function face() {
 | ||
|   rotateBox(-20, 20, 0)
 | ||
| }
 | ||
| 
 | ||
| function dos() {
 | ||
|   rotateBox(-20, 200, 0)
 | ||
| }
 | ||
| 
 | ||
| const box = ref()
 | ||
| const scene = ref()
 | ||
| 
 | ||
| /*
 | ||
|   ÉTATS POUR LE DRAG + INERTIE
 | ||
| */
 | ||
| let dragging = false
 | ||
| let lastPointer = { x: 0, y: 0, time: 0 } // position précédente du pointeur
 | ||
| let velocity = { x: 0, y: 0 }              // vitesse calculée pour inertie
 | ||
| let raf = null                              // id du requestAnimationFrame
 | ||
| 
 | ||
| /*
 | ||
|   PARAMÈTRES DE RÉGLAGE
 | ||
| */
 | ||
| const sensitivity = 0.3    // combien de degrés par pixel de mouvement
 | ||
| const friction = 0.95      // inertie : 1 = sans perte, plus bas = ralentit vite
 | ||
| const minVelocity = 0.02   // seuil sous lequel on arrête l’inertie
 | ||
| const enableInertia = true // true = inertie activée, false = rotation immédiate sans suite
 | ||
| 
 | ||
| /*
 | ||
|   Applique la transformation CSS à la box
 | ||
| */
 | ||
| function applyTransform() {
 | ||
|   angleX.value = Math.round(angleX.value)
 | ||
|   angleY.value = Math.round(angleY.value)
 | ||
|   angleZ.value = Math.round(angleZ.value)
 | ||
| 
 | ||
|   box.value.style.transform = `rotateX(${angleX.value}deg) rotateY(${angleY.value}deg) rotateZ(${angleZ.value}deg)`
 | ||
| }
 | ||
| 
 | ||
| 
 | ||
| /*
 | ||
|   Fonction utilitaire : place la box directement à une rotation donnée
 | ||
|   (utilisée par le bouton reset ou autre logique externe)
 | ||
| */
 | ||
| function rotateBox(x, y, z) {
 | ||
|   angleX.value = x
 | ||
|   angleY.value = y
 | ||
|   angleZ.value = z
 | ||
|   applyTransform()
 | ||
| 
 | ||
|   box.value.style.setProperty('transition', 'transform 800ms ease-in-out')
 | ||
|   setTimeout(() => {
 | ||
|     box.value.style.setProperty('transition', 'transform 120ms linear')
 | ||
|     console.log('dopne timeout')
 | ||
|   }, 120)
 | ||
| 
 | ||
| }
 | ||
| 
 | ||
| /*
 | ||
|   Boucle d’inertie après un drag
 | ||
|   - reprend la vitesse calculée à la release
 | ||
|   - diminue petit à petit avec friction
 | ||
|   - stoppe quand vitesse < minVelocity
 | ||
| */
 | ||
| function tickInertia() {
 | ||
|   if (!enableInertia) return
 | ||
| 
 | ||
|   // appliquer la friction
 | ||
|   velocity.x *= friction
 | ||
|   velocity.y *= friction
 | ||
| 
 | ||
|   // appliquer au box
 | ||
|   angleX.value += velocity.y
 | ||
|   angleY.value += velocity.x
 | ||
| 
 | ||
|   // clamp angleX pour éviter de retourner la box trop loin
 | ||
|   angleX.value = Math.max(-80, Math.min(80, angleX.value))
 | ||
| 
 | ||
|   applyTransform()
 | ||
| 
 | ||
|   // continuer tant qu’il reste du mouvement
 | ||
|   if (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity) {
 | ||
|     raf = requestAnimationFrame(tickInertia)
 | ||
|   } else {
 | ||
|     raf = null
 | ||
|   }
 | ||
| }
 | ||
| 
 | ||
| /*
 | ||
|   Mise en place des listeners une fois le composant monté
 | ||
| */
 | ||
| onMounted(() => {
 | ||
|   // setup CSS vars pour la scène
 | ||
|   updateCssVar('--height', '200px', scene.value)
 | ||
|   updateCssVar('--width', '300px', scene.value)
 | ||
|   updateCssVar('--depth', '60px', scene.value)
 | ||
| 
 | ||
|   frontFace.value.style.setProperty('background', `${props.compilation.colorTo}`)
 | ||
|   backFace.value.style.setProperty('background', `linear-gradient(to top, ${props.compilation.colorFrom}, ${props.compilation.colorTo})`)
 | ||
|   rightFace.value.style.setProperty('background', `linear-gradient(to top, ${props.compilation.colorFrom}, ${props.compilation.colorTo})`)
 | ||
|   leftFace.value.style.setProperty('background', `linear-gradient(to top, ${props.compilation.colorFrom}, ${props.compilation.colorTo})`)
 | ||
|   topFace.value.style.setProperty('background', `linear-gradient(to top, ${props.compilation.colorTo}, ${props.compilation.colorTo})`)
 | ||
|   bottomFace.value.style.setProperty('background', `${props.compilation.colorFrom}`)
 | ||
| 
 | ||
| 
 | ||
|   applyTransform()
 | ||
| 
 | ||
|   // pointerdown = début du drag
 | ||
|   const down = (ev) => {
 | ||
|     ev.preventDefault()
 | ||
|     dragging = true
 | ||
|     scene.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 }
 | ||
|   }
 | ||
| 
 | ||
|   // pointermove = on bouge la souris ou le doigt
 | ||
|   const move = (ev) => {
 | ||
|     if (!dragging) return
 | ||
|     ev.preventDefault()
 | ||
|     const now = performance.now()
 | ||
|     const dx = ev.clientX - lastPointer.x
 | ||
|     const dy = ev.clientY - lastPointer.y
 | ||
| 
 | ||
|     // mise à jour des angles
 | ||
|     angleY.value += dx * sensitivity
 | ||
|     angleX.value -= dy * sensitivity
 | ||
|     angleX.value = Math.max(-80, Math.min(80, angleX.value))
 | ||
| 
 | ||
|     // calcul vitesse pour inertie
 | ||
|     const dt = Math.max(1, now - lastPointer.time)
 | ||
|     velocity.x = (dx / dt) * 16 * sensitivity
 | ||
|     velocity.y = (-dy / dt) * 16 * sensitivity
 | ||
| 
 | ||
|     lastPointer = { x: ev.clientX, y: ev.clientY, time: now }
 | ||
| 
 | ||
|     applyTransform()
 | ||
|   }
 | ||
| 
 | ||
|   // pointerup = fin du drag
 | ||
|   const end = (ev) => {
 | ||
|     if (!dragging) return
 | ||
|     dragging = false
 | ||
|     try { scene.value.releasePointerCapture(ev.pointerId) } catch { }
 | ||
|     if (enableInertia && (Math.abs(velocity.x) > minVelocity || Math.abs(velocity.y) > minVelocity)) {
 | ||
|       if (!raf) raf = requestAnimationFrame(tickInertia)
 | ||
|     }
 | ||
|   }
 | ||
| 
 | ||
|   // attach des events
 | ||
|   scene.value.addEventListener('pointerdown', down)
 | ||
|   scene.value.addEventListener('pointermove', move)
 | ||
|   scene.value.addEventListener('pointerup', end)
 | ||
|   scene.value.addEventListener('pointercancel', end)
 | ||
|   scene.value.addEventListener('pointerleave', end)
 | ||
| 
 | ||
|   // cleanup au démontage
 | ||
|   onBeforeUnmount(() => {
 | ||
|     cancelAnimationFrame(raf)
 | ||
|   })
 | ||
| })
 | ||
| </script>
 | ||
| 
 | ||
| 
 | ||
| <style>
 | ||
| html,
 | ||
| body {
 | ||
|   height: 100%;
 | ||
|   margin: 0;
 | ||
|   display: flex;
 | ||
|   align-items: center;
 | ||
|   justify-content: center;
 | ||
|   font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial;
 | ||
|   -webkit-font-smoothing: antialiased;
 | ||
| }
 | ||
| 
 | ||
| /* scène avec perspective */
 | ||
| .scene {
 | ||
|   height: var(--height);
 | ||
|   width: var(--width);
 | ||
|   perspective: 1000px;
 | ||
|   touch-action: none;
 | ||
|   /* essentiel pour empêcher le scroll pendant le drag */
 | ||
|   /* height: 20px; */
 | ||
|   transition: all .5s;
 | ||
| }
 | ||
| 
 | ||
| /* l'objet 3D (box simple) */
 | ||
| .box {
 | ||
|   width: var(--width);
 | ||
|   height: var(--height);
 | ||
|   position: relative;
 | ||
|   transform-style: preserve-3d;
 | ||
|   transition: transform 120ms linear;
 | ||
|   /* légère smoothing quand on lâche */
 | ||
|   margin: auto;
 | ||
|   user-select: none;
 | ||
|   cursor: grab;
 | ||
| }
 | ||
| 
 | ||
| .box:active {
 | ||
|   cursor: grabbing;
 | ||
| }
 | ||
| 
 | ||
| /* faces du box */
 | ||
| .face {
 | ||
|   position: absolute;
 | ||
|   display: flex;
 | ||
|   align-items: center;
 | ||
|   justify-content: center;
 | ||
|   font-size: 20px;
 | ||
|   color: white;
 | ||
|   font-weight: 600;
 | ||
|   backface-visibility: hidden;
 | ||
|   /* border: 2px solid rgba(255, 255, 255, 0.06); */
 | ||
|   box-sizing: border-box;
 | ||
|   transform-origin: top right;
 | ||
| }
 | ||
| 
 | ||
| .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(0px) translateY(0px) translateZ(0px);
 | ||
| }
 | ||
| 
 | ||
| .face.back {
 | ||
|   transform: rotateY(180deg) translateX(var(--width)) translateY(0px) translateZ(var(--depth));
 | ||
| }
 | ||
| 
 | ||
| .face.right {
 | ||
|   transform: rotateY(90deg) translateX(0px) translateY(0px) translateZ(var(--width));
 | ||
|   transform-origin: top left;
 | ||
| }
 | ||
| 
 | ||
| .face.left {
 | ||
|   transform: rotateY(-90deg) translateX(0) translateY(0px) translateZ(var(--depth));
 | ||
| }
 | ||
| 
 | ||
| .face.top {
 | ||
|   transform: rotateX(90deg) translateX(0px) translateY(calc(var(--depth) * -1)) translateZ(0px);
 | ||
| }
 | ||
| 
 | ||
| .face.top>* {
 | ||
|   @apply rotate-180;
 | ||
| }
 | ||
| 
 | ||
| .face.bottom {
 | ||
|   transform: rotateX(-90deg) translateX(0) translateY(0px) translateZ(calc(var(--height)));
 | ||
| }
 | ||
| 
 | ||
| .cover {
 | ||
|   height: 100%;
 | ||
|   width: 100%;
 | ||
|   object-fit: cover;
 | ||
| }
 | ||
| 
 | ||
| .logo {
 | ||
|   filter: drop-shadow(2px 2px 0 rgb(0 0 0 / 0.8));
 | ||
| }
 | ||
| </style> |