Files
RepoDructor/components/TrackList.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.7 KiB
Vue

<template>
<div class="track-list glass">
<div class="track-list-header">
<h3 class="title">Your Music Library</h3>
<div v-if="tracks.length > 0" class="track-count">
{{ tracks.length }} song{{ tracks.length !== 1 ? 's' : '' }}
</div>
</div>
<div v-if="loading" class="loading-state">
<div class="loading-spinner large"></div>
<p>Loading your music...</p>
</div>
<div v-else-if="tracks.length === 0" class="empty-state">
<div class="empty-icon">
<Music :size="48" />
</div>
<p>No music files found</p>
<p class="hint">Add some music files to the /music folder!</p>
</div>
<div v-else class="track-list-content">
<div class="tracks-container stagger-children">
<TrackListItem
v-for="(track, index) in displayTracks"
:key="track.name"
:track="track"
:is-active="currentTrack?.name === track.name"
:is-playing="currentTrack?.name === track.name && isPlaying"
:is-loading="loadingTrack === track.name"
:has-error="failedTracks.has(track.name)"
@click="handleTrackClick(track, index)"
class="track-item-wrapper animate-fade-in-up"
/>
</div>
</div>
</div>
</template>
<script setup>
import { Music } from 'lucide-vue-next'
import TrackListItem from './TrackListItem.client.vue'
const props = defineProps({
tracks: {
type: Array,
default: () => []
},
displayTracks: {
type: Array,
default: () => []
},
currentTrack: {
type: Object,
default: null
},
isPlaying: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
},
loadingTrack: {
type: String,
default: null
},
failedTracks: {
type: Object, // Set object
default: () => new Set()
}
})
const emit = defineEmits(['track-selected'])
const handleTrackClick = (track, index) => {
emit('track-selected', { track, index })
}
</script>
<style scoped>
.track-list {
/* Cap height so sum with PlaybackControls fits 100vh; flex handles actual sizing */
max-height: var(--tracklist-height, none);
padding: 20px;
margin-bottom: 0;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border: 1px solid rgba(255, 255, 255, 0.1);
position: relative;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.track-list::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent-primary), transparent);
opacity: 0.5;
}
.track-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-glass);
flex-shrink: 0;
}
.title {
font-size: 1.3rem;
font-weight: 600;
background: linear-gradient(45deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
.track-count {
font-size: 0.9rem;
color: var(--text-secondary);
background: var(--bg-glass);
padding: 4px 12px;
border-radius: 20px;
border: 1px solid var(--border-glass);
}
.loading-state {
text-align: center;
padding: 60px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.loading-spinner {
width: 30px;
height: 30px;
border: 3px solid var(--bg-glass);
border-top: 3px solid var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-spinner.large {
width: 40px;
height: 40px;
border-width: 4px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-icon {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state p {
margin: 8px 0;
}
.hint {
font-size: 0.9rem;
font-style: italic;
}
.track-list-content {
position: relative;
flex: 1;
min-height: 0;
}
.tracks-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.track-item-wrapper {
animation: slideInUp 0.3s ease-out;
animation-fill-mode: both;
}
.track-item-wrapper:nth-child(1) { animation-delay: 0.05s; }
.track-item-wrapper:nth-child(2) { animation-delay: 0.1s; }
.track-item-wrapper:nth-child(3) { animation-delay: 0.15s; }
.track-item-wrapper:nth-child(4) { animation-delay: 0.2s; }
.track-item-wrapper:nth-child(5) { animation-delay: 0.25s; }
.track-item-wrapper:nth-child(n+6) { animation-delay: 0.3s; }
/* Scrollbar styling for track list */
.tracks-container {
height: 100%;
overflow-y: auto;
padding-right: 4px;
}
.tracks-container::-webkit-scrollbar {
width: 6px;
}
.tracks-container::-webkit-scrollbar-track {
background: var(--bg-glass);
border-radius: 3px;
}
.tracks-container::-webkit-scrollbar-thumb {
background: var(--accent-primary);
border-radius: 3px;
}
.tracks-container::-webkit-scrollbar-thumb:hover {
background: var(--accent-secondary);
}
/* Animations */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Responsive design */
@media (max-width: 768px) {
.track-list {
padding: 15px;
margin-bottom: 0;
}
.track-list-header {
margin-bottom: 12px;
padding-bottom: 10px;
}
.title {
font-size: 1.2rem;
}
.track-count {
font-size: 0.8rem;
padding: 3px 10px;
}
.loading-state,
.empty-state {
padding: 40px 15px;
}
.empty-icon {
font-size: 2.5rem;
}
.tracks-container {
gap: 6px;
}
}
</style>