draggable / touchable card v0.1
This commit is contained in:
@@ -6,9 +6,9 @@
|
||||
<template v-if="box.type === 'compilation'">
|
||||
<playButton @click.stop="playSelectedBox(box)" :objectToPlay="box" class="relative z-40 m-auto" />
|
||||
<deckCompilation :box="getBoxToDisplay(box)" class="box-page" :key="`${box.id}-${box.activeSide}`"
|
||||
@click.stop="" />
|
||||
@click.stop />
|
||||
</template>
|
||||
<deckPlaylist :box="box" class="box-page" v-if="box.type === 'playlist'" @click.stop="" />
|
||||
<deckPlaylist :box="box" class="box-page" v-if="box.type === 'playlist'" @click.stop />
|
||||
</template>
|
||||
</box>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="bucket" :class="{ 'drag-over': isDragOver }" @dragenter.prevent="onDragEnter"
|
||||
<div class="bucket" ref="bucket" :class="{ 'drag-over': isDragOver }" @dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop.prevent="onDrop">
|
||||
<div v-if="tracks.length === 0" class="bucket-empty">
|
||||
Drop cards here
|
||||
@@ -14,10 +14,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, defineEmits, onMounted } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useDataStore } from '~/store/data'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'card-dropped', track: any): void
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: any[]
|
||||
boxId?: string
|
||||
@@ -27,6 +31,7 @@ const dataStore = useDataStore()
|
||||
const isDragOver = ref(false)
|
||||
const drag = ref(false)
|
||||
const tracks = ref<any[]>(props.modelValue || [])
|
||||
const bucket = ref()
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (newValue) {
|
||||
@@ -37,7 +42,9 @@ watch(() => props.modelValue, (newValue) => {
|
||||
if (props.boxId) {
|
||||
onMounted(async () => {
|
||||
await dataStore.loadData()
|
||||
tracks.value = dataStore.getTracksByboxId(props.boxId)
|
||||
if (props.boxId) {
|
||||
tracks.value = dataStore.getTracksByboxId(props.boxId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,6 +90,13 @@ const onDrop = (e: DragEvent) => {
|
||||
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) => {
|
||||
emit('card-dropped', e.detail)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<template>
|
||||
<article class="card w-56 h-80 min-w-56 min-h-80" :class="[
|
||||
isFaceUp ? 'face-up' : 'face-down',
|
||||
{ 'current-track': playerStore.currentTrack?.id === track.id }
|
||||
]">
|
||||
<div class="flip-inner">
|
||||
<article role="button" @click.stop="cardClick" @keydown.enter.stop="cardClick" @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 }
|
||||
]">
|
||||
<div class="flip-inner" ref="cardElement">
|
||||
<!-- Face-Up -->
|
||||
<main draggable="true" @dragstart="dragStart" @dragend="dragEnd"
|
||||
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">
|
||||
<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`" />
|
||||
@@ -25,38 +27,31 @@
|
||||
</div>
|
||||
|
||||
<!-- Cover -->
|
||||
|
||||
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer"
|
||||
@click="playerStore.playTrack(track)">
|
||||
<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="p-3 text-center bg-white rounded-b-xl opacity-0 -mt-16 hover:opacity-100 hover:-mt-0 transition-all duration-300">
|
||||
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 }}
|
||||
{{ 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 }}
|
||||
{{ props.track.artist.name || 'artist' }}
|
||||
</p>
|
||||
<!-- <p class="select-text">
|
||||
{{ props.track.url.split('/')[4]?.split('__')[0] }}
|
||||
</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 @click.stop="playerStore.playTrack(track)"
|
||||
class="h-full flex text-center rounded-xl justify-center cursor-pointer"
|
||||
<figure class="h-full flex text-center rounded-xl justify-center cursor-pointer"
|
||||
:style="{ backgroundColor: cardColor }">
|
||||
<playButton :objectToPlay="track" />
|
||||
<img draggable="false" src="/face-down.svg" />
|
||||
@@ -64,6 +59,36 @@
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Clone fantôme pour le drag 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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -91,17 +116,166 @@ 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 pour le 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)
|
||||
|
||||
const cardClick = () => {
|
||||
if (!isDragging.value && !hasMovedDuringPress.value) {
|
||||
console.log('card click')
|
||||
playerStore.playTrack(props.track)
|
||||
}
|
||||
hasMovedDuringPress.value = false
|
||||
}
|
||||
|
||||
// Drag desktop
|
||||
const dragStart = (event: DragEvent) => {
|
||||
if (event.dataTransfer) {
|
||||
if (event.dataTransfer && cardElement.value) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('application/json', JSON.stringify(props.track));
|
||||
isDragging.value = true
|
||||
}
|
||||
};
|
||||
|
||||
const instance = getCurrentInstance();
|
||||
const dragEnd = (event: DragEvent) => {
|
||||
console.log('drag end');
|
||||
isDragging.value = false
|
||||
|
||||
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]
|
||||
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) => {
|
||||
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) => {
|
||||
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)
|
||||
|
||||
if (distance > 10) {
|
||||
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) => {
|
||||
if (longPressTimer.value) {
|
||||
clearTimeout(longPressTimer.value)
|
||||
longPressTimer.value = null
|
||||
}
|
||||
|
||||
if (isDragging.value) {
|
||||
event.preventDefault()
|
||||
const touch = event.changedTouches[0]
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isDragging.value = false
|
||||
touchClone.value = null
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
onUnmounted(() => {
|
||||
if (longPressTimer.value) {
|
||||
clearTimeout(longPressTimer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -121,7 +295,8 @@ const dragEnd = (event: DragEvent) => {
|
||||
/* Flip effect */
|
||||
.card {
|
||||
perspective: 1000px;
|
||||
@apply transition-all scale-100;
|
||||
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
|
||||
touch-action: none;
|
||||
|
||||
.flip-inner {
|
||||
position: relative;
|
||||
@@ -171,7 +346,7 @@ const dragEnd = (event: DragEvent) => {
|
||||
|
||||
&.current-track,
|
||||
&:focus {
|
||||
@apply z-50;
|
||||
@apply z-50 scale-110;
|
||||
|
||||
.face-up {
|
||||
@apply shadow-2xl;
|
||||
@@ -180,7 +355,9 @@ const dragEnd = (event: DragEvent) => {
|
||||
transform 0.6s;
|
||||
}
|
||||
|
||||
@apply scale-110;
|
||||
.play-button {
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
.play-button {
|
||||
@@ -207,5 +384,37 @@ const dragEnd = (event: DragEvent) => {
|
||||
@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 */
|
||||
.ghost-card {
|
||||
transition: none;
|
||||
|
||||
.card {
|
||||
@apply shadow-2xl scale-95 rotate-6;
|
||||
|
||||
.play-button,
|
||||
.card-body {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.flip-inner {
|
||||
perspective: 1000px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="platine" :class="{ 'drag-over': isDragOver }" @dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop.prevent="onDrop">
|
||||
<div class="disc" ref="discRef" :style="'background-image: url(' + coverUrl + ')'" id="disc">
|
||||
<card v-if="currentTrack" :track="currentTrack" />
|
||||
<div class="disc 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 + '%' }">
|
||||
@@ -37,12 +38,13 @@ const isDragOver = ref(false)
|
||||
const isFirstDrag = ref(true)
|
||||
const isLoadingTrack = ref(false)
|
||||
const isPlaying = ref(false)
|
||||
|
||||
const coverUrl = computed(() => props.track?.coverId || '/card-dock.svg')
|
||||
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
|
||||
@@ -50,7 +52,7 @@ const updateTurns = (disc: Disc) => {
|
||||
};
|
||||
|
||||
const startPlayback = () => {
|
||||
if (!disc || !sampler || !props.track || isPlaying.value) return
|
||||
if (!disc || !sampler || !currentTrack.value || isPlaying.value) return
|
||||
|
||||
isPlaying.value = true
|
||||
sampler.play(disc.secondsPlayed)
|
||||
@@ -58,7 +60,7 @@ const startPlayback = () => {
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!disc || !sampler || !props.track) return
|
||||
if (!disc || !sampler || !currentTrack.value) return
|
||||
|
||||
isPlaying.value = !isPlaying.value
|
||||
|
||||
@@ -75,27 +77,19 @@ const toggleMute = () => {
|
||||
sampler.mute()
|
||||
}
|
||||
|
||||
const handleRewind = () => {
|
||||
if (!disc || !sampler || !props.track) return
|
||||
|
||||
sampler.pause()
|
||||
disc.rewind()
|
||||
if (isPlaying.value) {
|
||||
sampler.play(0)
|
||||
}
|
||||
};
|
||||
|
||||
const loadTrack = async (track: Track) => {
|
||||
if (!sampler || !track) return
|
||||
|
||||
currentTrack.value = track
|
||||
isLoadingTrack.value = true
|
||||
try {
|
||||
await sampler.loadTrack(track.url)
|
||||
await sampler.loadTrack(currentTrack.value.url)
|
||||
if (disc) {
|
||||
disc.setDuration(sampler.duration)
|
||||
updateTurns(disc)
|
||||
sampler.play(0)
|
||||
disc.secondsPlayed = 0
|
||||
disc.powerOn()
|
||||
disc.powerOff()
|
||||
}
|
||||
} finally {
|
||||
isLoadingTrack.value = false
|
||||
@@ -134,6 +128,7 @@ const onDrop = (e: DragEvent) => {
|
||||
const newTrack = JSON.parse(cardData)
|
||||
if (newTrack && newTrack.url) {
|
||||
loadTrack(newTrack)
|
||||
disc?.powerOn()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du traitement de la carte déposée', error)
|
||||
@@ -175,9 +170,9 @@ onMounted(async () => {
|
||||
|
||||
})
|
||||
|
||||
watch(() => props.track, (newTrack) => {
|
||||
if (newTrack) {
|
||||
loadTrack(newTrack)
|
||||
watch(() => props.track, (propTrack) => {
|
||||
if (propTrack) {
|
||||
loadTrack(propTrack)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -199,6 +194,14 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
|
||||
.card {
|
||||
position: absolute !important;
|
||||
z-index: 99;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform: translate(-50%, 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.disc {
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls flex justify-center z-50 relative">
|
||||
<button class="px-4 py-2 text-black hover:text-black bg-esyellow transition-colors" @click="toggleAllCards">
|
||||
{{ allCardsRevealed ? 'Hide All' : 'Reveal All' }}
|
||||
</button>
|
||||
<SearchInput @search="onSearch" />
|
||||
<SelectCardRank @change="onRankChange" />
|
||||
<SelectCardSuit @change="onSuitChange" />
|
||||
@@ -17,10 +14,6 @@
|
||||
<card v-for="(track, i) in filteredTracks" :key="track.id" :track="track" :tabindex="i"
|
||||
:is-face-up="isCardRevealed(track.id)" />
|
||||
</div>
|
||||
<div class="dock">
|
||||
<Bucket @card-dropped="onCardDropped" />
|
||||
<Platine />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -52,20 +45,10 @@ const selectedSuit = ref('')
|
||||
const selectedRank = ref('')
|
||||
const searchQuery = ref('')
|
||||
|
||||
const isCardRevealed = (trackId: number) => cardStore.isCardRevealed(trackId)
|
||||
|
||||
// Vérifie si toutes les cartes sont révélées
|
||||
const allCardsRevealed = computed(() => {
|
||||
return tracks.value.every(track => cardStore.isCardRevealed(track.id))
|
||||
})
|
||||
|
||||
// Fonction pour basculer l'état de toutes les cartes
|
||||
const toggleAllCards = () => {
|
||||
if (allCardsRevealed.value) {
|
||||
cardStore.hideAllCards(tracks.value)
|
||||
} else {
|
||||
cardStore.revealAllCards(tracks.value)
|
||||
}
|
||||
const isCardRevealed = (trackId: number) => {
|
||||
// Si une recherche est en cours, révéler automatiquement les cartes correspondantes
|
||||
if (searchQuery.value) return true
|
||||
return cardStore.isCardRevealed(trackId)
|
||||
}
|
||||
|
||||
const closeDatBox = () => {
|
||||
@@ -77,11 +60,6 @@ const onSuitChange = (suit: string) => {
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const onCardDropped = (card: Track) => {
|
||||
console.log('Carte déposée dans le bucket:', card)
|
||||
// Vous pouvez ajouter ici la logique pour supprimer la carte de la liste actuelle si nécessaire
|
||||
}
|
||||
|
||||
const onRankChange = (rank: string) => {
|
||||
selectedRank.value = rank
|
||||
applyFilters()
|
||||
@@ -124,3 +102,14 @@ const applyFilters = () => {
|
||||
filteredTracks.value = result
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.deck {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.docked {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,117 +0,0 @@
|
||||
<template>
|
||||
<div class="deck">
|
||||
<draggable v-model="tracks" item-key="id" class="draggable-container" @start="drag = true" @end="onDragEnd">
|
||||
<template #item="{ element: track }">
|
||||
<card :key="track.id" :track="track" tabindex="0" is-face-up class="draggable-item" @click="flipCard(track)" />
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDataStore } from '~/store/data'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
const drag = ref(false)
|
||||
const tracks = ref([])
|
||||
// Configuration du layout
|
||||
definePageMeta({
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const dataStore = useDataStore()
|
||||
await dataStore.loadData()
|
||||
tracks.value = dataStore.getTracksByboxId('ESPLAYLIST')
|
||||
})
|
||||
|
||||
function flipCard(track) {
|
||||
track.isFaceUp = !track.isFaceUp
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
drag.value = false
|
||||
// Ici vous pouvez ajouter une logique supplémentaire après le drop si nécessaire
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo {
|
||||
filter: drop-shadow(3px 3px 0 rgb(0 0 0 / 0.7));
|
||||
}
|
||||
|
||||
.deck {
|
||||
position: relative;
|
||||
height: 80vh;
|
||||
|
||||
.draggable-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.draggable-item {
|
||||
cursor: grab;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&.sortable-ghost {
|
||||
opacity: 0.5;
|
||||
background: #c8ebfb;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
&.sortable-drag {
|
||||
transform: rotate(2deg);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* noise tools */
|
||||
$size: 130px;
|
||||
$scale: 1.05;
|
||||
$border-radius: calc($size / 2);
|
||||
$grad-position: 100% 0;
|
||||
$grad-start: 25%;
|
||||
$grad-stop: 65%;
|
||||
$duration: 3.5s;
|
||||
$noise: url('data:image/png;base64,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');
|
||||
|
||||
@mixin dithered-gradient($position, $start, $stop, $color) {
|
||||
background: radial-gradient(circle at $position, transparent $start, $color $stop);
|
||||
mask: $noise, radial-gradient(circle at $position, transparent $start, #000 ($stop + 10%));
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
@include dithered-gradient(50%, 30%, 60%, #6cc8ff);
|
||||
}
|
||||
|
||||
&::after {
|
||||
mask-image:
|
||||
$noise, linear-gradient(45deg, #000 0%, transparent 25%, transparent 75%, #000 100%);
|
||||
background: linear-gradient(45deg, #6d6dff 10%, transparent 25%, transparent 75%, #6af789 90%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
42
app/components/ui/Draggable.vue
Normal file
42
app/components/ui/Draggable.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div draggable="true" @dragstart="onDragStart" @dragend="onDragEnd"
|
||||
:class="['draggable', { 'is-dragging': isDragging }]">
|
||||
<slot :is-dragging="isDragging" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
data: any
|
||||
type?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['dragStart', 'dragEnd'])
|
||||
|
||||
const isDragging = ref(false)
|
||||
|
||||
const onDragStart = (e: DragEvent) => {
|
||||
isDragging.value = true
|
||||
e.dataTransfer?.setData('application/json', JSON.stringify(props.data))
|
||||
emit('dragStart', props.data)
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
isDragging.value = false
|
||||
emit('dragEnd')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.draggable {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.draggable.is-dragging {
|
||||
opacity: 0.5;
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
53
app/components/ui/Droppable.vue
Normal file
53
app/components/ui/Droppable.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div @dragover.prevent="onDragOver" @dragenter.prevent="onDragEnter" @dragleave="onDragLeave" @drop.prevent="onDrop"
|
||||
:class="['droppable', { 'is-drag-over': isDragOver }]">
|
||||
<slot :is-dragging-over="isDragOver" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
accept?: string
|
||||
onDrop: (data: any) => void
|
||||
}>()
|
||||
|
||||
const isDragOver = ref(false)
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!isDragOver.value) isDragOver.value = true
|
||||
}
|
||||
|
||||
const onDragEnter = (e: DragEvent) => {
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
const onDragLeave = () => {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
isDragOver.value = false
|
||||
const data = e.dataTransfer?.getData('application/json')
|
||||
if (data) {
|
||||
try {
|
||||
props.onDrop(JSON.parse(data))
|
||||
} catch (e) {
|
||||
console.error('Erreur lors du drop', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.droppable {
|
||||
min-height: 100px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.droppable.is-drag-over {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border: 2px dashed #4CAF50;
|
||||
}
|
||||
</style>
|
||||
@@ -1,3 +1,32 @@
|
||||
<template>
|
||||
<slot />
|
||||
<Bucket @card-dropped="onCardDropped" />
|
||||
<Platine />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Track } from '~~/types/types'
|
||||
|
||||
const onCardDropped = (card: Track) => {
|
||||
console.log('Carte déposée dans le bucket:', card)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bucket,
|
||||
.platine {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.bucket {
|
||||
z-index: 70;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.platine {
|
||||
z-index: 60;
|
||||
bottom: -70%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<card />
|
||||
<platine />
|
||||
<card />
|
||||
</template>
|
||||
|
||||
@@ -83,15 +83,6 @@ export default defineNuxtPlugin((nuxtApp) => {
|
||||
ui.closeBox()
|
||||
break
|
||||
|
||||
// Gestion de la touche Entrée pour ouvrir une boîte
|
||||
case 'Enter':
|
||||
if (document.activeElement?.id) {
|
||||
e.preventDefault()
|
||||
ui.selectBox(document.activeElement.id)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
break
|
||||
|
||||
// Gestion des touches fléchées (à implémenter si nécessaire)
|
||||
case 'ArrowUp':
|
||||
case 'ArrowDown':
|
||||
|
||||
Reference in New Issue
Block a user