yeah
All checks were successful
Deploy App / build (push) Successful in 10s
Deploy App / deploy (push) Successful in 11s

This commit is contained in:
valere
2026-02-02 21:00:28 +01:00
parent e257e076c4
commit b9a3d0184f
98 changed files with 5068 additions and 3713 deletions

View 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>

View 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>

View File

@@ -0,0 +1,221 @@
<template>
<transition name="fade">
<div v-if="ui.showSearch" class="fixed inset-0 z-50 flex items-center justify-center transition-all">
<div class="absolute inset-0 bg-black/60 backdrop-blur-md" @click="close"></div>
<div
class="relative w-full max-w-2xl rounded-xl bg-white shadow-xl ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-slate-700"
role="dialog" aria-modal="true" @keydown.esc.prevent.stop="close">
<div class="flex items-center gap-2 dark:border-slate-700">
<svg class="ml-4 h-7 w-7 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input ref="inputRef" v-model="ui.searchQuery" type="text" placeholder="Rechercher boxes, artistes, tracks..."
class="flex-1 bg-transparent px-2 py-2 text-slate-900 text-3xl placeholder-slate-400 outline-none dark:text-slate-100"
@keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter.prevent="confirm" />
</div>
<div class="max-h-72 overflow-auto results-scroll">
<template v-if="results.length">
<ul class="divide-y divide-slate-100 dark:divide-slate-800">
<li v-for="(resultItem, idx) in results" :key="resultItem.key" :class="[
'flex cursor-pointer items-center justify-between gap-3 px-2 py-3 hover:bg-slate-50 dark:hover:bg-slate-800',
idx === activeIndex ? 'bg-slate-100 dark:bg-slate-800' : ''
]" @mouseenter="activeIndex = idx" @click="selectResult(resultItem)">
<div class="flex items-center gap-3">
<img v-if="coverUrlFor(resultItem)" :src="coverUrlFor(resultItem)" alt="" loading="lazy"
class="h-10 w-10 rounded object-cover ring-1 ring-slate-200 dark:ring-slate-700" />
<span
class="inline-flex min-w-[68px] items-center justify-center rounded-md border px-2 py-0.5 text-xs font-semibold uppercase text-slate-600 dark:text-slate-300 dark:border-slate-600">{{
resultItem.type }}</span>
<span class="text-slate-900 dark:text-slate-100">{{ resultItem.label }}</span>
</div>
<div class="flex items-center gap-2">
<span v-if="resultItem.sublabel" class="text-sm text-slate-500 dark:text-slate-400">{{
resultItem.sublabel }}</span>
</div>
</li>
</ul>
</template>
<div v-else-if="ui.searchQuery" class="px-2 py-6 text-center text-slate-500 dark:text-slate-400">
Aucun résultat
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useUiStore } from '~/store/ui'
import { useDataStore } from '~/store/data'
import { usePlayerStore } from '~/store/player'
const ui = useUiStore()
const data = useDataStore()
const player = usePlayerStore()
const inputRef = ref<HTMLInputElement | null>(null)
const activeIndex = ref(0)
const close = () => ui.closeSearch()
const normalized = (s: string) =>
s
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase()
type ResultItem = {
key: string
type: 'BOX' | 'ARTIST' | 'TRACK'
label: string
sublabel?: string
payload: any
}
const results = computed<ResultItem[]>(() => {
const q = normalized(ui.searchQuery || '')
if (!q) return []
const out: ResultItem[] = []
for (const b of data.boxes) {
const label = `${b.id}`
if (normalized(label).includes(q)) {
out.push({ key: `box:${b.id}`, type: 'BOX', label, payload: b })
}
}
for (const a of data.artists) {
if (normalized(a.name).includes(q)) {
out.push({
key: `artist:${a.id}`,
type: 'ARTIST',
label: a.name,
payload: a
})
}
}
for (const track of data.tracks) {
const artistName =
typeof track.artist === 'object' && track.artist
? ((track.artist as any).name ?? '')
: String(track.artist)
const label = track.title
const sub = artistName
if (normalized(label).includes(q) || normalized(sub).includes(q)) {
out.push({ key: `track:${track.id}`, type: 'TRACK', label, sublabel: sub, payload: track })
}
}
return out.slice(0, 100)
})
const move = (delta: number) => {
if (!results.value.length) return
activeIndex.value = (activeIndex.value + delta + results.value.length) % results.value.length
}
const coverUrlFor = (ResultItem: ResultItem): string | undefined => {
if (ResultItem.type === 'BOX') {
return `/${ResultItem.payload.id}/cover.jpg`
}
if (ResultItem.type === 'TRACK') {
const track = ResultItem.payload
if (track && track.type === 'playlist' && track.coverId) return track.coverId as string
if (track && track.coverId) return track.coverId as string
return `/${track.boxId}/cover.jpg`
}
if (ResultItem.type === 'ARTIST') {
const tracks = data.getTracksByArtistId(ResultItem.payload.id)
if (tracks && tracks.length) {
const track = tracks[0]!
if (track.type === 'playlist' && track.coverId) return track.coverId as string
if (track.coverId) return track.coverId as string
return `/${track.boxId}/cover.jpg`
}
}
return undefined
}
const selectResult = (ResultItem: ResultItem) => {
if (ResultItem.type === 'BOX') {
ui.selectBox(ResultItem.payload.id)
nextTick(() => ui.scrollToBox(ResultItem.payload))
} else if (ResultItem.type === 'ARTIST') {
const tracks = data.getTracksByArtistId(ResultItem.payload.id)
if (tracks && tracks.length) {
const track = tracks[0]!
const box = data.getBoxById(track.boxId)
if (box) {
ui.selectBox(box.id)
}
}
} else if (ResultItem.type === 'TRACK') {
const track = ResultItem.payload
const box = data.getBoxById(track.boxId)
if (box) {
ui.selectBox(box.id)
player.playTrack(track)
}
}
close()
}
const confirm = () => {
const ResultItem = results.value[activeIndex.value]
if (ResultItem) selectResult(ResultItem)
}
watch(
() => ui.showSearch,
async (open) => {
if (open) {
activeIndex.value = 0
await nextTick()
inputRef.value?.focus()
}
}
)
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.results-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(100, 116, 139, 0.6) transparent;
}
.results-scroll::-webkit-scrollbar {
width: 8px;
}
.results-scroll::-webkit-scrollbar-track {
background: transparent;
}
.results-scroll::-webkit-scrollbar-thumb {
background-color: rgba(100, 116, 139, 0.6);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
.dark .results-scroll {
scrollbar-color: rgba(148, 163, 184, 0.5) transparent;
}
.dark .results-scroll::-webkit-scrollbar-thumb {
background-color: rgba(148, 163, 184, 0.5);
}
</style>

View 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'
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>

View File

@@ -0,0 +1,25 @@
<template>
<div class="relative">
<input v-model="searchQuery" type="text" placeholder="Rechercher..."
class="px-4 py-2 pl-10 w-48 m-4 h-12 font-bold text-black bg-esyellow border border-none rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-estyellow-dark focus:border-estyellow-dark"
@input="handleSearch">
<div class="absolute inset-y-0 left-0 flex items-center pl-6 pointer-events-none">
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits(['search'])
const searchQuery = ref('')
const handleSearch = () => {
emit('search', searchQuery.value.trim().toLowerCase())
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<select v-model="selectedRank" @change="handleChange"
class="px-4 py-2 m-4 font-bold h-12 border-none text-center bg-esyellow transition-colors border border-esyellow-dark rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-estyellow-dark focus:border-estyellow-dark cursor-pointer appearance-none">
<option value="">rank</option>
<option v-for="rank in ranks" :key="rank.value" :value="rank.value">
{{ rank.label }}
</option>
</select>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits(['change'])
const ranks = [
{ value: 'A', label: 'Ace' },
{ value: '2', label: '2' },
{ value: '3', label: '3' },
{ value: '4', label: '4' },
{ value: '5', label: '5' },
{ value: '6', label: '6' },
{ value: '7', label: '7' },
{ value: '8', label: '8' },
{ value: '9', label: '9' },
{ value: '10', label: '10' },
{ value: 'J', label: 'Jack' },
{ value: 'Q', label: 'Queen' },
{ value: 'K', label: 'King' }
]
const selectedRank = ref('')
const handleChange = () => {
emit('change', selectedRank.value)
}
</script>

View File

@@ -0,0 +1,28 @@
<template>
<select v-model="selectedSuit" @change="handleChange"
class="px-4 py-2 m-4 text-black font-bold h-12 border-none text-center hover:text-black bg-esyellow transition-colors border border-esyellow-dark rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-esyellow-dark focus:border-esyellow-dark cursor-pointer appearance-none">
<option value=""></option>
<option v-for="suit in suits" :key="suit.value" :value="suit.value">
{{ suit.label }}
</option>
</select>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits(['change'])
const suits = [
{ value: '♥', label: '♥' },
{ value: '♦', label: '♦' },
{ value: '♣', label: '♣' },
{ value: '♠', label: '♠' }
]
const selectedSuit = ref('')
const handleChange = () => {
emit('change', selectedSuit.value)
}
</script>