From 03f132a9f34854e0911789f52c70947d54d7e994 Mon Sep 17 00:00:00 2001 From: "cool.gitter.not.me.again.duh" Date: Thu, 29 May 2025 12:00:57 -0600 Subject: [PATCH] 2.0.0 --- app.py | 7 +- requirements.txt | 63 ++-- routes/config.py | 3 + routes/utils/watch/manager.py | 7 + src/js/config.ts | 81 ++++- src/js/watch.ts | 644 ++++++++++++++++++++++++++++++++++ static/css/config/config.css | 38 ++ static/css/main/base.css | 39 ++ static/css/watch/watch.css | 350 ++++++++++++++++++ static/html/album.html | 4 + static/html/artist.html | 4 + static/html/config.html | 19 + static/html/main.html | 4 + static/html/playlist.html | 4 + static/html/track.html | 4 + static/html/watch.html | 68 ++++ static/images/binoculars.svg | 12 + static/images/refresh-cw.svg | 4 + 18 files changed, 1318 insertions(+), 37 deletions(-) create mode 100644 src/js/watch.ts create mode 100644 static/css/watch/watch.css create mode 100644 static/html/watch.html create mode 100644 static/images/binoculars.svg create mode 100644 static/images/refresh-cw.svg diff --git a/app.py b/app.py index 133ad18..3bf61aa 100755 --- a/app.py +++ b/app.py @@ -160,12 +160,17 @@ def create_app(): def serve_config(): return render_template('config.html') + # New route: Serve watch.html under /watchlist + @app.route('/watchlist') + def serve_watchlist(): + return render_template('watch.html') + # New route: Serve playlist.html under /playlist/ @app.route('/playlist/') def serve_playlist(id): # The id parameter is captured, but you can use it as needed. return render_template('playlist.html') - # New route: Serve playlist.html under /playlist/ + @app.route('/album/') def serve_album(id): # The id parameter is captured, but you can use it as needed. diff --git a/requirements.txt b/requirements.txt index 8b596f7..5cae1d0 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,47 +1,60 @@ +amqp==5.3.1 annotated-types==0.7.0 -anyio==4.8.0 +anyio==4.9.0 +billiard==4.2.1 blinker==1.9.0 -certifi==2024.12.14 -charset-normalizer==3.4.1 -click==8.1.8 -deezspot @ git+https://github.com/Xoconoch/deezspot-fork-again +celery==5.5.2 +certifi==2025.4.26 +charset-normalizer==3.4.2 +click==8.2.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +deezspot @ git+https://github.com/Xoconoch/deezspot-fork-again@0b906e94e774cdeb8557a14a53236c0834fb1a2e defusedxml==0.7.1 -fastapi==0.115.7 -Flask==3.1.0 -Flask-Cors==5.0.0 -h11==0.14.0 +fastapi==0.115.12 +Flask==3.1.1 +Flask-Celery-Helper==1.1.0 +flask-cors==6.0.0 +h11==0.16.0 httptools==0.6.4 idna==3.10 ifaddr==0.2.0 itsdangerous==2.2.0 -Jinja2==3.1.5 +Jinja2==3.1.6 +kombu==5.5.3 librespot==0.0.9 MarkupSafe==3.0.2 mutagen==1.47.0 +prompt_toolkit==3.0.51 protobuf==3.20.1 -pycryptodome==3.21.0 +pycryptodome==3.23.0 pycryptodomex==3.17 -pydantic==2.10.6 -pydantic_core==2.27.2 +pydantic==2.11.5 +pydantic_core==2.33.2 PyOgg==0.6.14a1 -python-dotenv==1.0.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.1.0 PyYAML==6.0.2 -redis==5.2.1 +redis==6.2.0 requests==2.30.0 +six==1.17.0 sniffio==1.3.1 -spotipy -spotipy_anon -starlette==0.45.3 +spotipy==2.25.1 +spotipy_anon==1.4 +starlette==0.46.2 tqdm==4.67.1 -typing_extensions==4.12.2 -urllib3==2.3.0 -uvicorn==0.34.0 +typing-inspection==0.4.1 +typing_extensions==4.13.2 +tzdata==2025.2 +urllib3==2.4.0 +uvicorn==0.34.2 uvloop==0.21.0 +vine==5.1.0 waitress==3.0.2 -watchfiles==1.0.4 +watchfiles==1.0.5 +wcwidth==0.2.13 websocket-client==1.5.1 -websockets==14.2 +websockets==15.0.1 Werkzeug==3.1.3 zeroconf==0.62.0 -celery==5.3.6 -flask-celery-helper==1.1.0 \ No newline at end of file diff --git a/routes/config.py b/routes/config.py index 71a47f5..d363550 100644 --- a/routes/config.py +++ b/routes/config.py @@ -71,6 +71,7 @@ def get_watch_config(): CONFIG_PATH_WATCH.parent.mkdir(parents=True, exist_ok=True) # Default watch config defaults = { + 'enabled': False, 'watchedArtistAlbumGroup': ["album", "single"], 'watchPollIntervalSeconds': 3600 } @@ -82,6 +83,7 @@ def get_watch_config(): logging.error(f"Error reading watch config: {str(e)}") # Return defaults on error to prevent crashes return { + 'enabled': False, 'watchedArtistAlbumGroup': ["album", "single"], 'watchPollIntervalSeconds': 3600 } @@ -189,6 +191,7 @@ def handle_watch_config(): watch_config = get_watch_config() # Ensure defaults are applied if file was corrupted or missing fields defaults = { + 'enabled': False, 'watchedArtistAlbumGroup': ["album", "single"], 'watchPollIntervalSeconds': 3600 } diff --git a/routes/utils/watch/manager.py b/routes/utils/watch/manager.py index 88947d5..5ed25e9 100644 --- a/routes/utils/watch/manager.py +++ b/routes/utils/watch/manager.py @@ -27,6 +27,7 @@ CONFIG_PATH = Path('./data/config/watch.json') STOP_EVENT = threading.Event() DEFAULT_WATCH_CONFIG = { + "enabled": False, "watchPollIntervalSeconds": 3600, "max_tracks_per_run": 50, # For playlists "watchedArtistAlbumGroup": ["album", "single"], # Default for artists @@ -356,6 +357,12 @@ def playlist_watch_scheduler(): while not STOP_EVENT.is_set(): current_config = get_watch_config() # Get latest config for this run interval = current_config.get("watchPollIntervalSeconds", 3600) + watch_enabled = current_config.get("enabled", False) # Get enabled status + + if not watch_enabled: + logger.info("Watch Scheduler: Watch feature is disabled in config. Skipping checks.") + STOP_EVENT.wait(interval) # Still respect poll interval for checking config again + continue # Skip to next iteration try: logger.info("Watch Scheduler: Starting playlist check run.") diff --git a/src/js/config.ts b/src/js/config.ts index 09aaf1f..4e73a70 100644 --- a/src/js/config.ts +++ b/src/js/config.ts @@ -239,32 +239,52 @@ function setupEventListeners() { checkbox.addEventListener('change', saveWatchConfig); }); (document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.addEventListener('change', saveWatchConfig); + (document.getElementById('watchEnabledToggle') as HTMLInputElement | null)?.addEventListener('change', () => { + const isEnabling = (document.getElementById('watchEnabledToggle') as HTMLInputElement)?.checked; + const alreadyShownFirstEnableNotice = localStorage.getItem('watchFeatureFirstEnableNoticeShown'); + + if (isEnabling && !alreadyShownFirstEnableNotice) { + const noticeDiv = document.getElementById('watchFeatureFirstEnableNotice'); + if (noticeDiv) noticeDiv.style.display = 'block'; + localStorage.setItem('watchFeatureFirstEnableNoticeShown', 'true'); + // Hide notice after a delay or on click if preferred + setTimeout(() => { + if (noticeDiv) noticeDiv.style.display = 'none'; + }, 15000); // Hide after 15 seconds + } else { + // If disabling, or if notice was already shown, ensure it's hidden + const noticeDiv = document.getElementById('watchFeatureFirstEnableNotice'); + if (noticeDiv) noticeDiv.style.display = 'none'; + } + saveWatchConfig(); + updateWatchWarningDisplay(); // Call this also when the watch enable toggle changes + }); + (document.getElementById('realTimeToggle') as HTMLInputElement | null)?.addEventListener('change', () => { + saveConfig(); + updateWatchWarningDisplay(); // Call this when realTimeToggle changes + }); } function updateServiceSpecificOptions() { // Get the selected service const selectedService = (document.getElementById('defaultServiceSelect') as HTMLSelectElement | null)?.value; - - // Get all service-specific sections - const spotifyOptions = document.querySelectorAll('.config-item.spotify-specific'); - const deezerOptions = document.querySelectorAll('.config-item.deezer-specific'); - + // Handle Spotify specific options if (selectedService === 'spotify') { // Highlight Spotify section (document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); (document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); - + // Remove highlight from Deezer (document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); (document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); - } - // Handle Deezer specific options (for future use) + } + // Handle Deezer specific options else if (selectedService === 'deezer') { // Highlight Deezer section (document.getElementById('deezerQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); (document.getElementById('deezerAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.add('highlighted-option'); - + // Remove highlight from Spotify (document.getElementById('spotifyQualitySelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); (document.getElementById('spotifyAccountSelect') as HTMLElement | null)?.closest('.config-item')?.classList.remove('highlighted-option'); @@ -738,10 +758,14 @@ async function handleCredentialSubmit(e: Event) { await updateAccountSelectors(); await saveConfig(); loadCredentials(service!); - setFormVisibility(false); // Hide form and show add button on successful submission // Show success message showConfigSuccess(isEditingSearch ? 'API credentials saved successfully' : 'Account saved successfully'); + + // Add a delay before hiding the form + setTimeout(() => { + setFormVisibility(false); // Hide form and show add button on successful submission + }, 2000); // 2 second delay } catch (error: any) { showConfigError(error.message); } @@ -949,7 +973,17 @@ async function loadWatchConfig() { } const watchPollIntervalSecondsInput = document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null; - if (watchPollIntervalSecondsInput) watchPollIntervalSecondsInput.value = watchConfig.watchPollIntervalSeconds || '3600'; + if (watchPollIntervalSecondsInput) { + watchPollIntervalSecondsInput.value = watchConfig.watchPollIntervalSeconds || '3600'; + } + + const watchEnabledToggle = document.getElementById('watchEnabledToggle') as HTMLInputElement | null; + if (watchEnabledToggle) { + watchEnabledToggle.checked = !!watchConfig.enabled; + } + + // Call this after the state of the toggles has been set based on watchConfig + updateWatchWarningDisplay(); } catch (error: any) { showConfigError('Error loading watch config: ' + error.message); @@ -965,6 +999,7 @@ async function saveWatchConfig() { } const watchConfig = { + enabled: (document.getElementById('watchEnabledToggle') as HTMLInputElement | null)?.checked, watchedArtistAlbumGroup: selectedGroups, watchPollIntervalSeconds: parseInt((document.getElementById('watchPollIntervalSeconds') as HTMLInputElement | null)?.value || '3600', 10) || 3600, }; @@ -985,3 +1020,27 @@ async function saveWatchConfig() { showConfigError('Error saving watch config: ' + error.message); } } + +// New function to manage the warning display +function updateWatchWarningDisplay() { + const watchEnabledToggle = document.getElementById('watchEnabledToggle') as HTMLInputElement | null; + const realTimeToggle = document.getElementById('realTimeToggle') as HTMLInputElement | null; + const warningDiv = document.getElementById('watchEnabledWarning') as HTMLElement | null; + + if (watchEnabledToggle && realTimeToggle && warningDiv) { + const isWatchEnabled = watchEnabledToggle.checked; + const isRealTimeEnabled = realTimeToggle.checked; + + if (isWatchEnabled && !isRealTimeEnabled) { + warningDiv.style.display = 'block'; + } else { + warningDiv.style.display = 'none'; + } + } + // Hide the first-enable notice if watch is disabled or if it was already dismissed by timeout/interaction + // The primary logic for showing first-enable notice is in the event listener for watchEnabledToggle + const firstEnableNoticeDiv = document.getElementById('watchFeatureFirstEnableNotice'); + if (firstEnableNoticeDiv && watchEnabledToggle && !watchEnabledToggle.checked) { + firstEnableNoticeDiv.style.display = 'none'; + } +} diff --git a/src/js/watch.ts b/src/js/watch.ts new file mode 100644 index 0000000..b93d136 --- /dev/null +++ b/src/js/watch.ts @@ -0,0 +1,644 @@ +import { downloadQueue } from './queue.js'; // Assuming queue.js is in the same directory + +// Interfaces for API data +interface Image { + url: string; + height?: number; + width?: number; +} + +// --- Items from the initial /watch/list API calls --- +interface ArtistFromWatchList { + spotify_id: string; // Changed from id to spotify_id + name: string; + images?: Image[]; + total_albums?: number; // Already provided by /api/artist/watch/list +} + +// New interface for artists after initial processing (spotify_id mapped to id) +interface ProcessedArtistFromWatchList extends ArtistFromWatchList { + id: string; // This is the mapped spotify_id +} + +interface WatchedPlaylistOwner { // Kept as is, used by PlaylistFromWatchList + display_name?: string; + id?: string; +} + +interface PlaylistFromWatchList { + spotify_id: string; // Changed from id to spotify_id + name: string; + owner?: WatchedPlaylistOwner; + images?: Image[]; // Ensure images can be part of this initial fetch + total_tracks?: number; +} + +// New interface for playlists after initial processing (spotify_id mapped to id) +interface ProcessedPlaylistFromWatchList extends PlaylistFromWatchList { + id: string; // This is the mapped spotify_id +} +// --- End of /watch/list items --- + + +// --- Responses from /api/{artist|playlist}/info endpoints --- +interface AlbumWithImages { // For items in ArtistInfoResponse.items + images?: Image[]; + // Other album properties like name, id etc., are not strictly needed for this specific change +} + +interface ArtistInfoResponse { + artist_id: string; // Matches key from artist.py + artist_name: string; // Matches key from artist.py + artist_image_url?: string; // Matches key from artist.py + total: number; // This is total_albums, matches key from artist.py + artist_external_url?: string; // Matches key from artist.py + items?: AlbumWithImages[]; // Add album items to get the first album's image +} + +// PlaylistInfoResponse is effectively the Playlist interface from playlist.ts +// For clarity, defining it here based on what's needed for the card. +interface PlaylistInfoResponse { + id: string; + name: string; + description: string | null; + owner: { display_name?: string; id?: string; }; // Matches Playlist.owner + images: Image[]; // Matches Playlist.images + tracks: { total: number; /* items: PlaylistItem[] - not needed for card */ }; // Matches Playlist.tracks + followers?: { total: number; }; // Matches Playlist.followers + external_urls?: { spotify?: string }; // Matches Playlist.external_urls +} +// --- End of /info endpoint responses --- + + +// --- Final combined data structure for rendering cards --- +interface FinalArtistCardItem { + itemType: 'artist'; + id: string; // Spotify ID + name: string; // Best available name (from /info or fallback) + imageUrl?: string; // Best available image URL (from /info or fallback) + total_albums: number;// From /info or fallback + external_urls?: { spotify?: string }; // From /info +} + +interface FinalPlaylistCardItem { + itemType: 'playlist'; + id: string; // Spotify ID + name: string; // Best available name (from /info or fallback) + imageUrl?: string; // Best available image URL (from /info or fallback) + owner_name?: string; // From /info or fallback + total_tracks: number;// From /info or fallback + followers_count?: number; // From /info + description?: string | null; // From /info, for potential use (e.g., tooltip) + external_urls?: { spotify?: string }; // From /info +} + +type FinalCardItem = FinalArtistCardItem | FinalPlaylistCardItem; +// --- End of final card data structure --- + +// The type for items initially fetched from /watch/list, before detailed processing +// Updated to use ProcessedArtistFromWatchList for artists and ProcessedPlaylistFromWatchList for playlists +type InitialWatchedItem = + (ProcessedArtistFromWatchList & { itemType: 'artist' }) | + (ProcessedPlaylistFromWatchList & { itemType: 'playlist' }); + +// Interface for a settled promise (fulfilled) +interface CustomPromiseFulfilledResult { + status: 'fulfilled'; + value: T; +} + +// Interface for a settled promise (rejected) +interface CustomPromiseRejectedResult { + status: 'rejected'; + reason: any; +} + +type CustomSettledPromiseResult = CustomPromiseFulfilledResult | CustomPromiseRejectedResult; + +// Original WatchedItem type, which will be replaced by FinalCardItem for rendering +interface WatchedArtistOriginal { + id: string; + name: string; + images?: Image[]; + total_albums?: number; +} + +interface WatchedPlaylistOriginal { + id: string; + name: string; + owner?: WatchedPlaylistOwner; + images?: Image[]; + total_tracks?: number; +} + +type WatchedItem = (WatchedArtistOriginal & { itemType: 'artist' }) | (WatchedPlaylistOriginal & { itemType: 'playlist' }); + +document.addEventListener('DOMContentLoaded', function() { + const watchedItemsContainer = document.getElementById('watchedItemsContainer'); + const loadingIndicator = document.getElementById('loadingWatchedItems'); + const emptyStateIndicator = document.getElementById('emptyWatchedItems'); + const queueIcon = document.getElementById('queueIcon'); + const checkAllWatchedBtn = document.getElementById('checkAllWatchedBtn') as HTMLButtonElement | null; + + if (queueIcon) { + queueIcon.addEventListener('click', () => { + downloadQueue.toggleVisibility(); + }); + } + + if (checkAllWatchedBtn) { + checkAllWatchedBtn.addEventListener('click', async () => { + checkAllWatchedBtn.disabled = true; + const originalText = checkAllWatchedBtn.innerHTML; + checkAllWatchedBtn.innerHTML = 'Refreshing... Checking...'; + + try { + const artistCheckPromise = fetch('/api/artist/watch/trigger_check', { method: 'POST' }); + const playlistCheckPromise = fetch('/api/playlist/watch/trigger_check', { method: 'POST' }); + + // Use Promise.allSettled-like behavior to handle both responses + const results = await Promise.all([ + artistCheckPromise.then(async res => ({ + ok: res.ok, + data: await res.json().catch(() => ({ error: 'Invalid JSON response' })), + type: 'artist' + })).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'artist' })), + playlistCheckPromise.then(async res => ({ + ok: res.ok, + data: await res.json().catch(() => ({ error: 'Invalid JSON response' })), + type: 'playlist' + })).catch(e => ({ ok: false, data: { error: e.message || 'Request failed' }, type: 'playlist' })) + ]); + + const artistResult = results.find(r => r.type === 'artist'); + const playlistResult = results.find(r => r.type === 'playlist'); + + let successMessages: string[] = []; + let errorMessages: string[] = []; + + if (artistResult) { + if (artistResult.ok) { + successMessages.push(artistResult.data.message || 'Artist check triggered.'); + } else { + errorMessages.push(`Artist check failed: ${artistResult.data.error || 'Unknown error'}`); + } + } + + if (playlistResult) { + if (playlistResult.ok) { + successMessages.push(playlistResult.data.message || 'Playlist check triggered.'); + } else { + errorMessages.push(`Playlist check failed: ${playlistResult.data.error || 'Unknown error'}`); + } + } + + if (errorMessages.length > 0) { + showNotification(errorMessages.join(' '), true); + if (successMessages.length > 0) { // If some succeeded and some failed + // Delay the success message slightly so it doesn't overlap or get missed + setTimeout(() => showNotification(successMessages.join(' ')), 1000); + } + } else if (successMessages.length > 0) { + showNotification(successMessages.join(' ')); + } else { + showNotification('Could not determine check status for artists or playlists.', true); + } + + } catch (error: any) { // Catch for unexpected issues with Promise.all or setup + console.error('Error in checkAllWatchedBtn handler:', error); + showNotification(`An unexpected error occurred: ${error.message}`, true); + } finally { + checkAllWatchedBtn.disabled = false; + checkAllWatchedBtn.innerHTML = originalText; + } + }); + } + + // Initial load + loadWatchedItems(); +}); + +const MAX_NOTIFICATIONS = 3; + +async function loadWatchedItems() { + const watchedItemsContainer = document.getElementById('watchedItemsContainer'); + const loadingIndicator = document.getElementById('loadingWatchedItems'); + const emptyStateIndicator = document.getElementById('emptyWatchedItems'); + + showLoading(true); + showEmptyState(false); + if (watchedItemsContainer) watchedItemsContainer.innerHTML = ''; + + try { + const [artistsResponse, playlistsResponse] = await Promise.all([ + fetch('/api/artist/watch/list'), + fetch('/api/playlist/watch/list') + ]); + + if (!artistsResponse.ok || !playlistsResponse.ok) { + throw new Error('Failed to load initial watched items list'); + } + + const artists: ArtistFromWatchList[] = await artistsResponse.json(); + const playlists: PlaylistFromWatchList[] = await playlistsResponse.json(); + + const initialItems: InitialWatchedItem[] = [ + ...artists.map(artist => ({ + ...artist, + id: artist.spotify_id, // Map spotify_id to id for artists + itemType: 'artist' as const + })), + ...playlists.map(playlist => ({ + ...playlist, + id: playlist.spotify_id, // Map spotify_id to id for playlists + itemType: 'playlist' as const + })) + ]; + + if (initialItems.length === 0) { + showLoading(false); + showEmptyState(true); + return; + } + + // Fetch detailed info for each item + const detailedItemPromises = initialItems.map(async (initialItem) => { + try { + if (initialItem.itemType === 'artist') { + const infoResponse = await fetch(`/api/artist/info?id=${initialItem.id}`); + if (!infoResponse.ok) { + console.warn(`Failed to fetch artist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`); + // Fallback to initial data if info fetch fails + return { + itemType: 'artist', + id: initialItem.id, + name: initialItem.name, + imageUrl: (initialItem as ArtistFromWatchList).images?.[0]?.url, // Cast to access images + total_albums: (initialItem as ArtistFromWatchList).total_albums || 0, // Cast to access total_albums + } as FinalArtistCardItem; + } + const info: ArtistInfoResponse = await infoResponse.json(); + return { + itemType: 'artist', + id: initialItem.id, // Use the ID from the watch list, as /info might have 'artist_id' + name: info.artist_name || initialItem.name, // Prefer info, fallback to initial + imageUrl: info.items?.[0]?.images?.[0]?.url || info.artist_image_url || (initialItem as ProcessedArtistFromWatchList).images?.[0]?.url, // Prioritize first album image from items + total_albums: info.total, // 'total' from ArtistInfoResponse is total_albums + external_urls: { spotify: info.artist_external_url } + } as FinalArtistCardItem; + } else { // Playlist + const infoResponse = await fetch(`/api/playlist/info?id=${initialItem.id}`); + if (!infoResponse.ok) { + console.warn(`Failed to fetch playlist info for ${initialItem.name} (ID: ${initialItem.id}): ${infoResponse.status}`); + // Fallback to initial data if info fetch fails + return { + itemType: 'playlist', + id: initialItem.id, + name: initialItem.name, + imageUrl: (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Cast to access images + owner_name: (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Cast to access owner + total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0, // Cast to access total_tracks + } as FinalPlaylistCardItem; + } + const info: PlaylistInfoResponse = await infoResponse.json(); + return { + itemType: 'playlist', + id: initialItem.id, // Use ID from watch list + name: info.name || initialItem.name, // Prefer info, fallback to initial + imageUrl: info.images?.[0]?.url || (initialItem as ProcessedPlaylistFromWatchList).images?.[0]?.url, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList) + owner_name: info.owner?.display_name || (initialItem as ProcessedPlaylistFromWatchList).owner?.display_name, // Prefer info, fallback to initial (ProcessedPlaylistFromWatchList) + total_tracks: info.tracks.total, // 'total' from PlaylistInfoResponse.tracks + followers_count: info.followers?.total, + description: info.description, + external_urls: info.external_urls + } as FinalPlaylistCardItem; + } + } catch (e: any) { + console.error(`Error processing item ${initialItem.name} (ID: ${initialItem.id}):`, e); + // Return a fallback structure if processing fails catastrophically + return { + itemType: initialItem.itemType, + id: initialItem.id, + name: initialItem.name + " (Error loading details)", + imageUrl: initialItem.images?.[0]?.url, + // Add minimal common fields for artists and playlists for fallback + ...(initialItem.itemType === 'artist' ? { total_albums: (initialItem as ProcessedArtistFromWatchList).total_albums || 0 } : {}), + ...(initialItem.itemType === 'playlist' ? { total_tracks: (initialItem as ProcessedPlaylistFromWatchList).total_tracks || 0 } : {}), + } as FinalCardItem; // Cast to avoid TS errors, knowing one of the spreads will match + } + }); + + // Simulating Promise.allSettled behavior for compatibility + const settledResults: CustomSettledPromiseResult[] = await Promise.all( + detailedItemPromises.map(p => + p.then(value => ({ status: 'fulfilled', value } as CustomPromiseFulfilledResult)) + .catch(reason => ({ status: 'rejected', reason } as CustomPromiseRejectedResult)) + ) + ); + + const finalItems: FinalCardItem[] = settledResults + .filter((result): result is CustomPromiseFulfilledResult => result.status === 'fulfilled') + .map(result => result.value) + .filter(item => item !== null) as FinalCardItem[]; // Ensure no nulls from catastrophic failures + + showLoading(false); + + if (finalItems.length === 0) { + showEmptyState(true); + // Potentially show a different message if initialItems existed but all failed to load details + if (initialItems.length > 0 && watchedItemsContainer) { + watchedItemsContainer.innerHTML = `

