bucket + card sharer
This commit is contained in:
@@ -4,22 +4,32 @@
|
||||
<div v-if="tracks.length === 0" class="bucket-empty">
|
||||
Drop cards here
|
||||
</div>
|
||||
<draggable v-else v-model="tracks" item-key="id" class="bucket-cards" @start="onDragStart" @end="onDragEnd"
|
||||
:touch-start-threshold="50">
|
||||
<draggable v-else v-model="tracks" item-key="id" class="bucket-cards" @start="handleDragStart" @end="handleDragEnd"
|
||||
:touch-start-threshold="50" :component-data="{
|
||||
tag: 'div',
|
||||
type: 'transition-group',
|
||||
name: 'list'
|
||||
}">
|
||||
<template #item="{ element: track }">
|
||||
<card :track="track" tabindex="0" is-face-up class="bucket-card" @click="flipCard(track)" />
|
||||
<div class="bucket-card-wrapper">
|
||||
<card :track="track" tabindex="0" is-face-up class="bucket-card"
|
||||
@card-click="playerStore.playPlaylistTrack(track)" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, defineEmits, onMounted } from 'vue'
|
||||
import { ref, defineEmits, onMounted } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useDataStore } from '~/store/data'
|
||||
import { useCardStore } from '~/store/card'
|
||||
import { usePlayerStore } from '~/store/player'
|
||||
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'card-dropped', track: any): void
|
||||
(e: 'update:modelValue', value: any[]): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -27,28 +37,40 @@ const props = defineProps<{
|
||||
boxId?: string
|
||||
}>()
|
||||
|
||||
const dataStore = useDataStore()
|
||||
const cardStore = useCardStore()
|
||||
const playerStore = usePlayerStore()
|
||||
const isDragOver = ref(false)
|
||||
const drag = ref(false)
|
||||
const tracks = ref<any[]>(props.modelValue || [])
|
||||
const bucket = ref()
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (newValue) {
|
||||
tracks.value = [...newValue]
|
||||
// Utilisation du bucket du store comme source de vérité
|
||||
const tracks = computed({
|
||||
get: () => cardStore.bucket,
|
||||
set: (value) => {
|
||||
// Update the store when the order changes
|
||||
cardStore.updateBucketOrder(value)
|
||||
}
|
||||
})
|
||||
|
||||
if (props.boxId) {
|
||||
onMounted(async () => {
|
||||
await dataStore.loadData()
|
||||
if (props.boxId) {
|
||||
tracks.value = dataStore.getTracksByboxId(props.boxId)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Charger les données du localStorage au montage
|
||||
onMounted(() => {
|
||||
cardStore.loadBucketFromLocalStorage()
|
||||
})
|
||||
|
||||
// Gestion du drag and drop desktop
|
||||
const handleDragStart = (event: { item: HTMLElement }) => {
|
||||
drag.value = true
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: { item: HTMLElement; newIndex: number; oldIndex: number }) => {
|
||||
drag.value = false
|
||||
isDragOver.value = false
|
||||
// Update the store with the new order if the position changed
|
||||
if (event.newIndex !== event.oldIndex) {
|
||||
// The store will handle the reordering automatically through the v-model binding
|
||||
}
|
||||
}
|
||||
|
||||
const onDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
isDragOver.value = true
|
||||
@@ -63,34 +85,19 @@ const onDragLeave = () => {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const onDragStart = () => {
|
||||
drag.value = true
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
drag.value = false
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
isDragOver.value = false
|
||||
const cardData = e.dataTransfer?.getData('application/json')
|
||||
if (cardData) {
|
||||
try {
|
||||
const track = JSON.parse(cardData)
|
||||
if (!tracks.value.some(t => t.id === track.id)) {
|
||||
tracks.value.push(track)
|
||||
}
|
||||
cardStore.addToBucket(track)
|
||||
} catch (e) {
|
||||
console.error('Erreur lors du traitement de la carte déposée', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const flipCard = (track: any) => {
|
||||
track.isFaceUp = !track.isFaceUp
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Écouter aussi les événements tactiles personnalisés
|
||||
bucket.value?.addEventListener('card-dropped-touch', (e: CustomEvent) => {
|
||||
@@ -99,14 +106,12 @@ onMounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
.bucket {
|
||||
min-height: 200px;
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
@@ -125,9 +130,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.bucket-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -145,4 +149,19 @@ onMounted(() => {
|
||||
.bucket-card:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.bucket-card-wrapper {
|
||||
width: 70px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.bucket:hover,
|
||||
.card-dragging {
|
||||
border: 2px dashed #ccc;
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
|
||||
.bucket-card-wrapper {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,100 +1,38 @@
|
||||
<template>
|
||||
<div class="platine" :class="{ 'drag-over': isDragOver }" @dragenter.prevent="onDragEnter"
|
||||
<div class="platine pointer-events-none" :class="{ 'drag-over': isDragOver }" @dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop.prevent="onDrop">
|
||||
<card v-if="currentTrack" :track="currentTrack" />
|
||||
<div class="disc fixed" ref="discRef" :style="'background-image: url(/card-dock.svg)'" id="disc">
|
||||
<div class="disc pointer-events-auto fixed" ref="discRef" :style="'background-image: url(/card-dock.svg)'"
|
||||
id="disc">
|
||||
<div
|
||||
class="bobine bg-slate-800 bg-opacity-50 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-full"
|
||||
:style="{ height: progressPercentage + '%', width: progressPercentage + '%' }">
|
||||
</div>
|
||||
class="bobine bg-slate-900 bg-opacity-50 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-full"
|
||||
:style="{ height: platineStore.progressPercentage + '%', width: platineStore.progressPercentage + '%' }"></div>
|
||||
|
||||
<div class="disc-label rounded-full bg-cover bg-center">
|
||||
<img src="/favicon.svg" class="size-1/3">
|
||||
<div v-if="isLoadingTrack" class="loading-indicator">
|
||||
<div v-if="platineStore.isLoadingTrack" class="loading-indicator">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isLoadingTrack" class="absolute top-1/2 right-8 size-1/12 rounded-full bg-esyellow">
|
||||
<div class="w-full h-1/5 flex justify-center items-center text-8xl text-white absolute pointer-events-none">
|
||||
{{ platineStore.currentTrack?.title }}
|
||||
<br>
|
||||
{{ platineStore.currentTrack?.artist.name }}
|
||||
</div>
|
||||
<div v-if="!platineStore.isLoadingTrack" class="absolute top-1/2 right-8 size-1/12 rounded-full bg-esyellow">
|
||||
</div>
|
||||
</div>
|
||||
<button class="rewind absolute left-0 bottom-0" @click="toggleMute">mute</button>
|
||||
<button class="power absolute right-0 bottom-0" :class="{ 'is-active': isPlaying }"
|
||||
@click="togglePlay">power</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import Disc from '@/platine-tools/disc'
|
||||
import Sampler from '@/platine-tools/sampler'
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { usePlatineStore } from '~/store/platine'
|
||||
import type { Track } from '~~/types/types'
|
||||
|
||||
const props = withDefaults(defineProps<{ track?: Track }>(), {})
|
||||
const props = defineProps<{ track?: Track }>()
|
||||
const platineStore = usePlatineStore()
|
||||
const discRef = ref<HTMLElement>()
|
||||
const currentTurns = ref(0)
|
||||
const totalTurns = ref(0)
|
||||
const progressPercentage = ref(0)
|
||||
const drag = ref(false)
|
||||
const isDragOver = ref(false)
|
||||
const isFirstDrag = ref(true)
|
||||
const isLoadingTrack = ref(false)
|
||||
const isPlaying = ref(false)
|
||||
const coverUrl = computed(() => currentTrack.value?.coverId || '/card-dock.svg')
|
||||
|
||||
let disc: Disc | null = null
|
||||
let sampler: Sampler | null = null
|
||||
|
||||
const currentTrack = ref()
|
||||
|
||||
const updateTurns = (disc: Disc) => {
|
||||
currentTurns.value = disc.secondsPlayed * 0.75 // 0.75 tours par seconde (RPS)
|
||||
totalTurns.value = (disc as any)._duration * 0.75 // Accès à la propriété _duration privée
|
||||
progressPercentage.value = Math.min(100, (disc.secondsPlayed / (disc as any)._duration) * 100)
|
||||
};
|
||||
|
||||
const startPlayback = () => {
|
||||
if (!disc || !sampler || !currentTrack.value || isPlaying.value) return
|
||||
|
||||
isPlaying.value = true
|
||||
sampler.play(disc.secondsPlayed)
|
||||
disc.powerOn()
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!disc || !sampler || !currentTrack.value) return
|
||||
|
||||
isPlaying.value = !isPlaying.value
|
||||
|
||||
if (isPlaying.value) {
|
||||
startPlayback()
|
||||
} else {
|
||||
disc.powerOff()
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (!sampler) return
|
||||
|
||||
sampler.mute()
|
||||
}
|
||||
|
||||
const loadTrack = async (track: Track) => {
|
||||
if (!sampler || !track) return
|
||||
|
||||
currentTrack.value = track
|
||||
isLoadingTrack.value = true
|
||||
try {
|
||||
await sampler.loadTrack(currentTrack.value.url)
|
||||
if (disc) {
|
||||
disc.setDuration(sampler.duration)
|
||||
updateTurns(disc)
|
||||
sampler.play(0)
|
||||
disc.secondsPlayed = 0
|
||||
disc.powerOn()
|
||||
}
|
||||
} finally {
|
||||
isLoadingTrack.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion du drag and drop
|
||||
const onDragEnter = (e: DragEvent) => {
|
||||
@@ -111,80 +49,41 @@ const onDragLeave = () => {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const onDragStart = () => {
|
||||
drag.value = true
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
drag.value = false
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
isDragOver.value = false
|
||||
const cardData = e.dataTransfer?.getData('application/json')
|
||||
|
||||
if (cardData) {
|
||||
try {
|
||||
const newTrack = JSON.parse(cardData)
|
||||
if (newTrack && newTrack.url) {
|
||||
loadTrack(newTrack)
|
||||
disc?.powerOn()
|
||||
platineStore.loadTrack(newTrack)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du traitement de la carte déposée', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
disc = new Disc(discRef.value!)
|
||||
sampler = new Sampler()
|
||||
|
||||
disc.callbacks.onStop = () => {
|
||||
sampler?.pause()
|
||||
}
|
||||
|
||||
disc.callbacks.onDragStart = () => {
|
||||
if (isFirstDrag.value) {
|
||||
isFirstDrag.value = false
|
||||
isPlaying.value = true
|
||||
if (sampler && disc) {
|
||||
sampler.play(disc.secondsPlayed)
|
||||
disc.powerOn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disc.callbacks.onDragEnded = () => {
|
||||
if (!isPlaying.value) {
|
||||
return
|
||||
}
|
||||
sampler?.play(disc?.secondsPlayed)
|
||||
}
|
||||
|
||||
disc.callbacks.onLoop = ({ playbackSpeed, isReversed, secondsPlayed }) => {
|
||||
sampler?.updateSpeed(playbackSpeed, isReversed, secondsPlayed);
|
||||
if (disc) {
|
||||
updateTurns(disc);
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
watch(() => props.track, (propTrack) => {
|
||||
if (propTrack) {
|
||||
loadTrack(propTrack)
|
||||
// Initialisation du lecteur
|
||||
onMounted(() => {
|
||||
if (discRef.value) {
|
||||
platineStore.initPlatine(discRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Nettoyage
|
||||
onUnmounted(() => {
|
||||
if (disc) {
|
||||
disc.stop();
|
||||
disc.powerOff();
|
||||
platineStore.cleanup()
|
||||
})
|
||||
|
||||
// Surveillance des changements de piste
|
||||
watch(() => props.track, (newTrack) => {
|
||||
if (newTrack) {
|
||||
platineStore.loadTrack(newTrack)
|
||||
}
|
||||
if (sampler) {
|
||||
sampler.pause();
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -198,6 +97,7 @@ onUnmounted(() => {
|
||||
.card {
|
||||
position: absolute !important;
|
||||
z-index: 99;
|
||||
top: -20%;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform: translate(-50%, 50%);
|
||||
@@ -307,4 +207,17 @@ onUnmounted(() => {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.bobine {
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<button
|
||||
class="play-button rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-200 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold"
|
||||
<button tabindex="-1"
|
||||
class="play-button pointer-events-none rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-200 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold"
|
||||
:class="{ loading: isLoading }" :disabled="isLoading">
|
||||
<template v-if="isLoading">
|
||||
<img src="/loader.svg" alt="Chargement" class="size-16" />
|
||||
@@ -12,40 +12,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePlayerStore } from '~/store/player'
|
||||
import { usePlatineStore } from '~/store/platine'
|
||||
import type { Box, Track } from '~/../types/types'
|
||||
|
||||
const playerStore = usePlayerStore()
|
||||
const platineStore = usePlatineStore()
|
||||
const props = defineProps<{ objectToPlay: Box | Track }>()
|
||||
|
||||
const isCurrentBox = computed(() => {
|
||||
if ('activeSide' in props.objectToPlay) {
|
||||
// Vérifier si la piste courante appartient à cette box
|
||||
if (playerStore.currentTrack?.boxId === props.objectToPlay.id) {
|
||||
// Si c'est une compilation, on vérifie le side actif
|
||||
if (props.objectToPlay.type === 'compilation') {
|
||||
return playerStore.currentTrack.side === props.objectToPlay.activeSide
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const isCurrentTrack = computed(() => {
|
||||
if (!('activeSide' in props.objectToPlay)) {
|
||||
return playerStore.currentTrack?.id === props.objectToPlay.id
|
||||
return platineStore.currentTrack?.id === props.objectToPlay.id
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const isPlaying = computed(() => {
|
||||
return playerStore.isPlaying && (isCurrentTrack.value || isCurrentBox.value)
|
||||
return platineStore.isPlaying && isCurrentTrack.value
|
||||
})
|
||||
|
||||
const isLoading = computed(() => {
|
||||
return playerStore.isLoading && (isCurrentTrack.value || isCurrentBox.value)
|
||||
return platineStore.isLoadingTrack && isCurrentTrack.value
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<template>
|
||||
<div class="flex flex-col fixed right-0 top-0 z-50">
|
||||
<div class="flex flex-col fixed right-0 top-0 z-50" :class="props.class">
|
||||
<button @click="closeDatBox" v-if="uiStore.isBoxSelected"
|
||||
class="px-4 py-2 text-black hover:text-black bg-esyellow transition-colors z-50" aria-label="close the box">
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls flex justify-center z-50 relative">
|
||||
<div class="controls flex justify-center z-50 relative" v-bind="attrs">
|
||||
<SearchInput @search="onSearch" />
|
||||
<SelectCardRank @change="onRankChange" />
|
||||
<SelectCardSuit @change="onSuitChange" />
|
||||
</div>
|
||||
<div ref="deck" class="deck flex flex-wrap justify-center gap-4" :class="{ 'pb-36': playerStore.currentTrack }">
|
||||
<card v-for="(track, i) in filteredTracks" :key="track.id" :track="track" :tabindex="i"
|
||||
:is-face-up="isCardRevealed(track.id)" />
|
||||
@card-click="playerStore.playPlaylistTrack(track)" :is-face-up="isCardRevealed(track.id)"
|
||||
@click-card-symbol="openCardSharer()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -27,10 +28,19 @@ import SelectCardSuit from '~/components/ui/SelectCardSuit.vue'
|
||||
import SelectCardRank from '~/components/ui/SelectCardRank.vue'
|
||||
import SearchInput from '~/components/ui/SearchInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
box: Box
|
||||
// Define the events this component emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', event: MouseEvent): void;
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
box: Box;
|
||||
class?: string;
|
||||
}>()
|
||||
|
||||
// Use useAttrs to get all other attributes
|
||||
const attrs = useAttrs()
|
||||
|
||||
const cardStore = useCardStore()
|
||||
const dataStore = useDataStore()
|
||||
const playerStore = usePlayerStore()
|
||||
@@ -47,12 +57,17 @@ const searchQuery = ref('')
|
||||
|
||||
const isCardRevealed = (trackId: number) => {
|
||||
// Si une recherche est en cours, révéler automatiquement les cartes correspondantes
|
||||
if (searchQuery.value) return true
|
||||
if (searchQuery.value || (selectedRank.value && selectedSuit.value)) return true
|
||||
return cardStore.isCardRevealed(trackId)
|
||||
}
|
||||
|
||||
const closeDatBox = () => {
|
||||
const closeDatBox = (event: MouseEvent) => {
|
||||
uiStore.closeBox()
|
||||
emit('click', event)
|
||||
}
|
||||
|
||||
const openCardSharer = () => {
|
||||
uiStore.openCardSharer()
|
||||
}
|
||||
|
||||
const onSuitChange = (suit: string) => {
|
||||
@@ -107,9 +122,4 @@ const applyFilters = () => {
|
||||
.deck {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.docked {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
127
app/components/ui/ModalSharer.vue
Normal file
127
app/components/ui/ModalSharer.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
@click.self="close">
|
||||
<div class="bg-white rounded-xl shadow-2xl p-6 w-full max-w-md">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900">Partager cette carte</h2>
|
||||
<button @click="close" class="text-gray-400 hover:text-gray-500">
|
||||
<span class="sr-only">Fermer</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="currentTrack" class="space-y-4">
|
||||
<div class="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg">
|
||||
<img :src="currentTrack.coverId || '/card-dock.svg'" :alt="currentTrack.title"
|
||||
class="w-12 h-12 rounded-md object-cover">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ currentTrack.title }}</p>
|
||||
<p class="text-sm text-gray-500 truncate">{{ typeof currentTrack.artist === 'object' ?
|
||||
currentTrack.artist?.name : currentTrack.artist || 'Artiste inconnu' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="share-link" class="block text-sm font-medium text-gray-700">Lien de partage</label>
|
||||
<div class="flex rounded-md shadow-sm">
|
||||
<input type="text" id="share-link" readonly :value="shareLink"
|
||||
class="flex-1 min-w-0 block w-full px-3 py-2 rounded-l-md border border-gray-300 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
@focus="selectText">
|
||||
<button @click="copyToClipboard"
|
||||
class="inline-flex items-center px-3 py-2 border border-l-0 border-gray-300 bg-gray-50 text-gray-700 text-sm font-medium rounded-r-md hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-2">
|
||||
<button @click="close"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useUiStore } from '~/store/ui'
|
||||
import type { Track } from '~~/types/types'
|
||||
|
||||
const uiStore = useUiStore()
|
||||
const currentTrack = ref<Track | null>(null)
|
||||
|
||||
// Utilisation d'une ref locale pour éviter les réactivités inutiles
|
||||
const isOpen = ref(false)
|
||||
|
||||
// Mise à jour de l'état uniquement quand nécessaire
|
||||
watch(() => uiStore.showCardSharer, (newVal) => {
|
||||
isOpen.value = newVal
|
||||
})
|
||||
|
||||
const shareLink = computed(() => {
|
||||
if (!currentTrack.value) return ''
|
||||
return `${window.location.origin}/track/${currentTrack.value.id}`
|
||||
})
|
||||
|
||||
const open = (track: Track) => {
|
||||
currentTrack.value = track
|
||||
isOpen.value = true
|
||||
uiStore.openCardSharer()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false
|
||||
uiStore.showCardSharer = false
|
||||
// Nettoyage différé pour permettre l'animation
|
||||
setTimeout(() => {
|
||||
if (!isOpen.value) {
|
||||
currentTrack.value = null
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareLink.value)
|
||||
// Vous pourriez ajouter un toast ou une notification ici
|
||||
console.log('Lien copié dans le presse-papier')
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la copie :', err)
|
||||
}
|
||||
}
|
||||
|
||||
const selectText = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
input.select()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user