345 lines
9.3 KiB
Vue
345 lines
9.3 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 v-if="compilation.duration" class="cover absolute" :src="`/${compilation.id}/cover.jpg`" alt="">
|
|
<div class="size-full flex justify-center items-center text-7xl" v-else>
|
|
{{ compilation.description }}
|
|
</div>
|
|
</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.getTracksByCompilationId(compilation.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="compilation.duration !== 0">
|
|
<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="">
|
|
</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%;">
|
|
{{ compilation.name }}
|
|
</span>
|
|
</template>
|
|
</div>
|
|
<div class="face bottom" ref="bottomFace" />
|
|
</div>
|
|
<slot></slot>
|
|
</article>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
|
import type { Compilation, BoxState } from '~~/types/types'
|
|
import { useDataStore } from '~/store/data'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
compilation: Compilation
|
|
BoxState?: BoxState
|
|
}>(),
|
|
{ BoxState: 'list' }
|
|
)
|
|
const dataStore = useDataStore()
|
|
const isDraggable = computed(() => !['list', 'hidden'].includes(BoxState.value()))
|
|
const isNotManifesto = computed(() => !props.compilation.id.startsWith('ES00'))
|
|
|
|
// --- 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;
|
|
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;
|
|
}
|
|
}
|
|
</style>
|