Could not load details for any watched items. Please check the console for errors.

`; + } + return; + } + + if (watchedItemsContainer) { + // Clear previous content + watchedItemsContainer.innerHTML = ''; + + if (finalItems.length > 8) { + const playlistItems = finalItems.filter(item => item.itemType === 'playlist') as FinalPlaylistCardItem[]; + const artistItems = finalItems.filter(item => item.itemType === 'artist') as FinalArtistCardItem[]; + + // Create and append Playlist section + if (playlistItems.length > 0) { + const playlistSection = document.createElement('div'); + playlistSection.className = 'watched-items-group'; + const playlistHeader = document.createElement('h2'); + playlistHeader.className = 'watched-group-header'; + playlistHeader.textContent = 'Watched Playlists'; + playlistSection.appendChild(playlistHeader); + const playlistGrid = document.createElement('div'); + playlistGrid.className = 'results-grid'; // Use existing grid style + playlistItems.forEach(item => { + const cardElement = createWatchedItemCard(item); + playlistGrid.appendChild(cardElement); + }); + playlistSection.appendChild(playlistGrid); + watchedItemsContainer.appendChild(playlistSection); + } else { + const noPlaylistsMessage = document.createElement('p'); + noPlaylistsMessage.textContent = 'No watched playlists.'; + noPlaylistsMessage.className = 'empty-group-message'; + // Optionally add a header for consistency even if empty + const playlistHeader = document.createElement('h2'); + playlistHeader.className = 'watched-group-header'; + playlistHeader.textContent = 'Watched Playlists'; + watchedItemsContainer.appendChild(playlistHeader); + watchedItemsContainer.appendChild(noPlaylistsMessage); + } + + // Create and append Artist section + if (artistItems.length > 0) { + const artistSection = document.createElement('div'); + artistSection.className = 'watched-items-group'; + const artistHeader = document.createElement('h2'); + artistHeader.className = 'watched-group-header'; + artistHeader.textContent = 'Watched Artists'; + artistSection.appendChild(artistHeader); + const artistGrid = document.createElement('div'); + artistGrid.className = 'results-grid'; // Use existing grid style + artistItems.forEach(item => { + const cardElement = createWatchedItemCard(item); + artistGrid.appendChild(cardElement); + }); + artistSection.appendChild(artistGrid); + watchedItemsContainer.appendChild(artistSection); + } else { + const noArtistsMessage = document.createElement('p'); + noArtistsMessage.textContent = 'No watched artists.'; + noArtistsMessage.className = 'empty-group-message'; + // Optionally add a header for consistency even if empty + const artistHeader = document.createElement('h2'); + artistHeader.className = 'watched-group-header'; + artistHeader.textContent = 'Watched Artists'; + watchedItemsContainer.appendChild(artistHeader); + watchedItemsContainer.appendChild(noArtistsMessage); + } + + } else { // 8 or fewer items, render them directly + finalItems.forEach(item => { + const cardElement = createWatchedItemCard(item); + watchedItemsContainer.appendChild(cardElement); + }); + } + } + + } catch (error: any) { + console.error('Error loading watched items:', error); + showLoading(false); + if (watchedItemsContainer) { + watchedItemsContainer.innerHTML = `

