Files
RepoDructor/components/TrackListItem.client.vue
josedario87 bf7413b45f Fix 403 error when loading music files
- Fix path traversal security check by using absolute paths
- Remove problematic fetch override that forced JSON headers on all API requests
- Add error tracking and visual indicators for failed tracks
- Correct music directory resolution for both relative and absolute paths

The main issue was the security validation comparing relative paths incorrectly,
causing legitimate music file requests to be rejected with 403 errors.
2025-08-10 01:28:16 -06:00

287 lines
5.6 KiB
Vue

<template>
<div
:class="[
'track-item',
'ripple',
{
'active': isActive,
'loading': isLoading,
'has-error': hasError,
'animate-pulse-glow': isActive && isPlaying
}
]"
@click="handleClick"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<div class="track-info">
<p class="track-name">{{ track.name }}</p>
<p class="track-duration">{{ formatTime(track.duration || 0) }}</p>
</div>
<div class="track-status">
<div v-if="isLoading" class="loading-spinner"></div>
<div v-else class="status-icon">
<AlertCircle v-if="hasError" class="icon error" :size="18" />
<Pause v-else-if="isActive && !isPlaying" class="icon paused" :size="18" />
<Play v-else-if="isActive && isPlaying" class="icon playing" :size="18" />
<Music v-else class="icon idle" :size="18" />
</div>
</div>
<!-- Waveform visualization for active track -->
<div v-if="isActive && isPlaying" class="waveform">
<div class="wave" v-for="i in 5" :key="i" :style="{ animationDelay: i * 0.1 + 's' }"></div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Play, Pause, Music, AlertCircle } from 'lucide-vue-next'
const props = defineProps({
track: {
type: Object,
required: true
},
isActive: {
type: Boolean,
default: false
},
isPlaying: {
type: Boolean,
default: false
},
isLoading: {
type: Boolean,
default: false
},
hasError: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['click'])
const isHovered = ref(false)
const handleClick = () => {
emit('click', props.track)
}
const formatTime = (seconds) => {
if (!seconds || isNaN(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
</script>
<style scoped>
.track-item {
padding: 16px 20px;
margin-bottom: 8px;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
overflow: hidden;
min-height: 70px;
}
.track-item::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(59, 130, 246, 0.1), transparent);
transition: left 0.5s;
}
.track-item:hover::before {
left: 100%;
}
.track-item:hover {
background: var(--bg-glass);
transform: translateX(5px);
box-shadow:
0 6px 20px rgba(59, 130, 246, 0.2),
0 3px 8px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(0, 0, 0, 0.05);
}
.track-item.active {
background: linear-gradient(45deg, var(--accent-primary), var(--accent-secondary));
color: white;
box-shadow:
0 8px 25px rgba(59, 130, 246, 0.4),
0 4px 10px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 0 rgba(0, 0, 0, 0.1);
}
.track-item.active:hover {
transform: translateX(5px);
box-shadow:
0 10px 30px rgba(59, 130, 246, 0.5),
0 5px 12px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.25),
inset 0 -1px 0 rgba(0, 0, 0, 0.15);
}
/* Error state styles */
.track-item.has-error {
opacity: 0.7;
background: rgba(239, 68, 68, 0.05);
border-color: rgba(239, 68, 68, 0.2);
}
.track-item.has-error:hover {
background: rgba(239, 68, 68, 0.1);
box-shadow:
0 4px 12px rgba(239, 68, 68, 0.15),
0 2px 6px rgba(0, 0, 0, 0.05);
}
.track-item.has-error .track-name {
color: var(--text-secondary);
text-decoration: line-through;
text-decoration-color: rgba(239, 68, 68, 0.5);
}
.track-info {
flex: 1;
}
.track-name {
font-weight: 500;
font-size: 1.05rem;
margin-bottom: 6px;
transition: color 0.3s ease;
line-height: 1.3;
}
.track-duration {
font-size: 0.9rem;
color: var(--text-secondary);
transition: color 0.3s ease;
line-height: 1.2;
}
.track-item.active .track-duration {
color: rgba(255, 255, 255, 0.8);
}
.track-status {
display: flex;
align-items: center;
margin-left: 16px;
position: relative;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
}
.icon {
font-size: 1.2rem;
transition: transform 0.2s ease;
}
.icon.playing {
animation: pulse 1.5s ease-in-out infinite;
}
.icon.paused {
opacity: 0.7;
}
.icon.error {
color: #ef4444;
opacity: 0.9;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--bg-glass);
border-top: 2px solid var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* Waveform visualization */
.waveform {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
display: flex;
align-items: end;
gap: 2px;
padding: 0 16px;
}
.wave {
flex: 1;
background: rgba(255, 255, 255, 0.6);
border-radius: 1px;
animation: wave 1.5s ease-in-out infinite;
min-height: 1px;
}
/* Animations */
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes wave {
0%, 100% { height: 1px; }
50% { height: 3px; }
}
/* Responsive design */
@media (max-width: 768px) {
.track-item {
padding: 10px 12px;
}
.track-name {
font-size: 0.9rem;
}
.track-duration {
font-size: 0.8rem;
}
.status-icon {
width: 28px;
height: 28px;
}
.icon {
font-size: 1rem;
}
}
</style>