Correction de petits bugs d'interface
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,173 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||
import { Folder, Music } from 'lucide-vue-next'
|
||||
import ActionMenu from './ActionMenu.vue'
|
||||
import { computed } from "vue";
|
||||
import type { ContainerEntry } from "@/services/pmocontrol/types";
|
||||
import { Folder, Music } from "lucide-vue-next";
|
||||
import ActionMenu from "./ActionMenu.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
entry: ContainerEntry
|
||||
serverId: string
|
||||
showActions?: boolean
|
||||
}>()
|
||||
entry: ContainerEntry;
|
||||
serverId: string;
|
||||
showActions?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
browse: [containerId: string]
|
||||
playNow: [containerId: string, rendererId: string]
|
||||
addToQueue: [containerId: string, rendererId: string]
|
||||
}>()
|
||||
browse: [containerId: string];
|
||||
playNow: [containerId: string, rendererId: string];
|
||||
addToQueue: [containerId: string, rendererId: string];
|
||||
}>();
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
const cls = props.entry.class.toLowerCase()
|
||||
if (cls.includes('playlist')) return Music
|
||||
if (cls.includes('album')) return Music
|
||||
return Folder
|
||||
})
|
||||
const cls = props.entry.class.toLowerCase();
|
||||
if (cls.includes("playlist")) return Music;
|
||||
if (cls.includes("album")) return Music;
|
||||
return Folder;
|
||||
});
|
||||
|
||||
const containerType = computed(() => {
|
||||
const cls = props.entry.class.toLowerCase()
|
||||
if (cls.includes('playlist')) return 'Playlist'
|
||||
if (cls.includes('album')) return 'Album'
|
||||
if (cls.includes('artist')) return 'Artiste'
|
||||
if (cls.includes('genre')) return 'Genre'
|
||||
return 'Dossier'
|
||||
})
|
||||
const cls = props.entry.class.toLowerCase();
|
||||
if (cls.includes("playlist")) return "Playlist";
|
||||
if (cls.includes("album")) return "Album";
|
||||
if (cls.includes("artist")) return "Artiste";
|
||||
if (cls.includes("genre")) return "Genre";
|
||||
return "Dossier";
|
||||
});
|
||||
|
||||
const isPlayable = computed(() => {
|
||||
const cls = props.entry.class.toLowerCase();
|
||||
return cls.includes("playlist") || cls.includes("album");
|
||||
});
|
||||
|
||||
function handleBrowse() {
|
||||
emit('browse', props.entry.id)
|
||||
emit("browse", props.entry.id);
|
||||
}
|
||||
|
||||
function handlePlayNow(rendererId: string) {
|
||||
emit('playNow', props.entry.id, rendererId)
|
||||
emit("playNow", props.entry.id, rendererId);
|
||||
}
|
||||
|
||||
function handleAddToQueue(rendererId: string) {
|
||||
emit('addToQueue', props.entry.id, rendererId)
|
||||
emit("addToQueue", props.entry.id, rendererId);
|
||||
}
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.style.display = "none";
|
||||
const placeholder = img.nextElementSibling;
|
||||
if (placeholder && placeholder instanceof HTMLElement) {
|
||||
placeholder.style.display = "flex";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container-item">
|
||||
<!-- Main content (clickable) -->
|
||||
<button class="container-content" @click="handleBrowse">
|
||||
<div class="container-icon">
|
||||
<component :is="iconComponent" :size="24" />
|
||||
</div>
|
||||
<div class="container-metadata">
|
||||
<div class="container-title">{{ entry.title }}</div>
|
||||
<div class="container-details">
|
||||
<span class="container-type">{{ containerType }}</span>
|
||||
<span v-if="entry.child_count !== null" class="container-count">
|
||||
{{ entry.child_count }} élément{{ entry.child_count > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="container-item">
|
||||
<!-- Main content (clickable) -->
|
||||
<button class="container-content" @click="handleBrowse">
|
||||
<!-- Cover avec icône de type en overlay -->
|
||||
<div class="container-cover">
|
||||
<img
|
||||
v-if="entry.album_art_uri"
|
||||
:src="entry.album_art_uri"
|
||||
:alt="entry.title"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
class="cover-placeholder"
|
||||
:style="{
|
||||
display: entry.album_art_uri ? 'none' : 'flex',
|
||||
}"
|
||||
>
|
||||
<component :is="iconComponent" :size="28" />
|
||||
</div>
|
||||
<!-- Petite icône de type dans le coin inférieur droit -->
|
||||
<div v-if="isPlayable" class="type-badge">
|
||||
<Folder :size="14" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions menu -->
|
||||
<div class="container-actions">
|
||||
<ActionMenu
|
||||
type="container"
|
||||
:entry-id="entry.id"
|
||||
:server-id="serverId"
|
||||
@play-now="handlePlayNow"
|
||||
@add-to-queue="handleAddToQueue"
|
||||
/>
|
||||
<!-- Métadonnées -->
|
||||
<div class="container-metadata">
|
||||
<div class="container-title">{{ entry.title }}</div>
|
||||
<div class="container-details">
|
||||
<span v-if="entry.artist" class="container-artist">{{
|
||||
entry.artist
|
||||
}}</span>
|
||||
<span class="container-type">{{ containerType }}</span>
|
||||
<span
|
||||
v-if="entry.child_count !== null"
|
||||
class="container-count"
|
||||
>
|
||||
{{ entry.child_count }} élément{{
|
||||
entry.child_count > 1 ? "s" : ""
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Actions menu -->
|
||||
<div class="container-actions">
|
||||
<ActionMenu
|
||||
type="container"
|
||||
:entry-id="entry.id"
|
||||
:server-id="serverId"
|
||||
@play-now="handlePlayNow"
|
||||
@add-to-queue="handleAddToQueue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.container-item:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-color: var(--color-border);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.container-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.container-icon {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
/* Cover avec image et icône de type */
|
||||
.container-cover {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Métadonnées */
|
||||
.container-metadata {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.container-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.container-details {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.container-artist {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.container-type {
|
||||
font-weight: 500;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.container-count::before {
|
||||
content: '•';
|
||||
margin-right: var(--spacing-sm);
|
||||
content: "•";
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.container-actions {
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ export interface PlaylistSummary {
|
||||
persistent: boolean;
|
||||
cover_pk?: string | null;
|
||||
cover_url?: string | null;
|
||||
artist?: string | null;
|
||||
track_count: number;
|
||||
max_size?: number | null;
|
||||
default_ttl_secs?: number | null;
|
||||
@@ -49,6 +50,7 @@ export interface UpdatePlaylistPayload {
|
||||
max_size?: number | null;
|
||||
default_ttl_secs?: number | null;
|
||||
cover_pk?: string | null;
|
||||
artist?: string | null;
|
||||
}
|
||||
|
||||
export interface AddTracksPayload {
|
||||
@@ -99,7 +101,9 @@ export async function getPlaylistDetail(id: string): Promise<PlaylistDetail> {
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export async function createPlaylist(body: CreatePlaylistPayload): Promise<PlaylistDetail> {
|
||||
export async function createPlaylist(
|
||||
body: CreatePlaylistPayload,
|
||||
): Promise<PlaylistDetail> {
|
||||
const response = await fetch("/api/playlists", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -112,7 +116,7 @@ export async function createPlaylist(body: CreatePlaylistPayload): Promise<Playl
|
||||
|
||||
export async function updatePlaylist(
|
||||
id: string,
|
||||
body: UpdatePlaylistPayload
|
||||
body: UpdatePlaylistPayload,
|
||||
): Promise<PlaylistDetail> {
|
||||
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
@@ -133,31 +137,40 @@ export async function deletePlaylist(id: string): Promise<void> {
|
||||
|
||||
export async function addTracksToPlaylist(
|
||||
id: string,
|
||||
payload: AddTracksPayload
|
||||
payload: AddTracksPayload,
|
||||
): Promise<PlaylistDetail> {
|
||||
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}/tracks`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
const response = await fetch(
|
||||
`/api/playlists/${encodeURIComponent(id)}/tracks`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
);
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export async function flushPlaylist(id: string): Promise<PlaylistDetail> {
|
||||
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}/tracks`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/playlists/${encodeURIComponent(id)}/tracks`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export async function removeTrackFromPlaylist(id: string, cachePk: string): Promise<PlaylistDetail> {
|
||||
export async function removeTrackFromPlaylist(
|
||||
id: string,
|
||||
cachePk: string,
|
||||
): Promise<PlaylistDetail> {
|
||||
const response = await fetch(
|
||||
`/api/playlists/${encodeURIComponent(id)}/tracks/${encodeURIComponent(cachePk)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
},
|
||||
);
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user