Error loading watched items: ${error.message}

`; + } + } +} + +function createWatchedItemCard(item: FinalCardItem): HTMLDivElement { + const cardElement = document.createElement('div'); + cardElement.className = 'watched-item-card'; + cardElement.dataset.itemId = item.id; + cardElement.dataset.itemType = item.itemType; + + // Check Now button HTML is no longer generated separately here for absolute positioning + + let imageUrl = '/static/images/placeholder.jpg'; + if (item.imageUrl) { + imageUrl = item.imageUrl; + } + + let detailsHtml = ''; + let typeBadgeClass = ''; + let typeName = ''; + + if (item.itemType === 'artist') { + typeName = 'Artist'; + typeBadgeClass = 'artist'; + const artist = item as FinalArtistCardItem; + detailsHtml = artist.total_albums !== undefined ? `${artist.total_albums} albums` : ''; + } else if (item.itemType === 'playlist') { + typeName = 'Playlist'; + typeBadgeClass = 'playlist'; + const playlist = item as FinalPlaylistCardItem; + detailsHtml = playlist.owner_name ? `By: ${playlist.owner_name}` : ''; + detailsHtml += playlist.total_tracks !== undefined ? ` • ${playlist.total_tracks} tracks` : ''; + if (playlist.followers_count !== undefined) { + detailsHtml += ` • ${playlist.followers_count} followers`; + } + } + + cardElement.innerHTML = ` +
+ ${item.name} +
+
${item.name}
+
${detailsHtml}
+ ${typeName} +
+ + +
+ `; + + // Add click event to navigate to the item's detail page + cardElement.addEventListener('click', (e: MouseEvent) => { + const target = e.target as HTMLElement; + // Don't navigate if any button within the card was clicked + if (target.closest('button')) { + return; + } + window.location.href = `/${item.itemType}/${item.id}`; + }); + + // Add event listener for the "Check Now" button + const checkNowBtn = cardElement.querySelector('.check-item-now-btn') as HTMLButtonElement | null; + if (checkNowBtn) { + checkNowBtn.addEventListener('click', (e: MouseEvent) => { + e.stopPropagation(); + const itemId = checkNowBtn.dataset.id; + const itemType = checkNowBtn.dataset.type as 'artist' | 'playlist'; + if (itemId && itemType) { + triggerItemCheck(itemId, itemType, checkNowBtn); + } + }); + } + + // Add event listener for the "Unwatch" button + const unwatchBtn = cardElement.querySelector('.unwatch-item-btn') as HTMLButtonElement | null; + if (unwatchBtn) { + unwatchBtn.addEventListener('click', (e: MouseEvent) => { + e.stopPropagation(); + const itemId = unwatchBtn.dataset.id; + const itemType = unwatchBtn.dataset.type as 'artist' | 'playlist'; + if (itemId && itemType) { + unwatchItem(itemId, itemType, unwatchBtn, cardElement); + } + }); + } + + return cardElement; +} + +function showLoading(show: boolean) { + const loadingIndicator = document.getElementById('loadingWatchedItems'); + if (loadingIndicator) loadingIndicator.classList.toggle('hidden', !show); +} + +function showEmptyState(show: boolean) { + const emptyStateIndicator = document.getElementById('emptyWatchedItems'); + if (emptyStateIndicator) emptyStateIndicator.classList.toggle('hidden', !show); +} + +async function unwatchItem(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement, cardElement: HTMLElement) { + const originalButtonContent = buttonElement.innerHTML; + buttonElement.disabled = true; + buttonElement.innerHTML = 'Unwatching...'; // Assuming a small loader icon + + const endpoint = `/api/${itemType}/watch/${itemId}`; + + try { + const response = await fetch(endpoint, { method: 'DELETE' }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || `Server error: ${response.status}`); + } + const result = await response.json(); + showNotification(result.message || `${itemType.charAt(0).toUpperCase() + itemType.slice(1)} unwatched successfully.`); + + cardElement.style.transition = 'opacity 0.5s ease, transform 0.5s ease'; + cardElement.style.opacity = '0'; + cardElement.style.transform = 'scale(0.9)'; + setTimeout(() => { + cardElement.remove(); + const watchedItemsContainer = document.getElementById('watchedItemsContainer'); + const playlistGroups = document.querySelectorAll('.watched-items-group .results-grid'); + let totalItemsLeft = 0; + + if (playlistGroups.length > 0) { // Grouped view + playlistGroups.forEach(group => { + totalItemsLeft += group.childElementCount; + }); + // If a group becomes empty, we might want to remove the group header or show an empty message for that group. + // This can be added here if desired. + } else if (watchedItemsContainer) { // Non-grouped view + totalItemsLeft = watchedItemsContainer.childElementCount; + } + + if (totalItemsLeft === 0) { + // If all items are gone (either from groups or directly), reload to show empty state. + // This also correctly handles the case where the initial list had <= 8 items. + loadWatchedItems(); + } + + }, 500); + + } catch (error: any) { + console.error(`Error unwatching ${itemType}:`, error); + showNotification(`Failed to unwatch: ${error.message}`, true); + buttonElement.disabled = false; + buttonElement.innerHTML = originalButtonContent; + } +} + +async function triggerItemCheck(itemId: string, itemType: 'artist' | 'playlist', buttonElement: HTMLButtonElement) { + const originalButtonContent = buttonElement.innerHTML; // Will just be the img + buttonElement.disabled = true; + // Keep the icon, but we can add a class for spinning or use the same icon. + // For simplicity, just using the same icon. Text "Checking..." is removed. + buttonElement.innerHTML = 'Checking...'; + + const endpoint = `/api/${itemType}/watch/trigger_check/${itemId}`; + + try { + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); // Handle non-JSON error responses + throw new Error(errorData.error || `Server error: ${response.status}`); + } + const result = await response.json(); + showNotification(result.message || `Successfully triggered check for ${itemType}.`); + } catch (error: any) { + console.error(`Error triggering ${itemType} check:`, error); + showNotification(`Failed to trigger check: ${error.message}`, true); + } finally { + buttonElement.disabled = false; + buttonElement.innerHTML = originalButtonContent; + } +} + +// Helper function to show notifications (can be moved to a shared utility file if used elsewhere) +function showNotification(message: string, isError: boolean = false) { + const notificationArea = document.getElementById('notificationArea') || createNotificationArea(); + + // Limit the number of visible notifications + while (notificationArea.childElementCount >= MAX_NOTIFICATIONS) { + const oldestNotification = notificationArea.firstChild; // In column-reverse, firstChild is visually the bottom one + if (oldestNotification) { + oldestNotification.remove(); + } else { + break; // Should not happen if childElementCount > 0 + } + } + + const notification = document.createElement('div'); + notification.className = `notification-toast ${isError ? 'error' : 'success'}`; + notification.textContent = message; + + notificationArea.appendChild(notification); + + // Auto-remove after 5 seconds + setTimeout(() => { + notification.classList.add('hide'); + setTimeout(() => notification.remove(), 500); // Remove from DOM after fade out + }, 5000); +} + +function createNotificationArea(): HTMLElement { + const area = document.createElement('div'); + area.id = 'notificationArea'; + document.body.appendChild(area); + return area; +} \ No newline at end of file diff --git a/static/css/config/config.css b/static/css/config/config.css index 1f9d01f..3d11079 100644 --- a/static/css/config/config.css +++ b/static/css/config/config.css @@ -985,4 +985,42 @@ input:checked + .slider:before { /* Reset some global label styles if they interfere */ display: inline; margin-bottom: 0; +} + +/* Urgent Warning Message Style */ +.urgent-warning-message { + background-color: rgba(255, 165, 0, 0.1); /* Orange/Amber background */ + border: 1px solid #FFA500; /* Orange/Amber border */ + color: #FFA500; /* Orange/Amber text */ + padding: 1rem; + border-radius: 8px; + display: flex; /* Use flex to align icon and text */ + align-items: center; /* Vertically align icon and text */ + margin-top: 1rem; + margin-bottom: 1rem; +} + +.urgent-warning-message .warning-icon { + margin-right: 0.75rem; /* Space between icon and text */ + min-width: 24px; /* Ensure icon doesn't shrink too much */ + color: #FFA500; /* Match icon color to text/border */ +} + +/* Existing info-message style - ensure it doesn't conflict or adjust if needed */ +.info-message { + background-color: rgba(0, 123, 255, 0.1); + border: 1px solid #007bff; + color: #007bff; + padding: 1rem; + border-radius: 8px; + margin-top: 1rem; + margin-bottom: 1rem; +} + +/* Version text styling */ +.version-text { + font-size: 0.9rem; + color: #888; /* Light grey color */ + margin-left: auto; /* Push it to the right */ + padding-top: 0.5rem; /* Align with title better */ } \ No newline at end of file diff --git a/static/css/main/base.css b/static/css/main/base.css index a933b44..06037c3 100644 --- a/static/css/main/base.css +++ b/static/css/main/base.css @@ -27,6 +27,9 @@ --color-primary-hover: #17a44b; --color-error: #c0392b; --color-success: #2ecc71; + /* Adding accent green if not present, or ensuring it is */ + --color-accent-green: #22c55e; /* Example: A Tailwind-like green */ + --color-accent-green-dark: #16a34a; /* Darker shade for hover */ /* Spacing */ --space-xs: 0.25rem; @@ -499,4 +502,40 @@ a:hover, a:focus { font-style: italic; font-size: 0.9rem; margin-top: 0.5rem; +} + +.watchlist-icon { + position: fixed; + right: 20px; + bottom: 90px; /* Positioned above the queue icon */ + z-index: 1000; +} + +/* Responsive adjustments for floating icons */ +@media (max-width: 768px) { + .floating-icon { + width: 48px; + height: 48px; + right: 15px; + } + .settings-icon { + bottom: 15px; /* Adjust for smaller screens */ + } + .queue-icon { + bottom: 15px; /* Adjust for smaller screens */ + } + .watchlist-icon { + bottom: 75px; /* Adjust for smaller screens, above queue icon */ + } + .home-btn.floating-icon { /* Specific for home button if it's also floating */ + left: 15px; + bottom: 15px; + } +} + +/* Ensure images inside btn-icon are sized correctly */ +.btn-icon img { + width: 20px; + height: 20px; + filter: brightness(0) invert(1); } \ No newline at end of file diff --git a/static/css/watch/watch.css b/static/css/watch/watch.css new file mode 100644 index 0000000..5c4215b --- /dev/null +++ b/static/css/watch/watch.css @@ -0,0 +1,350 @@ +/* static/css/watch/watch.css */ + +/* General styles for the watch page, similar to main.css */ +body { + font-family: var(--font-family-sans-serif); + background-color: var(--background-color); + color: white; + margin: 0; + padding: 0; +} + +.app-container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.watch-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border-color-soft); +} + +.watch-header h1 { + color: white; + font-size: 2em; + margin: 0; +} + +.check-all-btn { + padding: 10px 15px; + font-size: 0.9em; + display: flex; + align-items: center; + gap: 8px; /* Space between icon and text */ + background-color: var(--color-accent-green); /* Green background */ + color: white; /* Ensure text is white for contrast */ + border: none; /* Remove default border */ +} + +.check-all-btn:hover { + background-color: var(--color-accent-green-dark); /* Darker green on hover */ +} + +.check-all-btn img { + width: 18px; /* Slightly larger for header button */ + height: 18px; + filter: brightness(0) invert(1); /* Ensure header icon is white */ +} + +.back-to-search-btn { + padding: 10px 20px; + font-size: 0.9em; +} + +/* Styling for the grid of watched items, similar to results-grid */ +.results-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); /* Responsive grid */ + gap: 20px; + padding: 0; +} + +/* Individual watched item card styling, inspired by result-card from main.css */ +.watched-item-card { + background-color: var(--color-surface); + border-radius: var(--border-radius-medium); + padding: 15px; + box-shadow: var(--shadow-soft); + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; + position: relative; +} + +.watched-item-card:hover { + transform: translateY(-5px); + box-shadow: var(--shadow-medium); + border-top: 1px solid var(--border-color-soft); +} + +.item-art-wrapper { + width: 100%; + padding-bottom: 100%; /* 1:1 Aspect Ratio */ + position: relative; + margin-bottom: 15px; + border-radius: var(--border-radius-soft); + overflow: hidden; +} + +.item-art { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; /* Cover the area, cropping if necessary */ +} + +.item-name { + font-size: 1.1em; + font-weight: bold; + color: white; + margin-bottom: 5px; + display: -webkit-box; + -webkit-line-clamp: 2; /* Limit to 2 lines */ + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + min-height: 2.4em; /* Reserve space for two lines */ +} + +.item-details { + font-size: 0.9em; + color: white; + margin-bottom: 10px; + line-height: 1.4; + width: 100%; /* Ensure it takes full width for centering/alignment */ +} + +.item-details span { + display: block; /* Each detail on a new line */ + margin-bottom: 3px; +} + +.item-type-badge { + display: inline-block; + padding: 3px 8px; + font-size: 0.75em; + font-weight: bold; + border-radius: var(--border-radius-small); + margin-bottom: 10px; + text-transform: uppercase; +} + +.item-type-badge.artist { + background-color: var(--color-accent-blue-bg); + color: var(--color-accent-blue-text); +} + +.item-type-badge.playlist { + background-color: var(--color-accent-green-bg); + color: var(--color-accent-green-text); +} + +/* Action buttons (e.g., Go to item, Unwatch) */ +.item-actions { + margin-top: auto; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 10px; + border-top: 1px solid var(--border-color-soft); +} + +.item-actions .btn-icon { + padding: 0; + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0; + border: none; +} + +.item-actions .check-item-now-btn, +.item-actions .unwatch-item-btn { + /* Shared properties are in .item-actions .btn-icon */ +} + +.item-actions .check-item-now-btn { + background-color: var(--color-accent-green); +} + +.item-actions .check-item-now-btn:hover { + background-color: var(--color-accent-green-dark); +} + +.item-actions .check-item-now-btn img, +.item-actions .unwatch-item-btn img { + width: 16px; + height: 16px; + filter: brightness(0) invert(1); +} + +.item-actions .unwatch-item-btn { + background-color: var(--color-error); + color: white; +} + +.item-actions .unwatch-item-btn:hover { + background-color: #a52a2a; +} + +/* Loading and Empty State - reuse from main.css if possible or define here */ +.loading, +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 40px 20px; + color: var(--text-color-muted); + width: 100%; +} + +.loading.hidden, +.empty-state.hidden { + display: none; +} + +.loading-indicator { + font-size: 1.2em; + margin-bottom: 10px; + color: white; +} + +.empty-state-content { + max-width: 400px; +} + +.empty-state-icon { + width: 80px; + height: 80px; + margin-bottom: 20px; + opacity: 0.7; + filter: brightness(0) invert(1); /* Added to make icon white */ +} + +.empty-state h2 { + font-size: 1.5em; + color: white; + margin-bottom: 10px; +} + +.empty-state p { + font-size: 1em; + line-height: 1.5; + color: white; +} + +/* Ensure floating icons from base.css are not obscured or mispositioned */ +/* No specific overrides needed if base.css handles them well */ + +/* Responsive adjustments if needed */ +@media (max-width: 768px) { + .results-grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + } + .watch-header h1 { + font-size: 1.5em; + } + .watched-group-header { + font-size: 1.5rem; + } +} + +@media (max-width: 480px) { + .results-grid { + grid-template-columns: 1fr; /* Single column on very small screens */ + } + .watched-item-card { + padding: 10px; + } + .item-name { + font-size: 1em; + } + .item-details { + font-size: 0.8em; + } +} + +.watched-items-group { + margin-bottom: 2rem; /* Space between groups */ +} + +.watched-group-header { + font-size: 1.8rem; + color: var(--color-text-primary); + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.empty-group-message { + color: var(--color-text-secondary); + padding: 1rem; + text-align: center; + font-style: italic; +} + +/* Ensure the main watchedItemsContainer still behaves like a grid if there are few items */ +#watchedItemsContainer:not(:has(.watched-items-group)) { + display: grid; + /* Assuming results-grid styles are already defined elsewhere, + or copy relevant grid styles here if needed */ +} + +/* Notification Toast Styles */ +#notificationArea { + position: fixed; + bottom: 20px; + left: 50%; /* Center horizontally */ + transform: translateX(-50%); /* Adjust for exact centering */ + z-index: 2000; + display: flex; + flex-direction: column-reverse; + gap: 10px; + width: auto; /* Allow width to be determined by content */ + max-width: 90%; /* Prevent it from being too wide on large screens */ +} + +.notification-toast { + padding: 12px 20px; + border-radius: var(--border-radius-medium); + color: white; /* Default text color to white */ + font-size: 0.9em; + box-shadow: var(--shadow-strong); + opacity: 1; + transition: opacity 0.5s ease, transform 0.5s ease; + transform: translateX(0); /* Keep this for the hide animation */ + text-align: center; /* Center text within the toast */ +} + +.notification-toast.success { + background-color: var(--color-success); /* Use existing success color */ + /* color: var(--color-accent-green-text); REMOVE - use white */ + /* border: 1px solid var(--color-accent-green-text); REMOVE */ +} + +.notification-toast.error { + background-color: var(--color-error); /* Use existing error color */ + /* color: var(--color-accent-red-text); REMOVE - use white */ + /* border: 1px solid var(--color-accent-red-text); REMOVE */ +} + +.notification-toast.hide { + opacity: 0; + transform: translateY(100%); /* Slide down for exit, or could keep translateX if preferred */ +} \ No newline at end of file diff --git a/static/html/album.html b/static/html/album.html index 0747fb5..a02c747 100644 --- a/static/html/album.html +++ b/static/html/album.html @@ -50,6 +50,10 @@ Home + + Watchlist + + + + Watchlist + + + + Watchlist + + + + Watchlist + + + + +
+ +
+ + + + + + + + + Home + + + + Watchlist + + + + + + + + + \ No newline at end of file diff --git a/static/images/binoculars.svg b/static/images/binoculars.svg new file mode 100644 index 0000000..553498d --- /dev/null +++ b/static/images/binoculars.svg @@ -0,0 +1,12 @@ + + + + binoculars-filled + + + + + + + + \ No newline at end of file diff --git a/static/images/refresh-cw.svg b/static/images/refresh-cw.svg new file mode 100644 index 0000000..efd180f --- /dev/null +++ b/static/images/refresh-cw.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file