519 lines
14 KiB
Vue
519 lines
14 KiB
Vue
<template>
|
|
<article v-bind="attrs" :role="props.role" :draggable="isFaceUp" :class="[
|
|
'card cursor-pointer',
|
|
isFaceUp ? 'face-up' : 'face-down',
|
|
{ 'current-track': playerStore.currentTrack?.id === track.id },
|
|
{ 'is-dragging': isDragging }
|
|
]" :tabindex="props.tabindex" :aria-disabled="false" @click.stop="handleClick" @keydown.enter.stop="handleClick"
|
|
@keydown.space.prevent.stop="handleClick" @dragstart="handleDragStart" @dragend="handleDragEnd"
|
|
@drag="handleDragMove" @touchstart.passive="!isFaceUp" @touchmove.passive="!isFaceUp">
|
|
<div class="flip-inner" ref="cardElement">
|
|
<!-- Face-Up -->
|
|
<main
|
|
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden">
|
|
|
|
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 right-7"
|
|
@click.stop="clickCardSymbol">
|
|
<div class="suit text-7xl absolute"
|
|
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
|
|
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
|
|
</div>
|
|
<div class="rank text-white font-bold absolute -mt-1">
|
|
{{ props.track.card?.rank }}
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="flex items-center justify-center size-7 absolute top-6 left-6">
|
|
<div class="rank text-white font-bold absolute -mt-1">
|
|
{{ props.track.order }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cover -->
|
|
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
|
|
<playButton :objectToPlay="track" />
|
|
<img draggable="false" v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
|
|
class="w-full h-full object-cover object-center" />
|
|
</figure>
|
|
|
|
<!-- Body -->
|
|
<div
|
|
class="card-body p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
|
|
<div v-if="isOrder" class="label">
|
|
{{ props.track.order }}
|
|
</div>
|
|
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
|
|
{{ props.track.title || 'title' }}
|
|
</h2>
|
|
<p v-if="isPlaylistTrack && track.artist && typeof track.artist === 'object'"
|
|
class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
|
{{ track.artist.name || 'artist' }}
|
|
</p>
|
|
<p v-else-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
|
{{ typeof track.artist === 'string' ? track.artist : 'artist' }}
|
|
</p>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Face-Down -->
|
|
<footer
|
|
class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden select-none">
|
|
<figure class="h-full flex text-center rounded-xl justify-center items-center"
|
|
:style="{ backgroundColor: cardColor }">
|
|
<playButton :objectToPlay="track" />
|
|
<img draggable="false" src="/face-down.svg" />
|
|
</figure>
|
|
</footer>
|
|
</div>
|
|
</article>
|
|
|
|
<!-- Clone fantôme unifié pour drag souris ET tactile -->
|
|
<Teleport to="body">
|
|
<div v-if="isDragging && touchClone" ref="ghostElement"
|
|
class="ghost-card fixed pointer-events-none z-[9999] w-56 h-80" :style="{
|
|
left: touchClone.x + 'px',
|
|
top: touchClone.y + 'px',
|
|
transform: 'translate(-50%, -50%)'
|
|
}">
|
|
<div class="flip-inner">
|
|
<main
|
|
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 rounded-2xl shadow-2xl flex flex-col overflow-hidden bg-white bg-opacity-90">
|
|
|
|
<div v-if="isPlaylistTrack" class="flex items-center justify-center size-7 absolute top-7 left-7">
|
|
<div class="suit text-7xl absolute"
|
|
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.track.card?.suit]">
|
|
<img draggable="false" :src="`/${props.track.card?.suit}.svg`" />
|
|
</div>
|
|
<div class="rank text-white font-bold absolute -mt-1">
|
|
{{ props.track.card?.rank }}
|
|
</div>
|
|
</div>
|
|
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl">
|
|
<img draggable="false" :src="coverUrl" alt="Pochette de l'album"
|
|
class="w-full h-full object-cover object-center" />
|
|
</figure>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
|
|
<!-- Modal de partage -->
|
|
<ModalSharer v-if="showModalSharer" ref="modalSharer" />
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Track } from '~~/types/types'
|
|
import { usePlayerStore } from '~/store/player'
|
|
import { useDataStore } from '~/store/data'
|
|
import { useNuxtApp } from '#app'
|
|
import ModalSharer from '~/components/ui/ModalSharer.vue'
|
|
|
|
const props = withDefaults(defineProps<{
|
|
track: Track;
|
|
isFaceUp?: boolean;
|
|
role?: string;
|
|
tabindex?: string | number;
|
|
'onUpdate:isFaceUp'?: (value: boolean) => void;
|
|
}>(), {
|
|
isFaceUp: true,
|
|
role: 'button',
|
|
tabindex: '0'
|
|
})
|
|
|
|
// Use useAttrs to get all other attributes
|
|
const attrs = useAttrs()
|
|
|
|
const modalSharer = ref<InstanceType<typeof ModalSharer> | null>(null)
|
|
const showModalSharer = ref(false)
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:isFaceUp', value: boolean): void;
|
|
(e: 'cardClick', track: Track): void;
|
|
(e: 'clickCardSymbol', track: Track): void;
|
|
(e: 'dragstart', event: DragEvent): void;
|
|
(e: 'dragend', event: DragEvent): void;
|
|
(e: 'drag', event: DragEvent): void;
|
|
(e: 'click', event: MouseEvent): void;
|
|
}>()
|
|
|
|
// Handle click events (mouse and keyboard)
|
|
const handleClick = (event: MouseEvent | KeyboardEvent) => {
|
|
if (!isDragging.value && !hasMovedDuringPress.value) {
|
|
emit('cardClick', props.track);
|
|
emit('click', event as MouseEvent);
|
|
}
|
|
hasMovedDuringPress.value = false;
|
|
}
|
|
|
|
const clickCardSymbol = (event: MouseEvent) => {
|
|
event.stopPropagation();
|
|
|
|
// Afficher la modale
|
|
showModalSharer.value = true;
|
|
|
|
// Donner le focus à la modale après le rendu
|
|
nextTick(() => {
|
|
if (modalSharer.value) {
|
|
modalSharer.value.open(props.track);
|
|
}
|
|
});
|
|
|
|
emit('clickCardSymbol', props.track);
|
|
}
|
|
|
|
// Handle drag start with proper event emission
|
|
const handleDragStart = (event: DragEvent) => {
|
|
if (!props.isFaceUp) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
const { $bodyClass } = useNuxtApp()
|
|
$bodyClass.add('card-dragging')
|
|
dragStart(event);
|
|
emit('dragstart', event);
|
|
}
|
|
|
|
// Handle drag end with proper event emission
|
|
const handleDragEnd = (event: DragEvent) => {
|
|
if (!props.isFaceUp) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
const { $bodyClass } = useNuxtApp()
|
|
$bodyClass.remove('card-dragging')
|
|
dragEnd(event);
|
|
emit('dragend', event);
|
|
}
|
|
|
|
// Handle drag move with proper event emission
|
|
const handleDragMove = (event: DragEvent) => {
|
|
if (!props.isFaceUp) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
dragMove(event);
|
|
emit('drag', event);
|
|
}
|
|
|
|
const playerStore = usePlayerStore()
|
|
const isManifesto = computed(() => props.track.boxId.startsWith('ES00'))
|
|
const isOrder = computed(() => props.track.order && !isManifesto)
|
|
const isPlaylistTrack = computed(() => props.track.type === 'playlist')
|
|
const isRedCard = computed(() => (props.track.card?.suit === '♥' || props.track.card?.suit === '♦'))
|
|
const dataStore = useDataStore()
|
|
const cardColor = computed(() => dataStore.getYearColor(props.track.year || 0))
|
|
const coverUrl = computed(() => props.track.coverId || '/card-dock.svg')
|
|
|
|
const isDragging = ref(false)
|
|
const cardElement = ref<HTMLElement | null>(null)
|
|
const ghostElement = ref<HTMLElement | null>(null)
|
|
|
|
// État unifié pour souris et tactile
|
|
const touchClone = ref<{ x: number, y: number } | null>(null)
|
|
const touchStartPos = ref<{ x: number, y: number } | null>(null)
|
|
const longPressTimer = ref<number | null>(null)
|
|
const LONG_PRESS_DURATION = 200 // ms
|
|
const hasMovedDuringPress = ref(false)
|
|
|
|
|
|
// Drag desktop - utilise maintenant ghostElement
|
|
const dragStart = (event: DragEvent) => {
|
|
if (event.dataTransfer && cardElement.value) {
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
event.dataTransfer.setData('application/json', JSON.stringify(props.track));
|
|
|
|
// Créer une image transparente pour masquer l'image par défaut du navigateur
|
|
const img = new Image();
|
|
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
|
event.dataTransfer.setDragImage(img, 0, 0);
|
|
|
|
// Activer le clone fantôme
|
|
isDragging.value = true
|
|
touchClone.value = {
|
|
x: event.clientX,
|
|
y: event.clientY
|
|
}
|
|
}
|
|
};
|
|
|
|
// Nouveau: suivre le mouvement de la souris pendant le drag
|
|
const dragMove = (event: DragEvent) => {
|
|
if (isDragging.value && touchClone.value && event.clientX !== 0 && event.clientY !== 0) {
|
|
touchClone.value = {
|
|
x: event.clientX,
|
|
y: event.clientY
|
|
}
|
|
}
|
|
}
|
|
|
|
const instance = getCurrentInstance();
|
|
const dragEnd = (event: DragEvent) => {
|
|
isDragging.value = false
|
|
touchClone.value = null
|
|
|
|
if (event.dataTransfer?.dropEffect === 'move' && instance?.vnode?.el?.parentNode) {
|
|
instance.vnode.el.parentNode.removeChild(instance.vnode.el);
|
|
const parent = instance.parent;
|
|
if (parent?.update) {
|
|
parent.update();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Touch events
|
|
const touchStart = (event: TouchEvent) => {
|
|
const touch = event.touches[0];
|
|
if (!touch) return;
|
|
|
|
touchStartPos.value = { x: touch.clientX, y: touch.clientY };
|
|
hasMovedDuringPress.value = false;
|
|
|
|
// Démarrer un timer pour le long press
|
|
longPressTimer.value = window.setTimeout(() => {
|
|
startTouchDrag(touch);
|
|
}, LONG_PRESS_DURATION);
|
|
}
|
|
|
|
const startTouchDrag = (touch: Touch) => {
|
|
if (!touch) return;
|
|
|
|
isDragging.value = true;
|
|
touchClone.value = {
|
|
x: touch.clientX,
|
|
y: touch.clientY
|
|
};
|
|
|
|
// Vibration feedback si disponible
|
|
if (navigator.vibrate) {
|
|
navigator.vibrate(50);
|
|
}
|
|
}
|
|
|
|
const touchMove = (event: TouchEvent) => {
|
|
const touch = event.touches[0];
|
|
if (!touch || !longPressTimer.value) return;
|
|
|
|
// Annuler le long press si l'utilisateur bouge trop
|
|
const dx = touch.clientX - (touchStartPos.value?.x || 0);
|
|
const dy = touch.clientY - (touchStartPos.value?.y || 0);
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (distance > 10) { // Seuil de tolérance pour un tap
|
|
clearTimeout(longPressTimer.value)
|
|
longPressTimer.value = null
|
|
hasMovedDuringPress.value = true
|
|
}
|
|
|
|
if (isDragging.value && touchClone.value) {
|
|
event.preventDefault()
|
|
const touch = event.touches[0]
|
|
touchClone.value = {
|
|
x: touch.clientX,
|
|
y: touch.clientY
|
|
}
|
|
|
|
// Déterminer l'élément sous le doigt
|
|
checkDropTarget(touch.clientX, touch.clientY)
|
|
}
|
|
}
|
|
|
|
const touchEnd = (event: TouchEvent) => {
|
|
// Annuler le timer de long press
|
|
if (longPressTimer.value) {
|
|
clearTimeout(longPressTimer.value);
|
|
longPressTimer.value = null;
|
|
}
|
|
|
|
// Vérifier si c'était un tap simple (pas de déplacement)
|
|
if (!hasMovedDuringPress.value && touchStartPos.value) {
|
|
const touch = event.changedTouches[0];
|
|
if (touch) {
|
|
const dx = touch.clientX - touchStartPos.value.x;
|
|
const dy = touch.clientY - touchStartPos.value.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (distance < 10) { // Seuil de tolérance pour un tap
|
|
handleClick(new MouseEvent('click'));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Réinitialiser l'état de glisser-déposer
|
|
if (isDragging.value) {
|
|
// Vérifier si on est au-dessus d'une cible de dépôt
|
|
const touch = event.changedTouches[0];
|
|
if (touch) {
|
|
checkDropTarget(touch.clientX, touch.clientY);
|
|
}
|
|
}
|
|
|
|
// Nettoyer
|
|
isDragging.value = false;
|
|
touchClone.value = null;
|
|
touchStartPos.value = null;
|
|
hasMovedDuringPress.value = false;
|
|
}
|
|
|
|
const checkDropTarget = (x: number, y: number): HTMLElement | null => {
|
|
const element = document.elementFromPoint(x, y);
|
|
if (element) {
|
|
const dropZone = element.closest('[data-drop-zone]');
|
|
if (dropZone) {
|
|
return dropZone as HTMLElement;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Cleanup
|
|
onUnmounted(() => {
|
|
if (longPressTimer.value) {
|
|
clearTimeout(longPressTimer.value)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.label {
|
|
@apply rounded-full size-7 p-2 bg-esyellow leading-3 -mt-6;
|
|
font-weight: bold;
|
|
text-align: center;
|
|
}
|
|
|
|
.♠,
|
|
.♣,
|
|
.♦,
|
|
.♥ {
|
|
@apply text-5xl size-14;
|
|
}
|
|
|
|
/* Flip effect */
|
|
.card {
|
|
perspective: 1000px;
|
|
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
|
|
touch-action: none;
|
|
|
|
.flip-inner {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
transition: transform 0.6s;
|
|
transform-style: preserve-3d;
|
|
transform-origin: center;
|
|
}
|
|
|
|
.face-down,
|
|
.face-up {
|
|
position: absolute;
|
|
width: 100%;
|
|
height: 100%;
|
|
backface-visibility: hidden;
|
|
will-change: transform;
|
|
background-color: rgba(255, 255, 255, 0.5);
|
|
}
|
|
|
|
.face-up {
|
|
transform: rotateY(0deg);
|
|
transition: box-shadow 0.6s;
|
|
}
|
|
|
|
.face-down {
|
|
transform: rotateY(-180deg);
|
|
}
|
|
|
|
&.face-down .flip-inner {
|
|
transform: rotateY(180deg);
|
|
}
|
|
|
|
&.face-up .flip-inner {
|
|
transform: rotateY(0deg);
|
|
}
|
|
|
|
&.face-down:hover {
|
|
.play-button {
|
|
opacity: 1;
|
|
}
|
|
|
|
.flip-inner {
|
|
transform: rotateY(170deg);
|
|
}
|
|
}
|
|
|
|
&:focus,
|
|
&.current-track {
|
|
@apply z-50 scale-110;
|
|
outline: none;
|
|
|
|
.face-up {
|
|
@apply shadow-2xl;
|
|
transition:
|
|
box-shadow 0.6s,
|
|
transform 0.6s;
|
|
}
|
|
}
|
|
|
|
&:focus,
|
|
&.current-track {
|
|
.play-button {
|
|
@apply opacity-100;
|
|
}
|
|
}
|
|
|
|
.play-button {
|
|
opacity: 0;
|
|
}
|
|
|
|
.face-up:hover {
|
|
.play-button {
|
|
opacity: 1;
|
|
}
|
|
|
|
.flip-inner {
|
|
transform: rotateY(-170deg);
|
|
}
|
|
}
|
|
|
|
.play-button {
|
|
@apply absolute bottom-1/2 top-24 opacity-0 hover:opacity-100;
|
|
}
|
|
|
|
.pochette:active,
|
|
.face-down:active {
|
|
.play-button {
|
|
@apply scale-90;
|
|
}
|
|
}
|
|
|
|
&.is-dragging {
|
|
@apply opacity-50 scale-95 rotate-6;
|
|
cursor: grabbing !important;
|
|
|
|
.face-up {
|
|
@apply shadow-2xl;
|
|
}
|
|
|
|
.play-button,
|
|
.card-body {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* Ghost card styles - maintenant unifié pour souris et tactile */
|
|
.ghost-card {
|
|
transition: none;
|
|
|
|
.card {
|
|
@apply shadow-2xl scale-95 rotate-6;
|
|
|
|
.play-button,
|
|
.card-body {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
.flip-inner {
|
|
perspective: 1000px;
|
|
}
|
|
}
|
|
</style> |