bucket + card sharer
This commit is contained in:
@@ -1,17 +1,19 @@
|
||||
<template>
|
||||
<article :role="props.role" @click.stop="cardClick" @keydown.enter.stop="cardClick" draggable="true"
|
||||
@dragstart="dragStart" @dragend="dragEnd" @drag="dragMove" @keydown.space.prevent.stop="cardClick"
|
||||
@touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd" class="card" :class="[
|
||||
isFaceUp ? 'face-up' : 'face-down',
|
||||
{ 'current-track': playerStore.currentTrack?.id === track.id },
|
||||
{ 'is-dragging': isDragging }
|
||||
]">
|
||||
<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 left-7">
|
||||
<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`" />
|
||||
@@ -43,16 +45,20 @@
|
||||
<h2 class="select-text text-sm text-neutral-500 first-letter:uppercase truncate">
|
||||
{{ props.track.title || 'title' }}
|
||||
</h2>
|
||||
<p v-if="isPlaylistTrack" class="select-text text-base text-neutral-800 font-bold capitalize truncate">
|
||||
{{ props.track.artist.name || 'artist' }}
|
||||
<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">
|
||||
<figure class="h-full flex text-center rounded-xl justify-center cursor-pointer"
|
||||
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" />
|
||||
@@ -90,26 +96,105 @@
|
||||
</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 { useDataStore } from '~/store/data'
|
||||
import { useNuxtApp } from '#app'
|
||||
import ModalSharer from '~/components/ui/ModalSharer.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
track?: Track;
|
||||
track: Track;
|
||||
isFaceUp?: boolean;
|
||||
role?: string;
|
||||
tabindex?: string | number;
|
||||
'onUpdate:isFaceUp'?: (value: boolean) => void;
|
||||
}>(), {
|
||||
track: () => {
|
||||
const dataStore = useDataStore();
|
||||
return dataStore.getRandomPlaylistTrack() || {} as Track;
|
||||
},
|
||||
isFaceUp: true,
|
||||
role: 'button'
|
||||
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)
|
||||
@@ -130,13 +215,6 @@ const longPressTimer = ref<number | null>(null)
|
||||
const LONG_PRESS_DURATION = 200 // ms
|
||||
const hasMovedDuringPress = ref(false)
|
||||
|
||||
const cardClick = () => {
|
||||
if (!isDragging.value && !hasMovedDuringPress.value) {
|
||||
console.log('card click')
|
||||
playerStore.playTrack(props.track)
|
||||
}
|
||||
hasMovedDuringPress.value = false
|
||||
}
|
||||
|
||||
// Drag desktop - utilise maintenant ghostElement
|
||||
const dragStart = (event: DragEvent) => {
|
||||
@@ -184,42 +262,46 @@ const dragEnd = (event: DragEvent) => {
|
||||
|
||||
// Touch events
|
||||
const touchStart = (event: TouchEvent) => {
|
||||
const touch = event.touches[0]
|
||||
touchStartPos.value = { x: touch.clientX, y: touch.clientY }
|
||||
hasMovedDuringPress.value = false
|
||||
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)
|
||||
startTouchDrag(touch);
|
||||
}, LONG_PRESS_DURATION);
|
||||
}
|
||||
|
||||
const startTouchDrag = (touch: Touch) => {
|
||||
isDragging.value = true
|
||||
if (!touch) return;
|
||||
|
||||
isDragging.value = true;
|
||||
touchClone.value = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY
|
||||
}
|
||||
};
|
||||
|
||||
// Vibration feedback si disponible
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50)
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
}
|
||||
|
||||
const touchMove = (event: TouchEvent) => {
|
||||
if (longPressTimer.value) {
|
||||
// Annuler le long press si l'utilisateur bouge trop
|
||||
const touch = event.touches[0]
|
||||
const dx = touch.clientX - (touchStartPos.value?.x || 0)
|
||||
const dy = touch.clientY - (touchStartPos.value?.y || 0)
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
const touch = event.touches[0];
|
||||
if (!touch || !longPressTimer.value) return;
|
||||
|
||||
if (distance > 10) {
|
||||
clearTimeout(longPressTimer.value)
|
||||
longPressTimer.value = null
|
||||
hasMovedDuringPress.value = true
|
||||
}
|
||||
// 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) {
|
||||
@@ -236,63 +318,51 @@ const touchMove = (event: TouchEvent) => {
|
||||
}
|
||||
|
||||
const touchEnd = (event: TouchEvent) => {
|
||||
// Annuler le timer de long press
|
||||
if (longPressTimer.value) {
|
||||
clearTimeout(longPressTimer.value)
|
||||
longPressTimer.value = null
|
||||
clearTimeout(longPressTimer.value);
|
||||
longPressTimer.value = null;
|
||||
}
|
||||
|
||||
if (isDragging.value) {
|
||||
event.preventDefault()
|
||||
const touch = event.changedTouches[0]
|
||||
// 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);
|
||||
|
||||
// Trouver l'élément de drop
|
||||
const dropTarget = document.elementFromPoint(touch.clientX, touch.clientY)
|
||||
const bucket = dropTarget?.closest('.bucket')
|
||||
|
||||
// Nettoyer les classes de drop target
|
||||
document.querySelectorAll('.bucket').forEach(b => {
|
||||
b.classList.remove('drop-target-active')
|
||||
})
|
||||
|
||||
if (bucket) {
|
||||
// Émettre un événement personnalisé pour le bucket
|
||||
const dropEvent = new CustomEvent('card-dropped-touch', {
|
||||
detail: props.track,
|
||||
bubbles: true
|
||||
})
|
||||
bucket.dispatchEvent(dropEvent)
|
||||
|
||||
// Vibration de confirmation
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(100)
|
||||
}
|
||||
|
||||
// Supprimer la carte
|
||||
if (instance?.vnode?.el?.parentNode) {
|
||||
instance.vnode.el.parentNode.removeChild(instance.vnode.el);
|
||||
const parent = instance.parent;
|
||||
if (parent?.update) {
|
||||
parent.update();
|
||||
}
|
||||
if (distance < 10) { // Seuil de tolérance pour un tap
|
||||
handleClick(new MouseEvent('click'));
|
||||
}
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
touchClone.value = null
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const element = document.elementFromPoint(x, y)
|
||||
const bucket = element?.closest('.bucket')
|
||||
|
||||
if (bucket) {
|
||||
bucket.classList.add('drop-target-active')
|
||||
} else {
|
||||
document.querySelectorAll('.bucket').forEach(b => {
|
||||
b.classList.remove('drop-target-active')
|
||||
})
|
||||
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
|
||||
@@ -369,9 +439,10 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
&.current-track,
|
||||
&:focus {
|
||||
&:focus,
|
||||
&.current-track {
|
||||
@apply z-50 scale-110;
|
||||
outline: none;
|
||||
|
||||
.face-up {
|
||||
@apply shadow-2xl;
|
||||
@@ -379,7 +450,10 @@ onUnmounted(() => {
|
||||
box-shadow 0.6s,
|
||||
transform 0.6s;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&.current-track {
|
||||
.play-button {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user