mirror of
https://github.com/markuryy/shark.git
synced 2026-06-18 18:41:03 +00:00
945 lines
27 KiB
Svelte
945 lines
27 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { spotifyAuth, loadSpotifyAuth, saveClientCredentials, saveTokens, saveUser, clearSpotifyAuth, saveCacheTimestamp } from '$lib/stores/spotify';
|
|
import { spotifyAPI } from '$lib/services/spotify';
|
|
import { start, onUrl } from '@fabianlars/tauri-plugin-oauth';
|
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
|
import {
|
|
getCachedPlaylists,
|
|
getCachedAlbums,
|
|
getCachedArtists,
|
|
getCachedTracks,
|
|
upsertPlaylists,
|
|
upsertAlbums,
|
|
upsertArtists,
|
|
upsertTracks,
|
|
type SpotifyPlaylist,
|
|
type SpotifyAlbum,
|
|
type SpotifyArtist,
|
|
type SpotifyTrack
|
|
} from '$lib/library/spotify-database';
|
|
|
|
// Fixed port for OAuth callback - user must register this in Spotify Dashboard
|
|
const OAUTH_PORT = 8228;
|
|
const REDIRECT_URI = `http://127.0.0.1:${OAUTH_PORT}/callback`;
|
|
|
|
// Login form state
|
|
let clientIdInput = $state('');
|
|
let clientSecretInput = $state('');
|
|
let isAuthenticating = $state(false);
|
|
let loginError = $state('');
|
|
let loginSuccess = $state('');
|
|
|
|
// OAuth state
|
|
let isWaitingForCallback = $state(false);
|
|
let oauthUnlisten: (() => void) | null = $state(null);
|
|
|
|
// Data state
|
|
type ViewMode = 'playlists' | 'tracks' | 'artists' | 'albums' | 'info';
|
|
let viewMode = $state<ViewMode>('playlists');
|
|
let playlists = $state<SpotifyPlaylist[]>([]);
|
|
let albums = $state<SpotifyAlbum[]>([]);
|
|
let artists = $state<SpotifyArtist[]>([]);
|
|
let tracks = $state<SpotifyTrack[]>([]);
|
|
let loading = $state(true);
|
|
let syncing = $state(false);
|
|
let error = $state<string | null>(null);
|
|
let selectedIndex = $state<number | null>(null);
|
|
|
|
// User refresh state
|
|
let refreshingUser = $state(false);
|
|
let userRefreshMessage = $state('');
|
|
|
|
const CACHE_DURATION = 24 * 60 * 60; // 24 hours in seconds
|
|
|
|
onMount(async () => {
|
|
await loadSpotifyAuth();
|
|
|
|
// Check if we have client credentials stored
|
|
if ($spotifyAuth.clientId) {
|
|
clientIdInput = $spotifyAuth.clientId;
|
|
}
|
|
if ($spotifyAuth.clientSecret) {
|
|
clientSecretInput = $spotifyAuth.clientSecret;
|
|
}
|
|
|
|
if ($spotifyAuth.loggedIn) {
|
|
await loadFavorites();
|
|
} else {
|
|
loading = false;
|
|
}
|
|
});
|
|
|
|
async function loadFavorites() {
|
|
loading = true;
|
|
error = null;
|
|
|
|
try {
|
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
|
|
spotifyAPI.setTokens(
|
|
$spotifyAuth.accessToken!,
|
|
$spotifyAuth.refreshToken!,
|
|
$spotifyAuth.expiresAt!
|
|
);
|
|
|
|
// Check if we need to refresh cache
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const cacheAge = $spotifyAuth.cacheTimestamp ? now - $spotifyAuth.cacheTimestamp : Infinity;
|
|
const needsRefresh = cacheAge > CACHE_DURATION;
|
|
|
|
if (needsRefresh) {
|
|
await refreshFavorites();
|
|
} else {
|
|
// Load from cache
|
|
const [cachedPlaylists, cachedAlbums, cachedArtists, cachedTracks] = await Promise.all([
|
|
getCachedPlaylists(),
|
|
getCachedAlbums(),
|
|
getCachedArtists(),
|
|
getCachedTracks()
|
|
]);
|
|
|
|
playlists = cachedPlaylists;
|
|
albums = cachedAlbums;
|
|
artists = cachedArtists;
|
|
tracks = cachedTracks;
|
|
}
|
|
} catch (e) {
|
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
if (errorMessage === 'REFRESH_TOKEN_REVOKED') {
|
|
await clearSpotifyAuth();
|
|
error = 'Your Spotify session has expired. Please log in again.';
|
|
} else {
|
|
error = 'Error loading favorites: ' + errorMessage;
|
|
}
|
|
viewMode = 'info';
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function refreshFavorites() {
|
|
if (!$spotifyAuth.accessToken || syncing) {
|
|
return;
|
|
}
|
|
|
|
syncing = true;
|
|
error = null;
|
|
|
|
try {
|
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
|
|
spotifyAPI.setTokens(
|
|
$spotifyAuth.accessToken,
|
|
$spotifyAuth.refreshToken!,
|
|
$spotifyAuth.expiresAt!
|
|
);
|
|
|
|
// Fetch all favorites from API
|
|
console.log('[Spotify] Fetching favorites from API...');
|
|
const [apiPlaylists, apiAlbums, apiArtists, apiTracks] = await Promise.all([
|
|
spotifyAPI.getAllUserPlaylists(),
|
|
spotifyAPI.getAllUserAlbums(),
|
|
spotifyAPI.getAllUserArtists(),
|
|
spotifyAPI.getAllUserTracks()
|
|
]);
|
|
|
|
console.log('[Spotify] Fetched from API:', {
|
|
playlists: apiPlaylists.length,
|
|
albums: apiAlbums.length,
|
|
artists: apiArtists.length,
|
|
tracks: apiTracks.length
|
|
});
|
|
|
|
// Update database cache
|
|
console.log('[Spotify] Updating database cache...');
|
|
await upsertPlaylists(apiPlaylists);
|
|
await upsertAlbums(apiAlbums);
|
|
await upsertArtists(apiArtists);
|
|
await upsertTracks(apiTracks);
|
|
|
|
// Update cache timestamp
|
|
const now = Math.floor(Date.now() / 1000);
|
|
await saveCacheTimestamp(now);
|
|
|
|
console.log('[Spotify] Reloading from cache...');
|
|
// Reload from cache
|
|
const [cachedPlaylists, cachedAlbums, cachedArtists, cachedTracks] = await Promise.all([
|
|
getCachedPlaylists(),
|
|
getCachedAlbums(),
|
|
getCachedArtists(),
|
|
getCachedTracks()
|
|
]);
|
|
|
|
playlists = cachedPlaylists;
|
|
albums = cachedAlbums;
|
|
artists = cachedArtists;
|
|
tracks = cachedTracks;
|
|
|
|
console.log('[Spotify] Refresh complete!');
|
|
} catch (e) {
|
|
console.error('Error refreshing favorites:', e);
|
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
if (errorMessage === 'REFRESH_TOKEN_REVOKED') {
|
|
await clearSpotifyAuth();
|
|
error = 'Your Spotify session has expired. Please log in again.';
|
|
} else {
|
|
error = 'Error refreshing favorites: ' + errorMessage;
|
|
}
|
|
viewMode = 'info';
|
|
} finally {
|
|
syncing = false;
|
|
}
|
|
}
|
|
|
|
async function handleAuthorize() {
|
|
if (!clientIdInput || !clientSecretInput) {
|
|
loginError = 'Please enter both Client ID and Client Secret';
|
|
return;
|
|
}
|
|
|
|
if (clientIdInput.trim().length === 0 || clientSecretInput.trim().length === 0) {
|
|
loginError = 'Client ID and Client Secret cannot be empty';
|
|
return;
|
|
}
|
|
|
|
isAuthenticating = true;
|
|
loginError = '';
|
|
loginSuccess = '';
|
|
|
|
try {
|
|
// Save credentials
|
|
await saveClientCredentials(clientIdInput.trim(), clientSecretInput.trim());
|
|
|
|
// Clean up any existing OAuth listener
|
|
if (oauthUnlisten) {
|
|
oauthUnlisten();
|
|
}
|
|
|
|
// Set up OAuth callback listener and store unlisten function
|
|
oauthUnlisten = await onUrl((callbackUrl) => {
|
|
handleOAuthCallback(callbackUrl);
|
|
});
|
|
|
|
// Start OAuth server on fixed port with custom styled response
|
|
const port = await start({
|
|
ports: [OAUTH_PORT],
|
|
response: `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Spotify Authorization Complete</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
|
|
background: #008080;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
padding: 20px;
|
|
}
|
|
.window {
|
|
background: silver;
|
|
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
|
padding: 3px;
|
|
max-width: 500px;
|
|
width: 100%;
|
|
}
|
|
.title-bar {
|
|
background: linear-gradient(90deg, navy, #1084d0);
|
|
padding: 3px 5px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
color: white;
|
|
font-weight: bold;
|
|
font-size: 11px;
|
|
}
|
|
.window-body {
|
|
background: silver;
|
|
padding: 16px;
|
|
margin: 3px;
|
|
}
|
|
h1 {
|
|
font-size: 16px;
|
|
margin-bottom: 12px;
|
|
}
|
|
p {
|
|
font-size: 11px;
|
|
line-height: 1.5;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="window">
|
|
<div class="title-bar">
|
|
<span>Spotify Authorization</span>
|
|
</div>
|
|
<div class="window-body">
|
|
<h1>Authorization Complete</h1>
|
|
<p>You have successfully authorized Shark with your Spotify account.</p>
|
|
<p>You can close this window and return to the app.</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`
|
|
});
|
|
console.log(`[Spotify] OAuth server started on port ${port}`);
|
|
|
|
// Generate authorization URL
|
|
const { url, codeVerifier } = await spotifyAPI.getAuthorizationUrl(
|
|
clientIdInput.trim(),
|
|
REDIRECT_URI
|
|
);
|
|
|
|
// Store code verifier for callback
|
|
localStorage.setItem('spotify_code_verifier', codeVerifier);
|
|
|
|
isWaitingForCallback = true;
|
|
|
|
// Open Spotify authorization in default browser
|
|
await openUrl(url);
|
|
} catch (error) {
|
|
console.error('[Spotify] Authorization error:', error);
|
|
loginError = `Authorization error: ${error instanceof Error ? error.message : JSON.stringify(error)}`;
|
|
isAuthenticating = false;
|
|
isWaitingForCallback = false;
|
|
}
|
|
}
|
|
|
|
async function handleOAuthCallback(callbackUrl: string) {
|
|
// Immediately remove the listener to prevent duplicate calls from hot reload
|
|
if (oauthUnlisten) {
|
|
oauthUnlisten();
|
|
oauthUnlisten = null;
|
|
}
|
|
|
|
try {
|
|
// Parse the callback URL
|
|
const url = new URL(callbackUrl);
|
|
const code = url.searchParams.get('code');
|
|
const error = url.searchParams.get('error');
|
|
|
|
if (error) {
|
|
throw new Error(`Authorization failed: ${error}`);
|
|
}
|
|
|
|
if (!code) {
|
|
throw new Error('No authorization code received');
|
|
}
|
|
|
|
// Retrieve code verifier from localStorage
|
|
const codeVerifier = localStorage.getItem('spotify_code_verifier');
|
|
|
|
if (!codeVerifier) {
|
|
throw new Error('OAuth state lost. Please try logging in again.');
|
|
}
|
|
|
|
// Exchange code for tokens
|
|
const tokenData = await spotifyAPI.exchangeCodeForToken(
|
|
code,
|
|
codeVerifier,
|
|
$spotifyAuth.clientId!,
|
|
REDIRECT_URI
|
|
);
|
|
|
|
// Save tokens
|
|
await saveTokens(tokenData.access_token, tokenData.refresh_token, tokenData.expires_in);
|
|
|
|
// Set tokens in API client
|
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
|
|
spotifyAPI.setTokens(
|
|
tokenData.access_token,
|
|
tokenData.refresh_token,
|
|
Date.now() + (tokenData.expires_in * 1000)
|
|
);
|
|
|
|
// Fetch user info
|
|
const user = await spotifyAPI.getCurrentUser();
|
|
await saveUser(user);
|
|
|
|
// Load favorites after successful login
|
|
await loadFavorites();
|
|
|
|
// Clean up
|
|
localStorage.removeItem('spotify_code_verifier');
|
|
} catch (error) {
|
|
loginError = `Authentication error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
localStorage.removeItem('spotify_code_verifier');
|
|
} finally {
|
|
isAuthenticating = false;
|
|
isWaitingForCallback = false;
|
|
}
|
|
}
|
|
|
|
async function handleLogout() {
|
|
await clearSpotifyAuth();
|
|
clientIdInput = '';
|
|
clientSecretInput = '';
|
|
loginSuccess = '';
|
|
loginError = '';
|
|
playlists = [];
|
|
albums = [];
|
|
artists = [];
|
|
tracks = [];
|
|
}
|
|
|
|
async function handleRefreshUser() {
|
|
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret) {
|
|
return;
|
|
}
|
|
|
|
refreshingUser = true;
|
|
userRefreshMessage = '';
|
|
|
|
try {
|
|
// Set credentials in API client
|
|
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
|
|
spotifyAPI.setTokens(
|
|
$spotifyAuth.accessToken,
|
|
$spotifyAuth.refreshToken!,
|
|
$spotifyAuth.expiresAt!
|
|
);
|
|
|
|
// Fetch updated user info
|
|
const user = await spotifyAPI.getCurrentUser();
|
|
await saveUser(user);
|
|
|
|
userRefreshMessage = 'User info refreshed successfully!';
|
|
setTimeout(() => {
|
|
userRefreshMessage = '';
|
|
}, 3000);
|
|
} catch (error) {
|
|
userRefreshMessage = 'Error refreshing user info: ' + (error instanceof Error ? error.message : 'Unknown error');
|
|
} finally {
|
|
refreshingUser = false;
|
|
setTimeout(() => {
|
|
userRefreshMessage = '';
|
|
}, 3000);
|
|
}
|
|
}
|
|
|
|
function handleItemClick(index: number) {
|
|
selectedIndex = index;
|
|
}
|
|
|
|
function handlePlaylistDoubleClick(playlistId: string) {
|
|
goto(`/services/spotify/playlists/${playlistId}`);
|
|
}
|
|
|
|
function formatDuration(ms: number): string {
|
|
const mins = Math.floor(ms / 60000);
|
|
const secs = Math.floor((ms % 60000) / 1000);
|
|
return `${mins}:${String(secs).padStart(2, '0')}`;
|
|
}
|
|
</script>
|
|
|
|
<div class="spotify-wrapper">
|
|
<h2 style="padding: 8px">Spotify</h2>
|
|
|
|
{#if !$spotifyAuth.loggedIn}
|
|
<!-- Login Form -->
|
|
<section class="window login-section" style="max-width: 600px; margin: 8px;">
|
|
<div class="title-bar">
|
|
<div class="title-bar-text">Login to Spotify</div>
|
|
</div>
|
|
<div class="window-body">
|
|
<p>Enter your Spotify Developer credentials and authorize access:</p>
|
|
|
|
<div class="field-row-stacked">
|
|
<label for="client-id-input">Client ID</label>
|
|
<input
|
|
id="client-id-input"
|
|
type="text"
|
|
bind:value={clientIdInput}
|
|
placeholder="Your Spotify App Client ID"
|
|
disabled={isAuthenticating || isWaitingForCallback}
|
|
/>
|
|
</div>
|
|
|
|
<div class="field-row-stacked">
|
|
<label for="client-secret-input">Client Secret</label>
|
|
<input
|
|
id="client-secret-input"
|
|
type="password"
|
|
bind:value={clientSecretInput}
|
|
placeholder="Your Spotify App Client Secret"
|
|
disabled={isAuthenticating || isWaitingForCallback}
|
|
/>
|
|
</div>
|
|
|
|
{#if loginError}
|
|
<div class="error-message">
|
|
⚠ {loginError}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if isWaitingForCallback}
|
|
<div class="info-message">
|
|
Waiting for authorization in your browser... Please complete the login process.
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="button-row">
|
|
<button onclick={handleAuthorize} disabled={isAuthenticating || isWaitingForCallback}>
|
|
{isAuthenticating ? 'Authorizing...' : 'Authorize with Spotify'}
|
|
</button>
|
|
</div>
|
|
|
|
<p style="margin-top: 8px; font-size: 11px; opacity: 0.7;">
|
|
This will open Spotify's login page in your default browser.
|
|
</p>
|
|
|
|
<details class="instructions">
|
|
<summary>How to get your Spotify Developer credentials</summary>
|
|
<div class="instructions-content">
|
|
<ol>
|
|
<li>Go to <strong>developer.spotify.com/dashboard</strong></li>
|
|
<li>Log in with your Spotify account</li>
|
|
<li>Click <strong>"Create app"</strong></li>
|
|
<li>Fill in the app details:
|
|
<ul>
|
|
<li>App name: (any name you want, e.g., "Shark Music Player")</li>
|
|
<li>App description: (any description)</li>
|
|
<li>Redirect URI: <code>http://127.0.0.1:8228/callback</code></li>
|
|
<li>Check the Web API box</li>
|
|
</ul>
|
|
</li>
|
|
<li>Click <strong>"Save"</strong></li>
|
|
<li>Click <strong>"Settings"</strong> on your new app</li>
|
|
<li>Copy the <strong>Client ID</strong> (visible by default)</li>
|
|
<li>Click <strong>"View client secret"</strong> and copy the <strong>Client Secret</strong></li>
|
|
<li>Paste both values into the fields above</li>
|
|
</ol>
|
|
<p><strong>Note:</strong> The Client ID and Client Secret are used to authenticate your app with Spotify. Keep the Client Secret private and never share it publicly.</p>
|
|
<p><strong>Important:</strong> The Redirect URI must be exactly <code>http://127.0.0.1:8228/callback</code>. Port 8228 must be available when authorizing. If you get a port error, close any application using port 8228.</p>
|
|
<p><strong>Scopes used:</strong> This app requests access to your profile, email, saved library (tracks, albums), playlists (including private and collaborative), and followed artists.</p>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
{:else if loading}
|
|
<p style="padding: 8px;">Loading favorites...</p>
|
|
{:else}
|
|
<section class="favorites-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 === 'playlists'}>
|
|
<button onclick={() => viewMode = 'playlists'}>Playlists</button>
|
|
</li>
|
|
<li role="tab" aria-selected={viewMode === 'tracks'}>
|
|
<button onclick={() => viewMode = 'tracks'}>Tracks</button>
|
|
</li>
|
|
<li role="tab" aria-selected={viewMode === 'artists'}>
|
|
<button onclick={() => viewMode = 'artists'}>Artists</button>
|
|
</li>
|
|
<li role="tab" aria-selected={viewMode === 'albums'}>
|
|
<button onclick={() => viewMode = 'albums'}>Albums</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 syncing}
|
|
<div class="sync-status">
|
|
<p>Refreshing favorites from Spotify...</p>
|
|
</div>
|
|
{:else if viewMode === 'playlists'}
|
|
<!-- Playlists View -->
|
|
<div class="tab-header">
|
|
<h4>Favorite Playlists</h4>
|
|
<button onclick={refreshFavorites} disabled={syncing}>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
<div class="sunken-panel table-container">
|
|
<table class="interactive">
|
|
<thead>
|
|
<tr>
|
|
<th>Playlist</th>
|
|
<th>Tracks</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- Virtual Spotify Likes 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('spotify-likes')}
|
|
>
|
|
<td>Spotify Likes</td>
|
|
<td>{tracks.length}</td>
|
|
</tr>
|
|
{/if}
|
|
|
|
<!-- User Playlists -->
|
|
{#each playlists as playlist, i}
|
|
<tr
|
|
class:highlighted={selectedIndex === i}
|
|
onclick={() => handleItemClick(i)}
|
|
ondblclick={() => handlePlaylistDoubleClick(playlist.id)}
|
|
>
|
|
<td>{playlist.name}</td>
|
|
<td>{playlist.track_count}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{:else if viewMode === 'tracks'}
|
|
<!-- Tracks View -->
|
|
<div class="tab-header">
|
|
<h4>Favorite Tracks</h4>
|
|
<button onclick={refreshFavorites} disabled={syncing}>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
<div class="sunken-panel table-container">
|
|
<table class="interactive">
|
|
<thead>
|
|
<tr>
|
|
<th>Title</th>
|
|
<th>Artist</th>
|
|
<th>Album</th>
|
|
<th>Duration</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each tracks as track, i}
|
|
<tr
|
|
class:highlighted={selectedIndex === i}
|
|
onclick={() => handleItemClick(i)}
|
|
>
|
|
<td>{track.name}</td>
|
|
<td>{track.artist_name}</td>
|
|
<td>{track.album_name}</td>
|
|
<td class="duration">{formatDuration(track.duration_ms)}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{:else if viewMode === 'artists'}
|
|
<!-- Artists View -->
|
|
<div class="tab-header">
|
|
<h4>Followed Artists</h4>
|
|
<button onclick={refreshFavorites} disabled={syncing}>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
<div class="sunken-panel table-container">
|
|
<table class="interactive">
|
|
<thead>
|
|
<tr>
|
|
<th>Artist</th>
|
|
<th>Followers</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each artists as artist, i}
|
|
<tr
|
|
class:highlighted={selectedIndex === i}
|
|
onclick={() => handleItemClick(i)}
|
|
>
|
|
<td>{artist.name}</td>
|
|
<td>{artist.followers.toLocaleString()}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{:else if viewMode === 'albums'}
|
|
<!-- Albums View -->
|
|
<div class="tab-header">
|
|
<h4>Saved Albums</h4>
|
|
<button onclick={refreshFavorites} disabled={syncing}>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
<div class="sunken-panel table-container">
|
|
<table class="interactive">
|
|
<thead>
|
|
<tr>
|
|
<th>Album</th>
|
|
<th>Artist</th>
|
|
<th>Year</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each albums as album, i}
|
|
<tr
|
|
class:highlighted={selectedIndex === i}
|
|
onclick={() => handleItemClick(i)}
|
|
>
|
|
<td>{album.name}</td>
|
|
<td>{album.artist_name}</td>
|
|
<td>{album.release_date ? new Date(album.release_date).getFullYear() : '—'}</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{:else if viewMode === 'info'}
|
|
<!-- User Info View -->
|
|
<div class="user-container">
|
|
{#if error}
|
|
<fieldset>
|
|
<legend>Error</legend>
|
|
<div class="error-message">
|
|
{error}
|
|
</div>
|
|
</fieldset>
|
|
{/if}
|
|
|
|
<fieldset>
|
|
<legend>User Information</legend>
|
|
<div class="field-row">
|
|
<span class="field-label">Name:</span>
|
|
<span>{$spotifyAuth.user?.display_name || 'Unknown'}</span>
|
|
</div>
|
|
<div class="field-row">
|
|
<span class="field-label">Email:</span>
|
|
<span>{$spotifyAuth.user?.email || 'N/A'}</span>
|
|
</div>
|
|
<div class="field-row">
|
|
<span class="field-label">Country:</span>
|
|
<span>{$spotifyAuth.user?.country || 'N/A'}</span>
|
|
</div>
|
|
<div class="field-row">
|
|
<span class="field-label">Subscription:</span>
|
|
<span>{$spotifyAuth.user?.product ? $spotifyAuth.user.product.toUpperCase() : 'Unknown'}</span>
|
|
</div>
|
|
</fieldset>
|
|
|
|
{#if userRefreshMessage}
|
|
<div class="message-box">
|
|
{userRefreshMessage}
|
|
</div>
|
|
{/if}
|
|
|
|
<fieldset style="margin-top: 16px;">
|
|
<legend>Actions</legend>
|
|
<div class="button-row">
|
|
<button onclick={handleRefreshUser} disabled={refreshingUser}>
|
|
{refreshingUser ? 'Refreshing...' : 'Refresh User Info'}
|
|
</button>
|
|
<button onclick={refreshFavorites} disabled={syncing}>
|
|
{syncing ? 'Refreshing...' : 'Refresh Cache'}
|
|
</button>
|
|
<button onclick={handleLogout}>Logout</button>
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.spotify-wrapper {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
h2 {
|
|
margin: 0;
|
|
}
|
|
|
|
.login-section {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.favorites-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;
|
|
}
|
|
|
|
.tab-content .window-body {
|
|
padding: 0;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.tab-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px;
|
|
border-bottom: 1px solid var(--button-shadow, #808080);
|
|
}
|
|
|
|
.tab-header h4 {
|
|
margin: 0;
|
|
}
|
|
|
|
.table-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
min-height: 0;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
}
|
|
|
|
th {
|
|
text-align: left;
|
|
}
|
|
|
|
.duration {
|
|
font-family: monospace;
|
|
font-size: 0.9em;
|
|
text-align: center;
|
|
width: 80px;
|
|
}
|
|
|
|
.user-container {
|
|
padding: 16px;
|
|
}
|
|
|
|
.message-box {
|
|
padding: 8px;
|
|
margin: 8px 0;
|
|
background-color: var(--button-shadow, #2a2a2a);
|
|
border: 1px solid var(--button-highlight, #606060);
|
|
}
|
|
|
|
.sync-status {
|
|
padding: 16px 8px;
|
|
text-align: center;
|
|
}
|
|
|
|
.favorite-tracks-row {
|
|
font-weight: bold;
|
|
}
|
|
|
|
.window-body {
|
|
padding: 12px;
|
|
}
|
|
|
|
.field-row-stacked {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.field-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.field-label {
|
|
font-weight: bold;
|
|
min-width: 120px;
|
|
}
|
|
|
|
input[type="text"],
|
|
input[type="password"] {
|
|
width: 100%;
|
|
padding: 4px;
|
|
}
|
|
|
|
.button-row {
|
|
margin-top: 12px;
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.error-message {
|
|
background-color: #ffcccc;
|
|
color: #cc0000;
|
|
padding: 8px;
|
|
margin: 8px 0;
|
|
border: 1px solid #cc0000;
|
|
}
|
|
|
|
.info-message {
|
|
background-color: #cce5ff;
|
|
color: #004085;
|
|
padding: 8px;
|
|
margin: 8px 0;
|
|
border: 1px solid #004085;
|
|
}
|
|
|
|
.instructions {
|
|
margin-top: 16px;
|
|
padding: 8px;
|
|
background-color: var(--button-shadow, #2a2a2a);
|
|
}
|
|
|
|
.instructions summary {
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
user-select: none;
|
|
}
|
|
|
|
.instructions-content {
|
|
margin-top: 8px;
|
|
padding-left: 4px;
|
|
}
|
|
|
|
.instructions-content ol {
|
|
margin: 8px 0;
|
|
padding-left: 20px;
|
|
}
|
|
|
|
.instructions-content ul {
|
|
margin: 4px 0;
|
|
padding-left: 20px;
|
|
}
|
|
|
|
.instructions-content li {
|
|
margin: 6px 0;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.instructions-content strong {
|
|
font-weight: bold;
|
|
}
|
|
|
|
.instructions-content code {
|
|
background-color: var(--button-highlight, #505050);
|
|
padding: 2px 4px;
|
|
border-radius: 2px;
|
|
font-family: monospace;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.instructions-content p {
|
|
margin: 8px 0;
|
|
line-height: 1.5;
|
|
}
|
|
</style>
|