mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
feat(dz): add playlist caching and playlist view UI
Introduce database schema and types for caching online playlist tracks. Add functions to upsert and retrieve playlist tracks from the cache. Implement a new Svelte page for viewing individual playlists, including favorite tracks, with UI for track listing and playlist info. Update the main service page to support double-click navigation to playlist details.
This commit is contained in:
@@ -100,10 +100,25 @@ pub fn run() {
|
|||||||
cached_at INTEGER NOT NULL
|
cached_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS deezer_playlist_tracks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
playlist_id TEXT NOT NULL,
|
||||||
|
track_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist_name TEXT NOT NULL,
|
||||||
|
album_title TEXT,
|
||||||
|
duration INTEGER DEFAULT 0,
|
||||||
|
track_number INTEGER,
|
||||||
|
cached_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(playlist_id, track_id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_deezer_playlists_title ON deezer_playlists(title);
|
CREATE INDEX IF NOT EXISTS idx_deezer_playlists_title ON deezer_playlists(title);
|
||||||
CREATE INDEX IF NOT EXISTS idx_deezer_albums_artist ON deezer_albums(artist_name);
|
CREATE INDEX IF NOT EXISTS idx_deezer_albums_artist ON deezer_albums(artist_name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_deezer_artists_name ON deezer_artists(name);
|
CREATE INDEX IF NOT EXISTS idx_deezer_artists_name ON deezer_artists(name);
|
||||||
CREATE INDEX IF NOT EXISTS idx_deezer_tracks_title ON deezer_tracks(title);
|
CREATE INDEX IF NOT EXISTS idx_deezer_tracks_title ON deezer_tracks(title);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deezer_playlist_tracks_playlist ON deezer_playlist_tracks(playlist_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_deezer_playlist_tracks_track ON deezer_playlist_tracks(track_id);
|
||||||
",
|
",
|
||||||
kind: MigrationKind::Up,
|
kind: MigrationKind::Up,
|
||||||
}];
|
}];
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ export interface DeezerTrack {
|
|||||||
cached_at: number;
|
cached_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeezerPlaylistTrack {
|
||||||
|
id: number;
|
||||||
|
playlist_id: string;
|
||||||
|
track_id: string;
|
||||||
|
title: string;
|
||||||
|
artist_name: string;
|
||||||
|
album_title: string;
|
||||||
|
duration: number;
|
||||||
|
track_number: number | null;
|
||||||
|
cached_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
let db: Database | null = null;
|
let db: Database | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,6 +251,69 @@ export async function getCacheTimestamp(): Promise<number | null> {
|
|||||||
return result[0]?.cached_at || null;
|
return result[0]?.cached_at || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached playlist tracks
|
||||||
|
*/
|
||||||
|
export async function getCachedPlaylistTracks(playlistId: string): Promise<DeezerPlaylistTrack[]> {
|
||||||
|
const database = await initDeezerDatabase();
|
||||||
|
const tracks = await database.select<DeezerPlaylistTrack[]>(
|
||||||
|
'SELECT * FROM deezer_playlist_tracks WHERE playlist_id = $1 ORDER BY track_number, id',
|
||||||
|
[playlistId]
|
||||||
|
);
|
||||||
|
return tracks || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single playlist by ID
|
||||||
|
*/
|
||||||
|
export async function getCachedPlaylist(playlistId: string): Promise<DeezerPlaylist | null> {
|
||||||
|
const database = await initDeezerDatabase();
|
||||||
|
const playlists = await database.select<DeezerPlaylist[]>(
|
||||||
|
'SELECT * FROM deezer_playlists WHERE id = $1',
|
||||||
|
[playlistId]
|
||||||
|
);
|
||||||
|
return playlists?.[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert playlist tracks
|
||||||
|
*/
|
||||||
|
export async function upsertPlaylistTracks(playlistId: string, tracks: any[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('[deezer-database] Upserting playlist tracks, playlistId:', playlistId, 'count:', tracks.length);
|
||||||
|
|
||||||
|
const database = await initDeezerDatabase();
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Clear existing tracks for this playlist
|
||||||
|
await database.execute('DELETE FROM deezer_playlist_tracks WHERE playlist_id = $1', [playlistId]);
|
||||||
|
console.log('[deezer-database] Cleared existing tracks for playlist:', playlistId);
|
||||||
|
|
||||||
|
// Insert new tracks
|
||||||
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
|
const track = tracks[i];
|
||||||
|
await database.execute(
|
||||||
|
`INSERT INTO deezer_playlist_tracks (playlist_id, track_id, title, artist_name, album_title, duration, track_number, cached_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
playlistId,
|
||||||
|
String(track.SNG_ID),
|
||||||
|
track.SNG_TITLE || '',
|
||||||
|
track.ART_NAME || 'Unknown',
|
||||||
|
track.ALB_TITLE || '',
|
||||||
|
track.DURATION || 0,
|
||||||
|
track.TRACK_NUMBER || i + 1,
|
||||||
|
now
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('[deezer-database] Inserted', tracks.length, 'tracks for playlist:', playlistId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[deezer-database] Error in upsertPlaylistTracks:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all Deezer cache
|
* Clear all Deezer cache
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { deezerAuth, loadDeezerAuth, saveArl, saveUser, clearDeezerAuth, saveCacheTimestamp } from '$lib/stores/deezer';
|
import { deezerAuth, loadDeezerAuth, saveArl, saveUser, clearDeezerAuth, saveCacheTimestamp } from '$lib/stores/deezer';
|
||||||
import { deezerAPI } from '$lib/services/deezer';
|
import { deezerAPI } from '$lib/services/deezer';
|
||||||
import {
|
import {
|
||||||
@@ -226,6 +227,10 @@
|
|||||||
selectedIndex = index;
|
selectedIndex = index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePlaylistDoubleClick(playlistId: string) {
|
||||||
|
goto(`/services/deezer/playlists/${playlistId}`);
|
||||||
|
}
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = Math.floor(seconds % 60);
|
const secs = Math.floor(seconds % 60);
|
||||||
@@ -343,10 +348,25 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
<!-- Virtual Favorite Tracks Playlist (only show if we have favorite tracks) -->
|
||||||
|
{#if tracks.length > 0}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedIndex === -1}
|
||||||
|
class="favorite-tracks-row"
|
||||||
|
onclick={() => handleItemClick(-1)}
|
||||||
|
ondblclick={() => handlePlaylistDoubleClick('favorite-tracks')}
|
||||||
|
>
|
||||||
|
<td>Favorite Tracks</td>
|
||||||
|
<td>{tracks.length}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- User Playlists -->
|
||||||
{#each playlists as playlist, i}
|
{#each playlists as playlist, i}
|
||||||
<tr
|
<tr
|
||||||
class:highlighted={selectedIndex === i}
|
class:highlighted={selectedIndex === i}
|
||||||
onclick={() => handleItemClick(i)}
|
onclick={() => handleItemClick(i)}
|
||||||
|
ondblclick={() => handlePlaylistDoubleClick(playlist.id)}
|
||||||
>
|
>
|
||||||
<td>{playlist.title}</td>
|
<td>{playlist.title}</td>
|
||||||
<td>{playlist.nb_tracks}</td>
|
<td>{playlist.nb_tracks}</td>
|
||||||
@@ -668,4 +688,8 @@
|
|||||||
padding: 16px 8px;
|
padding: 16px 8px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorite-tracks-row {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
403
src/routes/services/deezer/playlists/[id]/+page.svelte
Normal file
403
src/routes/services/deezer/playlists/[id]/+page.svelte
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
|
import { deezerAPI } from '$lib/services/deezer';
|
||||||
|
import {
|
||||||
|
getCachedPlaylist,
|
||||||
|
getCachedPlaylistTracks,
|
||||||
|
getCachedTracks,
|
||||||
|
upsertPlaylistTracks,
|
||||||
|
type DeezerPlaylistTrack
|
||||||
|
} from '$lib/library/deezer-database';
|
||||||
|
import type { Track } from '$lib/types/track';
|
||||||
|
|
||||||
|
type ViewMode = 'tracks' | 'info';
|
||||||
|
|
||||||
|
let viewMode = $state<ViewMode>('tracks');
|
||||||
|
let playlistId = $derived($page.params.id ?? '');
|
||||||
|
let playlistTitle = $state('');
|
||||||
|
let playlistCreator = $state('');
|
||||||
|
let playlistTrackCount = $state(0);
|
||||||
|
let playlistPicture = $state<string | undefined>(undefined);
|
||||||
|
let tracks = $state<Track[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let selectedTrackIndex = $state<number | null>(null);
|
||||||
|
let lastCached = $state<number | null>(null);
|
||||||
|
|
||||||
|
const isFavoriteTracks = $derived(playlistId === 'favorite-tracks');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadPlaylist();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadPlaylist() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isFavoriteTracks) {
|
||||||
|
// Load favorite tracks from cache
|
||||||
|
playlistTitle = 'Favorite Tracks';
|
||||||
|
playlistCreator = $deezerAuth.user?.name || 'Unknown';
|
||||||
|
|
||||||
|
const favTracks = await getCachedTracks();
|
||||||
|
playlistTrackCount = favTracks.length;
|
||||||
|
tracks = favTracks.map(convertDeezerTrackToTrack);
|
||||||
|
lastCached = favTracks[0]?.cached_at || null;
|
||||||
|
} else {
|
||||||
|
// Load regular playlist
|
||||||
|
const [playlist, cachedTracks] = await Promise.all([
|
||||||
|
getCachedPlaylist(playlistId),
|
||||||
|
getCachedPlaylistTracks(playlistId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!playlist) {
|
||||||
|
error = `Playlist "${playlistId}" not found in cache.`;
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistTitle = playlist.title;
|
||||||
|
playlistCreator = playlist.creator_name || 'Unknown';
|
||||||
|
playlistTrackCount = playlist.nb_tracks;
|
||||||
|
playlistPicture = playlist.picture_medium || playlist.picture_small;
|
||||||
|
lastCached = playlist.cached_at;
|
||||||
|
|
||||||
|
// If no cached tracks, fetch from API
|
||||||
|
if (cachedTracks.length === 0) {
|
||||||
|
await refreshPlaylistTracks();
|
||||||
|
} else {
|
||||||
|
tracks = cachedTracks.map(convertPlaylistTrackToTrack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Error loading playlist: ' + (e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPlaylistTracks() {
|
||||||
|
if (!$deezerAuth.arl || refreshing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshing = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
deezerAPI.setArl($deezerAuth.arl);
|
||||||
|
|
||||||
|
console.log('[Deezer Playlist] Fetching tracks for playlist:', playlistId);
|
||||||
|
const apiTracks = await deezerAPI.getPlaylistTracks(playlistId);
|
||||||
|
|
||||||
|
console.log('[Deezer Playlist] Fetched', apiTracks.length, 'tracks');
|
||||||
|
|
||||||
|
// Update database cache
|
||||||
|
await upsertPlaylistTracks(playlistId, apiTracks);
|
||||||
|
|
||||||
|
// Reload from cache
|
||||||
|
const cachedTracks = await getCachedPlaylistTracks(playlistId);
|
||||||
|
tracks = cachedTracks.map(convertPlaylistTrackToTrack);
|
||||||
|
lastCached = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
console.log('[Deezer Playlist] Refresh complete!');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error refreshing playlist tracks:', e);
|
||||||
|
error = 'Error refreshing playlist: ' + (e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertPlaylistTrackToTrack(deezerTrack: DeezerPlaylistTrack): Track {
|
||||||
|
return {
|
||||||
|
path: '', // Deezer tracks don't have a local path
|
||||||
|
filename: deezerTrack.title,
|
||||||
|
format: 'unknown',
|
||||||
|
metadata: {
|
||||||
|
title: deezerTrack.title,
|
||||||
|
artist: deezerTrack.artist_name,
|
||||||
|
album: deezerTrack.album_title || undefined,
|
||||||
|
trackNumber: deezerTrack.track_number || undefined,
|
||||||
|
duration: deezerTrack.duration
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertDeezerTrackToTrack(deezerTrack: any): Track {
|
||||||
|
return {
|
||||||
|
path: '',
|
||||||
|
filename: deezerTrack.title,
|
||||||
|
format: 'unknown',
|
||||||
|
metadata: {
|
||||||
|
title: deezerTrack.title,
|
||||||
|
artist: deezerTrack.artist_name,
|
||||||
|
album: deezerTrack.album_title || undefined,
|
||||||
|
duration: deezerTrack.duration
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTrackClick(index: number) {
|
||||||
|
selectedTrackIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp: number | null): string {
|
||||||
|
if (!timestamp) return 'Never';
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wrapper">
|
||||||
|
{#if loading}
|
||||||
|
<p style="padding: 8px;">Loading playlist...</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="error" style="padding: 8px;">{error}</p>
|
||||||
|
{:else}
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="collection-header">
|
||||||
|
{#if playlistPicture}
|
||||||
|
<img
|
||||||
|
src={playlistPicture}
|
||||||
|
alt="{playlistTitle} cover"
|
||||||
|
class="collection-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="collection-cover-placeholder"></div>
|
||||||
|
{/if}
|
||||||
|
<div class="collection-info">
|
||||||
|
<h2>{playlistTitle}</h2>
|
||||||
|
<p class="collection-subtitle">by {playlistCreator}</p>
|
||||||
|
<p class="collection-metadata">{playlistTrackCount} track{playlistTrackCount !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="collection-content">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<!--
|
||||||
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||||
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||||
|
-->
|
||||||
|
<menu role="tablist">
|
||||||
|
<li role="tab" aria-selected={viewMode === 'tracks'}>
|
||||||
|
<button onclick={() => viewMode = 'tracks'}>Tracks</button>
|
||||||
|
</li>
|
||||||
|
<li role="tab" aria-selected={viewMode === 'info'}>
|
||||||
|
<button onclick={() => viewMode = 'info'}>Info</button>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div class="window tab-content" role="tabpanel">
|
||||||
|
<div class="window-body">
|
||||||
|
{#if viewMode === 'tracks'}
|
||||||
|
<!-- Track Listing -->
|
||||||
|
<div class="sunken-panel table-container">
|
||||||
|
<table class="interactive">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 50px;">#</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Album</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tracks as track, i}
|
||||||
|
<tr
|
||||||
|
class:highlighted={selectedTrackIndex === i}
|
||||||
|
onclick={() => handleTrackClick(i)}
|
||||||
|
>
|
||||||
|
<td class="track-number">
|
||||||
|
{track.metadata.trackNumber ?? i + 1}
|
||||||
|
</td>
|
||||||
|
<td>{track.metadata.title || track.filename}</td>
|
||||||
|
<td>{track.metadata.artist || '—'}</td>
|
||||||
|
<td>{track.metadata.album || '—'}</td>
|
||||||
|
<td class="duration">
|
||||||
|
{#if track.metadata.duration}
|
||||||
|
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
|
||||||
|
{:else}
|
||||||
|
—
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{:else if viewMode === 'info'}
|
||||||
|
<!-- Playlist Info -->
|
||||||
|
<div class="info-container">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Playlist Information</legend>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Title:</span>
|
||||||
|
<span>{playlistTitle}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Creator:</span>
|
||||||
|
<span>{playlistCreator}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Tracks:</span>
|
||||||
|
<span>{playlistTrackCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<span class="field-label">Last Updated:</span>
|
||||||
|
<span>{formatTimestamp(lastCached)}</span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{#if !isFavoriteTracks}
|
||||||
|
<fieldset style="margin-top: 16px;">
|
||||||
|
<legend>Actions</legend>
|
||||||
|
<button onclick={refreshPlaylistTracks} disabled={refreshing}>
|
||||||
|
{refreshing ? 'Refreshing...' : 'Refresh Playlist'}
|
||||||
|
</button>
|
||||||
|
<p class="help-text">Fetch the latest tracks from Deezer</p>
|
||||||
|
</fieldset>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-cover {
|
||||||
|
width: 152px;
|
||||||
|
height: 152px;
|
||||||
|
object-fit: cover;
|
||||||
|
image-rendering: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-cover-placeholder {
|
||||||
|
width: 152px;
|
||||||
|
height: 152px;
|
||||||
|
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-subtitle {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 1.1em;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-metadata {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-content {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
margin-top: -2px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-body {
|
||||||
|
padding: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-number {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
text-align: center;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
margin: 8px 0 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user