diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc5441f --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Wails Build +build/ +*.exe +*.dll +*.dylib +*.so + +# Wails Generated Files +frontend/wailsjs/ + +# Go +*.test +*.out +go.work +go.work.sum + +# Node / Frontend +node_modules/ +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.pnpm-store/ +.npm +.yarn +*.tsbuildinfo + +# IDE / Editors +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# OS +Thumbs.db +desktop.ini + +# Environment +.env +.env.local +.env.*.local + +# Logs +*.log +logs/ + +# Temporary files +tmp/ +temp/ +*.tmp +*.bak +*.old + +# Build notes (optional - uncomment if you don't want to commit) +# BUILD_NOTES.md diff --git a/SpotiFLAC.py b/SpotiFLAC.py deleted file mode 100644 index 2ed7c35..0000000 --- a/SpotiFLAC.py +++ /dev/null @@ -1,2294 +0,0 @@ -import sys -import os -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -import requests -import re -import asyncio -import json -from packaging import version -import qdarktheme -from mutagen.flac import FLAC - -from PyQt6.QtWidgets import ( - QApplication, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLineEdit, - QLabel, QFileDialog, QListWidget, QTextEdit, QTabWidget, QButtonGroup, QRadioButton, - QAbstractItemView, QProgressBar, QCheckBox, QDialog, - QDialogButtonBox, QComboBox, QStyledItemDelegate, QMessageBox -) -from PyQt6.QtCore import Qt, QThread, pyqtSignal, QUrl, QTimer, QTime, QSettings, QSize -from PyQt6.QtGui import QIcon, QTextCursor, QDesktopServices, QPixmap, QBrush, QPainter, QColor -from PyQt6.QtSvg import QSvgRenderer -from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply - -from getMetadata import get_filtered_data, parse_uri, SpotifyInvalidUrlException -from tidalDL import TidalDownloader -from deezerDL import DeezerDownloader -from getSecret import scrape_and_save - -@dataclass -class Track: - external_urls: str - title: str - artists: str - album: str - track_number: int - duration_ms: int - id: str - isrc: str = "" - release_date: str = "" - -class SecretScrapeWorker(QThread): - finished = pyqtSignal(bool, str) - progress = pyqtSignal(str) - - def run(self): - try: - self.progress.emit("Fixing error...") - self.progress.emit("Please wait, this may take a moment...") - - success, message = scrape_and_save(progress_callback=self.progress.emit) - - if success: - self.finished.emit(True, "Fixed successfully!") - else: - self.finished.emit(False, message) - - except Exception as e: - self.finished.emit(False, f"Error: {str(e)}") - -class MetadataFetchWorker(QThread): - finished = pyqtSignal(dict) - error = pyqtSignal(str) - - def __init__(self, url): - super().__init__() - self.url = url - - def run(self): - try: - metadata = get_filtered_data(self.url) - if "error" in metadata: - self.error.emit(metadata["error"]) - else: - self.finished.emit(metadata) - except SpotifyInvalidUrlException as e: - self.error.emit(str(e)) - except Exception as e: - self.error.emit(f'Failed to fetch metadata: {str(e)}') - -class DownloadWorker(QThread): - finished = pyqtSignal(bool, str, list, list, list) - progress = pyqtSignal(str, int) - - def __init__(self, tracks, outpath, is_single_track=False, is_album=False, is_playlist=False, - album_or_playlist_name='', filename_format='title_artist', use_track_numbers=True, - use_artist_subfolders=False, use_album_subfolders=False, service="tidal", tidal_api_url=None): - super().__init__() - self.tracks = tracks - self.outpath = outpath - self.is_single_track = is_single_track - self.is_album = is_album - self.is_playlist = is_playlist - self.album_or_playlist_name = album_or_playlist_name - self.filename_format = filename_format - self.use_track_numbers = use_track_numbers - self.use_artist_subfolders = use_artist_subfolders - self.use_album_subfolders = use_album_subfolders - self.service = service - self.tidal_api_url = tidal_api_url - self.is_paused = False - self.is_stopped = False - self.failed_tracks = [] - self.successful_tracks = [] - self.skipped_tracks = [] - - def get_flac_isrc(self, filepath): - try: - audio = FLAC(filepath) - if 'isrc' in audio: - return audio['isrc'][0] - except Exception: - pass - return None - - def get_formatted_filename(self, track): - if self.filename_format == "artist_title": - filename = f"{track.artists} - {track.title}.flac" - elif self.filename_format == "title_only": - filename = f"{track.title}.flac" - else: - filename = f"{track.title} - {track.artists}.flac" - return re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', filename) - - def run(self): - try: - if self.service == "tidal": - downloader = TidalDownloader(api_url=self.tidal_api_url) - deezer_downloader = DeezerDownloader() - elif self.service == "deezer": - downloader = DeezerDownloader() - deezer_downloader = None - else: - downloader = TidalDownloader(api_url=self.tidal_api_url) - deezer_downloader = DeezerDownloader() - - def progress_update(current, total): - if total <= 0: - self.progress.emit("Processing metadata...", 0) - - downloader.set_progress_callback(progress_update) - - total_tracks = len(self.tracks) - - for i, track in enumerate(self.tracks): - while self.is_paused: - if self.is_stopped: - return - self.msleep(100) - if self.is_stopped: - return - - self.progress.emit(f"Starting download ({i+1}/{total_tracks}): {track.title} - {track.artists}", - int((i) / total_tracks * 100)) - - try: - if self.is_playlist: - track_outpath = self.outpath - - if self.use_artist_subfolders: - artist_name = track.artists.split(', ')[0] if ', ' in track.artists else track.artists - artist_folder = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', artist_name) - artist_folder = artist_folder.rstrip('. ') - track_outpath = os.path.join(track_outpath, artist_folder) - - if self.use_album_subfolders: - album_folder = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', track.album) - album_folder = album_folder.rstrip('. ') - track_outpath = os.path.join(track_outpath, album_folder) - - os.makedirs(track_outpath, exist_ok=True) - else: - track_outpath = self.outpath - - spotify_isrc = track.isrc - if spotify_isrc: - is_already_downloaded = False - try: - for filename in os.listdir(track_outpath): - if filename.lower().endswith('.flac'): - filepath = os.path.join(track_outpath, filename) - local_isrc = self.get_flac_isrc(filepath) - if local_isrc and local_isrc == spotify_isrc: - self.progress.emit(f"Skipped: Track with matching ISRC '{spotify_isrc}' already exists ('{filename}').", 0) - self.progress.emit(f"Skipped: {track.title} - {track.artists}", - int((i + 1) / total_tracks * 100)) - self.skipped_tracks.append(track) - is_already_downloaded = True - break - except FileNotFoundError: - pass - - if is_already_downloaded: - continue - - if (self.is_album or self.is_playlist) and self.use_track_numbers: - new_filename = f"{track.track_number:02d} - {self.get_formatted_filename(track)}" - else: - new_filename = self.get_formatted_filename(track) - - new_filename = re.sub(r'[<>:"/\\|?*]', lambda m: "'" if m.group() == '"' else '_', new_filename) - new_filepath = os.path.join(track_outpath, new_filename) - - if os.path.exists(new_filepath) and os.path.getsize(new_filepath) > 0: - self.progress.emit(f"File already exists by name: {new_filename}. Skipping download.", 0) - self.progress.emit(f"Skipped: {track.title} - {track.artists}", - int((i + 1) / total_tracks * 100)) - self.skipped_tracks.append(track) - continue - - if self.service == "tidal": - if not track.isrc: - self.progress.emit(f"No ISRC found for track: {track.title}. Skipping.", 0) - self.failed_tracks.append((track.title, track.artists, "No ISRC available")) - continue - - self.progress.emit(f"Searching and downloading from Tidal for ISRC: {track.isrc} - {track.title} - {track.artists}", 0) - is_paused_callback = lambda: self.is_paused - is_stopped_callback = lambda: self.is_stopped - - auto_fallback = (self.tidal_api_url == "auto") - - download_result_details = downloader.download( - query=f"{track.title} {track.artists}", - isrc=track.isrc, - output_dir=track_outpath, - quality="LOSSLESS", - is_paused_callback=is_paused_callback, - is_stopped_callback=is_stopped_callback, - auto_fallback=auto_fallback - ) - - if isinstance(download_result_details, str) and os.path.exists(download_result_details): - downloaded_file = download_result_details - elif isinstance(download_result_details, dict) and download_result_details.get("success") == False and download_result_details.get("error") == "Download stopped by user": - self.progress.emit(f"Download stopped by user for: {track.title}",0) - return - elif isinstance(download_result_details, dict) and download_result_details.get("success") == False: - raise Exception(download_result_details.get("error", "Tidal download failed")) - elif isinstance(download_result_details, dict) and (download_result_details.get("status") == "all_skipped" or download_result_details.get("status") == "skipped_exists"): - self.progress.emit(f"File already exists or skipped: {new_filename}",0) - downloaded_file = new_filepath - self.skipped_tracks.append(track) - else: - downloaded_file = None - raise Exception(f"Tidal download failed or returned unexpected result: {download_result_details}") - elif self.service == "deezer": - if not track.isrc: - self.progress.emit(f"No ISRC found for track: {track.title}. Skipping.", 0) - self.failed_tracks.append((track.title, track.artists, "No ISRC available")) - continue - - self.progress.emit(f"Downloading from Deezer with ISRC: {track.isrc}", 0) - - success = asyncio.run(downloader.download_by_isrc(track.isrc, track_outpath)) - - if success: - safe_title = "".join(c for c in track.title if c.isalnum() or c in (' ', '-', '_')).rstrip() - safe_artist = "".join(c for c in track.artists if c.isalnum() or c in (' ', '-', '_')).rstrip() - expected_filename = f"{safe_artist} - {safe_title}.flac" - downloaded_file = os.path.join(track_outpath, expected_filename) - - if not os.path.exists(downloaded_file): - import glob - flac_files = glob.glob(os.path.join(track_outpath, "*.flac")) - if flac_files: - downloaded_file = max(flac_files, key=os.path.getctime) - else: - raise Exception("Downloaded file not found") - else: - raise Exception("Deezer download failed") - else: - track_id = track.id - self.progress.emit(f"Getting track info for ID: {track_id} from {self.service}", 0) - - try: - loop = asyncio.get_event_loop() - if loop.is_closed(): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - metadata = loop.run_until_complete(downloader.get_track_info(track_id, self.service)) - self.progress.emit(f"Track info received, starting download process", 0) - - is_paused_callback = lambda: self.is_paused - is_stopped_callback = lambda: self.is_stopped - - downloaded_file = downloader.download( - metadata, - track_outpath, - is_paused_callback=is_paused_callback, - is_stopped_callback=is_stopped_callback - ) - if self.is_stopped: - return - - if downloaded_file and os.path.exists(downloaded_file): - if downloaded_file == new_filepath: - self.progress.emit(f"File already exists: {new_filename}", 0) - self.progress.emit(f"Skipped: {track.title} - {track.artists}", - int((i + 1) / total_tracks * 100)) - self.skipped_tracks.append(track) - continue - - if downloaded_file != new_filepath: - try: - os.rename(downloaded_file, new_filepath) - self.progress.emit(f"File renamed to: {new_filename}", 0) - except OSError as e: - self.progress.emit(f"Warning: Could not rename file {downloaded_file} to {new_filepath}: {str(e)}", 0) - pass - else: - raise Exception(f"Download failed or file not found: {downloaded_file}") - - self.progress.emit(f"Successfully downloaded: {track.title} - {track.artists}", - int((i + 1) / total_tracks * 100)) - self.successful_tracks.append(track) - except Exception as e: - if self.service == "tidal" and deezer_downloader and track.isrc: - try: - self.progress.emit(f"Tidal failed, trying Deezer fallback for: {track.title}", 0) - - success = asyncio.run(deezer_downloader.download_by_isrc(track.isrc, track_outpath)) - - if success: - safe_title = "".join(c for c in track.title if c.isalnum() or c in (' ', '-', '_')).rstrip() - safe_artist = "".join(c for c in track.artists if c.isalnum() or c in (' ', '-', '_')).rstrip() - expected_filename = f"{safe_artist} - {safe_title}.flac" - downloaded_file = os.path.join(track_outpath, expected_filename) - - if not os.path.exists(downloaded_file): - import glob - flac_files = glob.glob(os.path.join(track_outpath, "*.flac")) - if flac_files: - downloaded_file = max(flac_files, key=os.path.getctime) - else: - raise Exception("Downloaded file not found") - - if downloaded_file != new_filepath: - try: - os.rename(downloaded_file, new_filepath) - self.progress.emit(f"File renamed to: {new_filename}", 0) - except OSError: - pass - - self.progress.emit(f"Successfully downloaded via Deezer fallback: {track.title} - {track.artists}", - int((i + 1) / total_tracks * 100)) - self.successful_tracks.append(track) - continue - else: - raise Exception("Deezer fallback also failed") - except Exception as deezer_error: - self.progress.emit(f"Deezer fallback also failed: {str(deezer_error)}", 0) - self.failed_tracks.append((track.title, track.artists, f"Tidal: {str(e)}, Deezer: {str(deezer_error)}")) - self.progress.emit(f"Failed to download: {track.title} - {track.artists}\nBoth services failed", - int((i + 1) / total_tracks * 100)) - continue - - self.failed_tracks.append((track.title, track.artists, str(e))) - self.progress.emit(f"Failed to download: {track.title} - {track.artists}\nError: {str(e)}", - int((i + 1) / total_tracks * 100)) - continue - - if not self.is_stopped: - success_message = "Download completed!" - if self.failed_tracks: - success_message += f"\n\nFailed downloads: {len(self.failed_tracks)} tracks" - if self.successful_tracks: - success_message += f"\n\nSuccessful downloads: {len(self.successful_tracks)} tracks" - if self.skipped_tracks: - success_message += f"\n\nSkipped (already exists): {len(self.skipped_tracks)} tracks" - self.finished.emit(True, success_message, self.failed_tracks, self.successful_tracks, self.skipped_tracks) - - except Exception as e: - self.finished.emit(False, str(e), self.failed_tracks, self.successful_tracks, self.skipped_tracks) - - def pause(self): - self.is_paused = True - self.progress.emit("Download process paused.", 0) - - def resume(self): - self.is_paused = False - self.progress.emit("Download process resumed.", 0) - - def stop(self): - self.is_stopped = True - self.is_paused = False - -class UpdateDialog(QDialog): - def __init__(self, current_version, new_version, parent=None): - super().__init__(parent) - self.setWindowTitle("Update Now") - self.setFixedWidth(400) - self.setModal(True) - - layout = QVBoxLayout() - - message = QLabel(f"SpotiFLAC v{new_version} Available!") - message.setWordWrap(True) - layout.addWidget(message) - - button_box = QDialogButtonBox() - self.update_button = QPushButton("Check") - self.update_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.cancel_button = QPushButton("Later") - self.cancel_button.setCursor(Qt.CursorShape.PointingHandCursor) - - button_box.addButton(self.update_button, QDialogButtonBox.ButtonRole.AcceptRole) - button_box.addButton(self.cancel_button, QDialogButtonBox.ButtonRole.RejectRole) - - layout.addWidget(button_box) - - self.setLayout(layout) - - self.update_button.clicked.connect(self.accept) - self.cancel_button.clicked.connect(self.reject) - -class ServiceStatusDelegate(QStyledItemDelegate): - def paint(self, painter, option, index): - item_data = index.data(Qt.ItemDataRole.UserRole) - is_online = item_data.get('online', False) if item_data else False - - super().paint(painter, option, index) - - indicator_color = Qt.GlobalColor.green if is_online else Qt.GlobalColor.red - - circle_size = 6 - circle_y = option.rect.center().y() - circle_size // 2 - circle_x = option.rect.right() - circle_size - 5 - - painter.save() - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QBrush(indicator_color)) - painter.drawEllipse(circle_x, circle_y, circle_size, circle_size) - painter.restore() - -class TidalAPIDelegate(QStyledItemDelegate): - def paint(self, painter, option, index): - item_data = index.data(Qt.ItemDataRole.UserRole + 1) - - super().paint(painter, option, index) - - if item_data and isinstance(item_data, dict) and 'status' in item_data: - is_online = item_data.get('status') == 'UP' - indicator_color = Qt.GlobalColor.green if is_online else Qt.GlobalColor.red - - circle_size = 6 - circle_y = option.rect.center().y() - circle_size // 2 - circle_x = option.rect.right() - circle_size - 5 - - painter.save() - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QBrush(indicator_color)) - painter.drawEllipse(circle_x, circle_y, circle_size, circle_size) - painter.restore() - -class TidalStatusChecker(QThread): - status_updated = pyqtSignal(bool) - error = pyqtSignal(str) - - def check_single_api(self, api): - try: - url = api.get('url', '') - test_response = requests.get(f"{url}/track/?id=251380837&quality=LOSSLESS", timeout=5) - return test_response.status_code == 200 - except: - return False - - def run(self): - try: - from concurrent.futures import ThreadPoolExecutor, as_completed - - apis = TidalDownloader.get_available_apis() - - if not apis: - self.status_updated.emit(False) - return - - any_online = False - with ThreadPoolExecutor(max_workers=10) as executor: - futures = {executor.submit(self.check_single_api, api): api for api in apis} - - for future in as_completed(futures): - try: - if future.result(): - any_online = True - for f in futures: - f.cancel() - break - except: - continue - - self.status_updated.emit(any_online) - except Exception as e: - self.error.emit(f"Error checking Tidal status: {str(e)}") - self.status_updated.emit(False) - -class DeezerStatusChecker(QThread): - status_updated = pyqtSignal(bool) - error = pyqtSignal(str) - - def run(self): - try: - response = requests.get("https://deezmate.com/", timeout=5) - is_online = response.status_code == 200 - self.status_updated.emit(is_online) - except Exception as e: - self.error.emit(f"Error checking Deezer status: {str(e)}") - self.status_updated.emit(False) - -class APIStatusChecker(QThread): - status_checked = pyqtSignal(str, str) - all_completed = pyqtSignal() - - def __init__(self, apis): - super().__init__() - self.apis = apis - - def check_single_api(self, api): - url = api.get('url', '') - try: - test_response = requests.get(f"{url}/track/?id=251380837&quality=LOSSLESS", timeout=5) - is_online = test_response.status_code == 200 - status = 'UP' if is_online else 'DOWN' - except: - status = 'DOWN' - return (url, status) - - def run(self): - from concurrent.futures import ThreadPoolExecutor, as_completed - - with ThreadPoolExecutor(max_workers=10) as executor: - futures = {executor.submit(self.check_single_api, api): api for api in self.apis} - - for future in as_completed(futures): - try: - url, status = future.result() - self.status_checked.emit(url, status) - except Exception as e: - print(f"Error checking API: {e}") - - self.all_completed.emit() - -class ServiceComboBox(QComboBox): - def __init__(self, parent=None): - super().__init__(parent) - self.setIconSize(QSize(16, 16)) - self.setItemDelegate(ServiceStatusDelegate()) - self.setup_items() - - QTimer.singleShot(100, self.start_tidal_status_check) - QTimer.singleShot(100, self.start_deezer_status_check) - - self.tidal_status_timer = QTimer(self) - self.tidal_status_timer.timeout.connect(self.refresh_tidal_status) - self.tidal_status_timer.start(60000) - - self.deezer_status_timer = QTimer(self) - self.deezer_status_timer.timeout.connect(self.refresh_deezer_status) - self.deezer_status_timer.start(60000) - - def start_tidal_status_check(self): - self.tidal_status_checker = TidalStatusChecker() - self.tidal_status_checker.status_updated.connect(self.update_tidal_status) - self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}")) - self.tidal_status_checker.start() - - def start_deezer_status_check(self): - self.deezer_status_checker = DeezerStatusChecker() - self.deezer_status_checker.status_updated.connect(self.update_deezer_status) - self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}")) - self.deezer_status_checker.start() - - def setup_items(self): - current_dir = os.path.dirname(os.path.abspath(__file__)) - - self.services = [ - {'id': 'tidal', 'name': 'Tidal', 'icon': 'icons/tidal.png', 'online': False}, - {'id': 'deezer', 'name': 'Deezer', 'icon': 'icons/deezer.png', 'online': False} - ] - - for service in self.services: - icon_path = os.path.join(current_dir, service['icon']) - if not os.path.exists(icon_path): - self.create_placeholder_icon(icon_path) - - icon = QIcon(icon_path) - - self.addItem(icon, service['name']) - item_index = self.count() - 1 - self.setItemData(item_index, service['id'], Qt.ItemDataRole.UserRole + 1) - self.setItemData(item_index, service, Qt.ItemDataRole.UserRole) - def create_placeholder_icon(self, path): - pixmap = QPixmap(16, 16) - pixmap.fill(Qt.GlobalColor.transparent) - pixmap.save(path) - - def update_tidal_status(self, is_online): - for i in range(self.count()): - service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1) - if service_id == 'tidal': - service_data = self.itemData(i, Qt.ItemDataRole.UserRole) - if isinstance(service_data, dict): - service_data['online'] = is_online - self.setItemData(i, service_data, Qt.ItemDataRole.UserRole) - break - self.update() - - def refresh_tidal_status(self): - if hasattr(self, 'tidal_status_checker') and self.tidal_status_checker.isRunning(): - self.tidal_status_checker.quit() - self.tidal_status_checker.wait() - - self.tidal_status_checker = TidalStatusChecker() - self.tidal_status_checker.status_updated.connect(self.update_tidal_status) - self.tidal_status_checker.error.connect(lambda e: print(f"Tidal status check error: {e}")) - self.tidal_status_checker.start() - - def update_deezer_status(self, is_online): - for i in range(self.count()): - service_id = self.itemData(i, Qt.ItemDataRole.UserRole + 1) - if service_id == 'deezer': - service_data = self.itemData(i, Qt.ItemDataRole.UserRole) - if isinstance(service_data, dict): - service_data['online'] = is_online - self.setItemData(i, service_data, Qt.ItemDataRole.UserRole) - break - self.update() - - def refresh_deezer_status(self): - if hasattr(self, 'deezer_status_checker') and self.deezer_status_checker.isRunning(): - self.deezer_status_checker.quit() - self.deezer_status_checker.wait() - - self.deezer_status_checker = DeezerStatusChecker() - self.deezer_status_checker.status_updated.connect(self.update_deezer_status) - self.deezer_status_checker.error.connect(lambda e: print(f"Deezer status check error: {e}")) - self.deezer_status_checker.start() - - def currentData(self, role=Qt.ItemDataRole.UserRole + 1): - return super().currentData(role) - -class SpotiFLACGUI(QWidget): - def __init__(self): - super().__init__() - self.current_version = "5.4" - self.tracks = [] - self.all_tracks = [] - self.successful_downloads = [] - self.reset_state() - - self.settings = QSettings('SpotiFLAC', 'Settings') - self.last_output_path = self.settings.value('output_path', str(Path.home() / "Music")) - self.last_url = self.settings.value('spotify_url', '') - - self.filename_format = self.settings.value('filename_format', 'title_artist') - self.use_track_numbers = self.settings.value('use_track_numbers', False, type=bool) - self.use_artist_subfolders = self.settings.value('use_artist_subfolders', False, type=bool) - self.use_album_subfolders = self.settings.value('use_album_subfolders', False, type=bool) - self.service = self.settings.value('service', 'tidal') - self.tidal_api = self.settings.value('tidal_api', 'auto') - self.check_for_updates = self.settings.value('check_for_updates', True, type=bool) - self.current_theme_color = self.settings.value('theme_color', '#2196F3') - self.track_list_format = self.settings.value('track_list_format', 'track_artist_date_duration') - self.date_format = self.settings.value('date_format', 'dd_mm_yyyy') - - self.elapsed_time = QTime(0, 0, 0) - self.timer = QTimer(self) - self.timer.timeout.connect(self.update_timer) - - self.network_manager = QNetworkAccessManager() - self.network_manager.finished.connect(self.on_cover_loaded) - - self.initUI() - - if self.check_for_updates: - QTimer.singleShot(0, self.check_updates) - - def set_combobox_value(self, combobox, target_value): - for i in range(combobox.count()): - if combobox.itemData(i, Qt.ItemDataRole.UserRole + 1) == target_value: - combobox.setCurrentIndex(i) - return True - if combobox.itemData(i, Qt.ItemDataRole.UserRole) == target_value: - combobox.setCurrentIndex(i) - return True - return False - - def check_updates(self): - try: - response = requests.get("https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/version.json") - if response.status_code == 200: - data = response.json() - new_version = data.get("version") - - if new_version and version.parse(new_version) > version.parse(self.current_version): - dialog = UpdateDialog(self.current_version, new_version, self) - result = dialog.exec() - - if result == QDialog.DialogCode.Accepted: - QDesktopServices.openUrl(QUrl("https://github.com/afkarxyz/SpotiFLAC/releases")) - - except Exception as e: - pass - - @staticmethod - def format_duration(ms): - minutes = ms // 60000 - seconds = (ms % 60000) // 1000 - return f"{minutes}:{seconds:02d}" - - def reset_state(self): - self.tracks.clear() - self.all_tracks.clear() - self.is_album = False - self.is_playlist = False - self.is_single_track = False - self.album_or_playlist_name = '' - - def reset_ui(self): - self.track_list.clear() - self.track_list.show() - self.log_output.clear() - self.progress_bar.setValue(0) - self.progress_bar.hide() - self.stop_btn.hide() - self.pause_resume_btn.hide() - self.pause_resume_btn.setText('Pause') - self.reset_info_widget() - self.hide_track_buttons() - if hasattr(self, 'search_input'): - self.search_input.clear() - if hasattr(self, 'search_widget'): - self.search_widget.hide() - - def get_themed_icon(self, icon_name): - icon_path = os.path.join(os.path.dirname(__file__), "icons", icon_name) - if not os.path.exists(icon_path): - return QIcon() - - with open(icon_path, 'r') as f: - svg_content = f.read() - - svg_content = svg_content.replace('currentColor', self.current_theme_color) - - renderer = QSvgRenderer(svg_content.encode()) - pixmap = QPixmap(16, 16) - pixmap.fill(QColor(0, 0, 0, 0)) - - painter = QPainter(pixmap) - renderer.render(painter) - painter.end() - - return QIcon(pixmap) - - def initUI(self): - self.setWindowTitle('SpotiFLAC') - self.setFixedWidth(650) - self.setMinimumHeight(350) - - icon_path = os.path.join(os.path.dirname(__file__), "icons", "icon.svg") - if os.path.exists(icon_path): - self.setWindowIcon(QIcon(icon_path)) - - self.main_layout = QVBoxLayout() - - self.setup_spotify_section() - self.setup_tabs() - - self.setLayout(self.main_layout) - - def setup_spotify_section(self): - spotify_layout = QHBoxLayout() - spotify_label = QLabel('Spotify URL:') - spotify_label.setFixedWidth(100) - - self.spotify_url = QLineEdit() - self.spotify_url.setPlaceholderText("Enter Spotify URL") - self.spotify_url.setClearButtonEnabled(True) - self.spotify_url.setText(self.last_url) - self.spotify_url.textChanged.connect(self.save_url) - - self.fetch_btn = QPushButton('Fetch') - self.fetch_btn.setFixedWidth(80) - self.fetch_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.fetch_btn.clicked.connect(self.fetch_tracks) - - spotify_layout.addWidget(spotify_label) - spotify_layout.addWidget(self.spotify_url) - spotify_layout.addWidget(self.fetch_btn) - self.main_layout.addLayout(spotify_layout) - - def filter_tracks(self): - search_text = self.search_input.text().lower().strip() - - if not search_text: - self.tracks = self.all_tracks.copy() - else: - self.tracks = [ - track for track in self.all_tracks - if (search_text in track.title.lower() or - search_text in track.artists.lower() or - search_text in track.album.lower()) - ] - - self.update_track_list_display() - - def format_track_date(self, release_date): - if not release_date: - return "" - - try: - if len(release_date) == 4: - date_obj = datetime.strptime(release_date, "%Y") - if self.date_format == "yyyy": - return date_obj.strftime('%Y') - else: - return date_obj.strftime('%Y') - elif len(release_date) == 7: - date_obj = datetime.strptime(release_date, "%Y-%m") - if self.date_format == "dd_mm_yyyy": - return date_obj.strftime('%m-%Y') - elif self.date_format == "yyyy_mm_dd": - return date_obj.strftime('%Y-%m') - else: - return date_obj.strftime('%Y') - else: - date_obj = datetime.strptime(release_date, "%Y-%m-%d") - if self.date_format == "dd_mm_yyyy": - return date_obj.strftime('%d-%m-%Y') - elif self.date_format == "yyyy_mm_dd": - return date_obj.strftime('%Y-%m-%d') - else: - return date_obj.strftime('%Y') - except ValueError: - return release_date - - def update_track_list_display(self): - self.track_list.clear() - for i, track in enumerate(self.tracks, 1): - duration = self.format_duration(track.duration_ms) - formatted_date = self.format_track_date(track.release_date) - - if self.track_list_format == "artist_track_date_duration": - display_parts = [f"{i}. {track.artists} - {track.title}"] - if formatted_date: - display_parts.append(formatted_date) - display_parts.append(duration) - display_text = " • ".join(display_parts) - elif self.track_list_format == "track_artist_date": - display_parts = [f"{i}. {track.title} - {track.artists}"] - if formatted_date: - display_parts.append(formatted_date) - display_text = " • ".join(display_parts) - elif self.track_list_format == "artist_track_date": - display_parts = [f"{i}. {track.artists} - {track.title}"] - if formatted_date: - display_parts.append(formatted_date) - display_text = " • ".join(display_parts) - elif self.track_list_format == "track_artist_duration": - display_text = f"{i}. {track.title} - {track.artists} • {duration}" - elif self.track_list_format == "artist_track_duration": - display_text = f"{i}. {track.artists} - {track.title} • {duration}" - elif self.track_list_format == "track_artist": - display_text = f"{i}. {track.title} - {track.artists}" - elif self.track_list_format == "artist_track": - display_text = f"{i}. {track.artists} - {track.title}" - else: - display_parts = [f"{i}. {track.title} - {track.artists}"] - if formatted_date: - display_parts.append(formatted_date) - display_parts.append(duration) - display_text = " • ".join(display_parts) - - self.track_list.addItem(display_text) - - def browse_output(self): - directory = QFileDialog.getExistingDirectory(self, "Select Output Directory") - if directory: - self.output_dir.setText(directory) - self.save_settings() - - def setup_tabs(self): - self.tab_widget = QTabWidget() - self.main_layout.addWidget(self.tab_widget) - - self.setup_dashboard_tab() - self.setup_process_tab() - self.setup_settings_tab() - self.setup_theme_tab() - self.setup_about_tab() - - def setup_dashboard_tab(self): - dashboard_tab = QWidget() - dashboard_layout = QVBoxLayout() - - self.setup_info_widget() - dashboard_layout.addWidget(self.info_widget) - - self.track_list = QListWidget() - self.track_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) - dashboard_layout.addWidget(self.track_list) - - self.setup_track_buttons() - dashboard_layout.addLayout(self.btn_layout) - dashboard_layout.addWidget(self.single_track_container) - - dashboard_tab.setLayout(dashboard_layout) - self.tab_widget.addTab(dashboard_tab, "Dashboard") - - self.hide_track_buttons() - - def setup_info_widget(self): - self.info_widget = QWidget() - info_layout = QHBoxLayout() - self.cover_label = QLabel() - self.cover_label.setFixedSize(80, 80) - self.cover_label.setScaledContents(True) - info_layout.addWidget(self.cover_label) - - text_info_layout = QVBoxLayout() - - self.title_label = QLabel() - self.title_label.setStyleSheet("font-size: 14px; font-weight: bold;") - self.title_label.setWordWrap(True) - - self.artists_label = QLabel() - self.artists_label.setWordWrap(True) - - self.followers_label = QLabel() - self.followers_label.setWordWrap(True) - - self.release_date_label = QLabel() - self.release_date_label.setWordWrap(True) - - self.type_label = QLabel() - self.type_label.setStyleSheet("font-size: 12px;") - - text_info_layout.addWidget(self.title_label) - text_info_layout.addWidget(self.artists_label) - text_info_layout.addWidget(self.followers_label) - text_info_layout.addWidget(self.release_date_label) - text_info_layout.addWidget(self.type_label) - text_info_layout.addStretch() - - info_layout.addLayout(text_info_layout, 1) - - self.setup_search_widget() - info_layout.addWidget(self.search_widget) - - self.info_widget.setLayout(info_layout) - self.info_widget.setFixedHeight(100) - self.info_widget.hide() - - def setup_search_widget(self): - self.search_widget = QWidget() - search_layout = QVBoxLayout() - search_layout.setContentsMargins(10, 0, 0, 0) - - search_layout.addStretch() - - search_input_layout = QHBoxLayout() - search_input_layout.addStretch() - - self.search_input = QLineEdit() - self.search_input.setPlaceholderText("Search...") - self.search_input.setClearButtonEnabled(True) - self.search_input.textChanged.connect(self.filter_tracks) - self.search_input.setFixedWidth(250) - - search_input_layout.addWidget(self.search_input) - search_layout.addLayout(search_input_layout) - - self.search_widget.setLayout(search_layout) - self.search_widget.hide() - - def setup_track_buttons(self): - self.btn_layout = QHBoxLayout() - self.download_btn = QPushButton(' Download') - self.download_btn.setIcon(self.get_themed_icon('download.svg')) - self.delete_btn = QPushButton(' Delete') - self.delete_btn.setIcon(self.get_themed_icon('trash.svg')) - - for btn in [self.download_btn, self.delete_btn]: - btn.setFixedWidth(120) - btn.setCursor(Qt.CursorShape.PointingHandCursor) - - self.download_btn.clicked.connect(self.download_tracks_action) - self.delete_btn.clicked.connect(self.delete_tracks) - - self.btn_layout.addStretch() - self.btn_layout.addWidget(self.download_btn) - self.btn_layout.addWidget(self.delete_btn) - self.btn_layout.addStretch() - - self.single_track_container = QWidget() - single_track_layout = QHBoxLayout(self.single_track_container) - single_track_layout.setContentsMargins(0, 0, 0, 0) - - self.single_download_btn = QPushButton(' Download') - self.single_download_btn.setIcon(self.get_themed_icon('download.svg')) - self.single_delete_btn = QPushButton(' Delete') - self.single_delete_btn.setIcon(self.get_themed_icon('trash.svg')) - - for btn in [self.single_download_btn, self.single_delete_btn]: - btn.setFixedWidth(120) - btn.setCursor(Qt.CursorShape.PointingHandCursor) - - self.single_download_btn.clicked.connect(self.download_tracks_action) - self.single_delete_btn.clicked.connect(self.delete_tracks) - - single_track_layout.addStretch() - single_track_layout.addWidget(self.single_download_btn) - single_track_layout.addWidget(self.single_delete_btn) - single_track_layout.addStretch() - - self.single_track_container.hide() - - def setup_process_tab(self): - self.process_tab = QWidget() - process_layout = QVBoxLayout() - process_layout.setSpacing(5) - - self.log_output = QTextEdit() - self.log_output.setReadOnly(True) - process_layout.addWidget(self.log_output) - - fix_error_layout = QHBoxLayout() - fix_error_layout.addStretch() - self.fix_error_btn = QPushButton(' Fix Error') - self.fix_error_btn.setIcon(self.get_themed_icon('tool.svg')) - self.fix_error_btn.setFixedWidth(120) - self.fix_error_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.fix_error_btn.clicked.connect(self.fix_error_action) - self.fix_error_btn.hide() - fix_error_layout.addWidget(self.fix_error_btn) - fix_error_layout.addStretch() - process_layout.addLayout(fix_error_layout) - - progress_time_layout = QVBoxLayout() - progress_time_layout.setSpacing(2) - - self.progress_bar = QProgressBar() - progress_time_layout.addWidget(self.progress_bar) - - self.time_label = QLabel("00:00:00") - self.time_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - progress_time_layout.addWidget(self.time_label) - - process_layout.addLayout(progress_time_layout) - - control_layout = QHBoxLayout() - self.stop_btn = QPushButton('Stop') - self.pause_resume_btn = QPushButton('Pause') - - self.stop_btn.setFixedWidth(120) - self.pause_resume_btn.setFixedWidth(120) - - self.stop_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.pause_resume_btn.setCursor(Qt.CursorShape.PointingHandCursor) - - self.stop_btn.clicked.connect(self.stop_download) - self.pause_resume_btn.clicked.connect(self.toggle_pause_resume) - - self.remove_successful_btn = QPushButton(' Remove Finished Tracks') - self.remove_successful_btn.setIcon(self.get_themed_icon('circle-x.svg')) - self.remove_successful_btn.setFixedWidth(200) - self.remove_successful_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.remove_successful_btn.clicked.connect(self.remove_successful_downloads) - - control_layout.addStretch() - control_layout.addWidget(self.stop_btn) - control_layout.addWidget(self.pause_resume_btn) - control_layout.addWidget(self.remove_successful_btn) - control_layout.addStretch() - - process_layout.addLayout(control_layout) - - self.process_tab.setLayout(process_layout) - - self.tab_widget.addTab(self.process_tab, "Process") - - self.progress_bar.hide() - self.time_label.hide() - self.stop_btn.hide() - self.pause_resume_btn.hide() - self.remove_successful_btn.hide() - - def setup_settings_tab(self): - settings_tab = QWidget() - settings_layout = QVBoxLayout() - settings_layout.setSpacing(4) - settings_layout.setContentsMargins(10, 10, 10, 10) - - output_group = QWidget() - output_layout = QVBoxLayout(output_group) - output_layout.setSpacing(2) - output_layout.setContentsMargins(0, 0, 0, 0) - - output_label = QLabel('Output Directory') - output_label.setStyleSheet("font-weight: bold; margin-top: 0px; margin-bottom: 5px;") - output_layout.addWidget(output_label) - - output_dir_layout = QHBoxLayout() - self.output_dir = QLineEdit() - self.output_dir.setText(self.last_output_path) - self.output_dir.textChanged.connect(self.save_settings) - - self.output_browse = QPushButton('Browse') - self.output_browse.setFixedWidth(80) - self.output_browse.setCursor(Qt.CursorShape.PointingHandCursor) - self.output_browse.clicked.connect(self.browse_output) - - output_dir_layout.addWidget(self.output_dir) - output_dir_layout.addSpacing(5) - output_dir_layout.addWidget(self.output_browse) - - output_layout.addLayout(output_dir_layout) - - settings_layout.addWidget(output_group) - - dashboard_group = QWidget() - dashboard_layout = QVBoxLayout(dashboard_group) - dashboard_layout.setSpacing(3) - dashboard_layout.setContentsMargins(0, 0, 0, 0) - - dashboard_label = QLabel('Dashboard Settings') - dashboard_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;") - dashboard_layout.addWidget(dashboard_label) - - dashboard_controls_layout = QHBoxLayout() - - list_format_label = QLabel('Track List View:') - list_format_label.setFixedWidth(90) - - self.track_list_format_dropdown = QComboBox() - self.track_list_format_dropdown.addItem("Track - Artist - Date - Duration", "track_artist_date_duration") - self.track_list_format_dropdown.addItem("Artist - Track - Date - Duration", "artist_track_date_duration") - self.track_list_format_dropdown.addItem("Track - Artist - Date", "track_artist_date") - self.track_list_format_dropdown.addItem("Artist - Track - Date", "artist_track_date") - self.track_list_format_dropdown.addItem("Track - Artist - Duration", "track_artist_duration") - self.track_list_format_dropdown.addItem("Artist - Track - Duration", "artist_track_duration") - self.track_list_format_dropdown.addItem("Track - Artist", "track_artist") - self.track_list_format_dropdown.addItem("Artist - Track", "artist_track") - self.track_list_format_dropdown.currentIndexChanged.connect(self.save_track_list_format) - - dashboard_controls_layout.addWidget(list_format_label) - dashboard_controls_layout.addWidget(self.track_list_format_dropdown) - - dashboard_controls_layout.addSpacing(15) - - date_format_label = QLabel('Date Format:') - date_format_label.setFixedWidth(80) - - self.date_format_dropdown = QComboBox() - self.date_format_dropdown.addItem("DD-MM-YYYY", "dd_mm_yyyy") - self.date_format_dropdown.addItem("YYYY-MM-DD", "yyyy_mm_dd") - self.date_format_dropdown.addItem("YYYY", "yyyy") - self.date_format_dropdown.currentIndexChanged.connect(self.save_date_format) - - dashboard_controls_layout.addWidget(date_format_label) - dashboard_controls_layout.addWidget(self.date_format_dropdown) - dashboard_controls_layout.addStretch() - - dashboard_layout.addLayout(dashboard_controls_layout) - - settings_layout.addWidget(dashboard_group) - - file_group = QWidget() - file_layout = QVBoxLayout(file_group) - file_layout.setSpacing(2) - file_layout.setContentsMargins(0, 0, 0, 0) - - file_label = QLabel('File Settings') - file_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;") - file_layout.addWidget(file_label) - - format_layout = QHBoxLayout() - format_label = QLabel('Filename Format:') - self.format_group = QButtonGroup(self) - self.title_artist_radio = QRadioButton('Title - Artist') - self.title_artist_radio.setCursor(Qt.CursorShape.PointingHandCursor) - self.title_artist_radio.toggled.connect(self.save_filename_format) - - self.artist_title_radio = QRadioButton('Artist - Title') - self.artist_title_radio.setCursor(Qt.CursorShape.PointingHandCursor) - self.artist_title_radio.toggled.connect(self.save_filename_format) - - self.title_only_radio = QRadioButton('Title') - self.title_only_radio.setCursor(Qt.CursorShape.PointingHandCursor) - self.title_only_radio.toggled.connect(self.save_filename_format) - - if hasattr(self, 'filename_format') and self.filename_format == "artist_title": - self.artist_title_radio.setChecked(True) - elif hasattr(self, 'filename_format') and self.filename_format == "title_only": - self.title_only_radio.setChecked(True) - else: - self.title_artist_radio.setChecked(True) - - self.format_group.addButton(self.title_artist_radio) - self.format_group.addButton(self.artist_title_radio) - self.format_group.addButton(self.title_only_radio) - - format_layout.addWidget(format_label) - format_layout.addWidget(self.title_artist_radio) - format_layout.addSpacing(10) - format_layout.addWidget(self.artist_title_radio) - format_layout.addSpacing(10) - format_layout.addWidget(self.title_only_radio) - format_layout.addStretch() - file_layout.addLayout(format_layout) - - checkbox_layout = QHBoxLayout() - - self.artist_subfolder_checkbox = QCheckBox('Artist Subfolder (Playlist)') - self.artist_subfolder_checkbox.setCursor(Qt.CursorShape.PointingHandCursor) - self.artist_subfolder_checkbox.setChecked(self.use_artist_subfolders) - self.artist_subfolder_checkbox.toggled.connect(self.save_artist_subfolder_setting) - checkbox_layout.addWidget(self.artist_subfolder_checkbox) - checkbox_layout.addSpacing(10) - - self.album_subfolder_checkbox = QCheckBox('Album Subfolder (Playlist)') - self.album_subfolder_checkbox.setCursor(Qt.CursorShape.PointingHandCursor) - self.album_subfolder_checkbox.setChecked(self.use_album_subfolders) - self.album_subfolder_checkbox.toggled.connect(self.save_album_subfolder_setting) - checkbox_layout.addWidget(self.album_subfolder_checkbox) - checkbox_layout.addSpacing(10) - - self.track_number_checkbox = QCheckBox('Track Number') - self.track_number_checkbox.setCursor(Qt.CursorShape.PointingHandCursor) - self.track_number_checkbox.setChecked(self.use_track_numbers) - self.track_number_checkbox.toggled.connect(self.save_track_numbering) - checkbox_layout.addWidget(self.track_number_checkbox) - - checkbox_layout.addStretch() - file_layout.addLayout(checkbox_layout) - - settings_layout.addWidget(file_group) - - auth_group = QWidget() - auth_layout = QVBoxLayout(auth_group) - auth_layout.setSpacing(2) - auth_layout.setContentsMargins(0, 0, 0, 0) - - auth_label = QLabel('Service Settings') - auth_label.setStyleSheet("font-weight: bold; margin-top: 8px; margin-bottom: 5px;") - auth_layout.addWidget(auth_label) - - service_api_layout = QHBoxLayout() - - service_label = QLabel('Service:') - service_label.setFixedWidth(53) - - self.service_dropdown = ServiceComboBox() - self.service_dropdown.setFixedWidth(100) - self.service_dropdown.currentIndexChanged.connect(self.on_service_changed) - - service_api_layout.addWidget(service_label) - service_api_layout.addWidget(self.service_dropdown) - - self.api_spacer = QWidget() - self.api_spacer.setFixedWidth(15) - service_api_layout.addWidget(self.api_spacer) - - self.tidal_api_label = QLabel('API Instances:') - self.tidal_api_label.setFixedWidth(85) - - self.tidal_api_dropdown = QComboBox() - self.tidal_api_dropdown.setItemDelegate(TidalAPIDelegate()) - self.tidal_api_dropdown.addItem("Auto Fallback", "auto") - self.tidal_api_dropdown.currentIndexChanged.connect(self.on_tidal_api_changed) - - self.refresh_api_btn = QPushButton('Refresh') - self.refresh_api_btn.setFixedWidth(80) - self.refresh_api_btn.setCursor(Qt.CursorShape.PointingHandCursor) - self.refresh_api_btn.clicked.connect(self.refresh_tidal_apis) - - service_api_layout.addWidget(self.tidal_api_label) - service_api_layout.addWidget(self.tidal_api_dropdown, 2) - service_api_layout.addSpacing(5) - service_api_layout.addWidget(self.refresh_api_btn) - service_api_layout.addStretch(1) - - auth_layout.addLayout(service_api_layout) - - self.refresh_tidal_apis() - - self.update_tidal_api_visibility() - - settings_layout.addWidget(auth_group) - settings_layout.addStretch() - settings_tab.setLayout(settings_layout) - self.tab_widget.addTab(settings_tab, "Settings") - self.set_combobox_value(self.service_dropdown, self.service) - self.set_combobox_value(self.track_list_format_dropdown, self.track_list_format) - self.set_combobox_value(self.date_format_dropdown, self.date_format) - - self.set_combobox_value(self.tidal_api_dropdown, self.tidal_api) - - def setup_theme_tab(self): - theme_tab = QWidget() - theme_layout = QVBoxLayout() - theme_layout.setSpacing(8) - theme_layout.setContentsMargins(8, 15, 15, 15) - - grid_layout = QVBoxLayout() - - self.color_buttons = {} - - first_row_palettes = [ - ("Red", [ - ("#FFCDD2", "100"), ("#EF9A9A", "200"), ("#E57373", "300"), ("#EF5350", "400"), ("#F44336", "500"), ("#E53935", "600"), ("#D32F2F", "700"), ("#C62828", "800"), ("#B71C1C", "900"), ("#FF8A80", "A100"), ("#FF5252", "A200"), ("#FF1744", "A400"), ("#D50000", "A700") - ]), - ("Pink", [ - ("#F8BBD0", "100"), ("#F48FB1", "200"), ("#F06292", "300"), ("#EC407A", "400"), ("#E91E63", "500"), ("#D81B60", "600"), ("#C2185B", "700"), ("#AD1457", "800"), ("#880E4F", "900"), ("#FF80AB", "A100"), ("#FF4081", "A200"), ("#F50057", "A400"), ("#C51162", "A700") - ]), - ("Purple", [ - ("#E1BEE7", "100"), ("#CE93D8", "200"), ("#BA68C8", "300"), ("#AB47BC", "400"), ("#9C27B0", "500"), ("#8E24AA", "600"), ("#7B1FA2", "700"), ("#6A1B9A", "800"), ("#4A148C", "900"), ("#EA80FC", "A100"), ("#E040FB", "A200"), ("#D500F9", "A400"), ("#AA00FF", "A700") - ]) - ] - - second_row_palettes = [ - ("Deep Purple", [ - ("#D1C4E9", "100"), ("#B39DDB", "200"), ("#9575CD", "300"), ("#7E57C2", "400"), ("#673AB7", "500"), ("#5E35B1", "600"), ("#512DA8", "700"), ("#4527A0", "800"), ("#311B92", "900"), ("#B388FF", "A100"), ("#7C4DFF", "A200"), ("#651FFF", "A400"), ("#6200EA", "A700") - ]), - ("Indigo", [ - ("#C5CAE9", "100"), ("#9FA8DA", "200"), ("#7986CB", "300"), ("#5C6BC0", "400"), ("#3F51B5", "500"), ("#3949AB", "600"), ("#303F9F", "700"), ("#283593", "800"), ("#1A237E", "900"), ("#8C9EFF", "A100"), ("#536DFE", "A200"), ("#3D5AFE", "A400"), ("#304FFE", "A700") - ]), - ("Blue", [ - ("#BBDEFB", "100"), ("#90CAF9", "200"), ("#64B5F6", "300"), ("#42A5F5", "400"), ("#2196F3", "500"), ("#1E88E5", "600"), ("#1976D2", "700"), ("#1565C0", "800"), ("#0D47A1", "900"), ("#82B1FF", "A100"), ("#448AFF", "A200"), ("#2979FF", "A400"), ("#2962FF", "A700") - ]) - ] - - third_row_palettes = [ - ("Light Blue", [ - ("#B3E5FC", "100"), ("#81D4FA", "200"), ("#4FC3F7", "300"), ("#29B6F6", "400"), ("#03A9F4", "500"), ("#039BE5", "600"), ("#0288D1", "700"), ("#0277BD", "800"), ("#01579B", "900"), ("#80D8FF", "A100"), ("#40C4FF", "A200"), ("#00B0FF", "A400"), ("#0091EA", "A700") - ]), - ("Cyan", [ - ("#B2EBF2", "100"), ("#80DEEA", "200"), ("#4DD0E1", "300"), ("#26C6DA", "400"), ("#00BCD4", "500"), ("#00ACC1", "600"), ("#0097A7", "700"), ("#00838F", "800"), ("#006064", "900"), ("#84FFFF", "A100"), ("#18FFFF", "A200"), ("#00E5FF", "A400"), ("#00B8D4", "A700") - ]), - ("Teal", [ - ("#B2DFDB", "100"), ("#80CBC4", "200"), ("#4DB6AC", "300"), ("#26A69A", "400"), ("#009688", "500"), ("#00897B", "600"), ("#00796B", "700"), ("#00695C", "800"), ("#004D40", "900"), ("#A7FFEB", "A100"), ("#64FFDA", "A200"), ("#1DE9B6", "A400"), ("#00BFA5", "A700") - ]) - ] - - fourth_row_palettes = [ - ("Green", [ - ("#C8E6C9", "100"), ("#A5D6A7", "200"), ("#81C784", "300"), ("#66BB6A", "400"), ("#4CAF50", "500"), ("#43A047", "600"), ("#388E3C", "700"), ("#2E7D32", "800"), ("#1B5E20", "900"), ("#B9F6CA", "A100"), ("#69F0AE", "A200"), ("#00E676", "A400"), ("#00C853", "A700") - ]), - ("Light Green", [ - ("#DCEDC8", "100"), ("#C5E1A5", "200"), ("#AED581", "300"), ("#9CCC65", "400"), ("#8BC34A", "500"), ("#7CB342", "600"), ("#689F38", "700"), ("#558B2F", "800"), ("#33691E", "900"), ("#CCFF90", "A100"), ("#B2FF59", "A200"), ("#76FF03", "A400"), ("#64DD17", "A700") - ]), - ("Lime", [ - ("#F0F4C3", "100"), ("#E6EE9C", "200"), ("#DCE775", "300"), ("#D4E157", "400"), ("#CDDC39", "500"), ("#C0CA33", "600"), ("#AFB42B", "700"), ("#9E9D24", "800"), ("#827717", "900"), ("#F4FF81", "A100"), ("#EEFF41", "A200"), ("#C6FF00", "A400"), ("#AEEA00", "A700") - ]) - ] - - fifth_row_palettes = [ - ("Yellow", [ - ("#FFF9C4", "100"), ("#FFF59D", "200"), ("#FFF176", "300"), ("#FFEE58", "400"), ("#FFEB3B", "500"), ("#FDD835", "600"), ("#FBC02D", "700"), ("#F9A825", "800"), ("#F57F17", "900"), ("#FFFF8D", "A100"), ("#FFFF00", "A200"), ("#FFEA00", "A400"), ("#FFD600", "A700") - ]), - ("Amber", [ - ("#FFECB3", "100"), ("#FFE082", "200"), ("#FFD54F", "300"), ("#FFCA28", "400"), ("#FFC107", "500"), ("#FFB300", "600"), ("#FFA000", "700"), ("#FF8F00", "800"), ("#FF6F00", "900"), ("#FFE57F", "A100"), ("#FFD740", "A200"), ("#FFC400", "A400"), ("#FFAB00", "A700") - ]), - ("Orange", [ - ("#FFE0B2", "100"), ("#FFCC80", "200"), ("#FFB74D", "300"), ("#FFA726", "400"), ("#FF9800", "500"), ("#FB8C00", "600"), ("#F57C00", "700"), ("#EF6C00", "800"), ("#E65100", "900"), ("#FFD180", "A100"), ("#FFAB40", "A200"), ("#FF9100", "A400"), ("#FF6D00", "A700") - ]) - ] - - for row_palettes in [first_row_palettes, second_row_palettes, third_row_palettes, fourth_row_palettes, fifth_row_palettes]: - row_layout = QHBoxLayout() - row_layout.setSpacing(15) - - for palette_name, colors in row_palettes: - column_layout = QVBoxLayout() - column_layout.setSpacing(3) - - palette_label = QLabel(palette_name) - palette_label.setStyleSheet("margin-bottom: 2px;") - palette_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - column_layout.addWidget(palette_label) - - color_buttons_layout = QHBoxLayout() - color_buttons_layout.setSpacing(3) - - for color_hex, color_name in colors: - color_btn = QPushButton() - color_btn.setFixedSize(18, 18) - - is_current = color_hex == self.current_theme_color - border_style = "2px solid #fff" if is_current else "none" - - color_btn.setStyleSheet(f""" - QPushButton {{ - background-color: {color_hex}; - border: {border_style}; - border-radius: 9px; - }} - QPushButton:hover {{ - border: 2px solid #fff; - }} - QPushButton:pressed {{ - border: 2px solid #fff; - }} - """) - color_btn.setCursor(Qt.CursorShape.PointingHandCursor) - color_btn.setToolTip(f"{palette_name} {color_name}\n{color_hex}") - color_btn.clicked.connect(lambda checked, color=color_hex, btn=color_btn: self.change_theme_color(color, btn)) - - self.color_buttons[color_hex] = color_btn - - color_buttons_layout.addWidget(color_btn) - - column_layout.addLayout(color_buttons_layout) - row_layout.addLayout(column_layout) - - grid_layout.addLayout(row_layout) - - theme_layout.addLayout(grid_layout) - theme_layout.addStretch() - - theme_tab.setLayout(theme_layout) - self.tab_widget.addTab(theme_tab, "Theme") - - def change_theme_color(self, color, clicked_btn=None): - if hasattr(self, 'color_buttons'): - for color_hex, btn in self.color_buttons.items(): - if color_hex == self.current_theme_color: - btn.setStyleSheet(f""" - QPushButton {{ - background-color: {color_hex}; - border: none; - border-radius: 9px; - }} - QPushButton:hover {{ - border: 2px solid #fff; - }} - QPushButton:pressed {{ - border: 2px solid #fff; - }} - """) - break - - self.current_theme_color = color - self.settings.setValue('theme_color', color) - self.settings.sync() - - if clicked_btn: - clicked_btn.setStyleSheet(f""" - QPushButton {{ - background-color: {color}; - border: 2px solid #fff; - border-radius: 9px; - }} - QPushButton:hover {{ - border: 2px solid #fff; - }} - QPushButton:pressed {{ - border: 2px solid #fff; - }} - """) - - qdarktheme.setup_theme( - custom_colors={ - "[dark]": { - "primary": color, - } - } - ) - - self.refresh_button_icons() - - def refresh_button_icons(self): - if hasattr(self, 'download_btn'): - self.download_btn.setIcon(self.get_themed_icon('download.svg')) - if hasattr(self, 'delete_btn'): - self.delete_btn.setIcon(self.get_themed_icon('trash.svg')) - - if hasattr(self, 'single_download_btn'): - self.single_download_btn.setIcon(self.get_themed_icon('download.svg')) - if hasattr(self, 'single_delete_btn'): - self.single_delete_btn.setIcon(self.get_themed_icon('trash.svg')) - - if hasattr(self, 'fix_error_btn'): - self.fix_error_btn.setIcon(self.get_themed_icon('tool.svg')) - - if hasattr(self, 'remove_successful_btn'): - self.remove_successful_btn.setIcon(self.get_themed_icon('circle-x.svg')) - - def setup_about_tab(self): - about_tab = QWidget() - about_layout = QVBoxLayout() - about_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - about_layout.setSpacing(15) - - sections = [ - ("Check for Updates", "Check", "https://github.com/afkarxyz/SpotiFLAC/releases"), - ("Report an Issue", "Report", "https://github.com/afkarxyz/SpotiFLAC/issues") - ] - - for title, button_text, url in sections: - section_widget = QWidget() - section_layout = QVBoxLayout(section_widget) - section_layout.setSpacing(10) - section_layout.setContentsMargins(0, 0, 0, 0) - - label = QLabel(title) - label.setStyleSheet("color: palette(text); font-weight: bold;") - label.setAlignment(Qt.AlignmentFlag.AlignCenter) - section_layout.addWidget(label) - - button = QPushButton(button_text) - button.setFixedSize(120, 25) - button.setCursor(Qt.CursorShape.PointingHandCursor) - button.clicked.connect(lambda _, url=url: QDesktopServices.openUrl(QUrl(url if url.startswith(('http://', 'https://')) else f'https://{url}'))) - section_layout.addWidget(button, alignment=Qt.AlignmentFlag.AlignCenter) - - about_layout.addWidget(section_widget) - - footer_label = QLabel(f"v{self.current_version} | November 2025") - about_layout.addWidget(footer_label, alignment=Qt.AlignmentFlag.AlignCenter) - - about_tab.setLayout(about_layout) - self.tab_widget.addTab(about_tab, "About") - - def on_service_changed(self, index): - service = self.service_dropdown.currentData() - self.service = service - self.settings.setValue('service', service) - self.settings.sync() - self.log_output.append(f"Service changed to: {self.service_dropdown.currentText()}") - self.update_tidal_api_visibility() - - def update_tidal_api_visibility(self): - is_tidal = self.service_dropdown.currentData() == 'tidal' - self.api_spacer.setVisible(is_tidal) - self.tidal_api_label.setVisible(is_tidal) - self.tidal_api_dropdown.setVisible(is_tidal) - self.refresh_api_btn.setVisible(is_tidal) - - def on_tidal_api_changed(self, index): - selected_api = self.tidal_api_dropdown.currentData() - if selected_api: - self.tidal_api = selected_api - self.settings.setValue('tidal_api', selected_api) - self.settings.sync() - self.log_output.append(f"API Instance changed to: {self.tidal_api_dropdown.currentText()}") - - def refresh_tidal_apis(self): - try: - self.log_output.append("Fetching available API instances...") - apis = TidalDownloader.get_available_apis() - - while self.tidal_api_dropdown.count() > 1: - self.tidal_api_dropdown.removeItem(1) - - if apis: - self.log_output.append(f"Found {len(apis)} API instances, loading...") - - for api in apis: - url = api.get('url', '') - domain = url.replace('https://', '').replace('http://', '') - label = domain - - status_data = {'status': 'CHECKING'} - - self.tidal_api_dropdown.addItem(label, url) - item_index = self.tidal_api_dropdown.count() - 1 - self.tidal_api_dropdown.setItemData(item_index, status_data, Qt.ItemDataRole.UserRole + 1) - - self.log_output.append(f"Loaded {len(apis)} API instances, checking status in background...") - - self.api_status_checker = APIStatusChecker(apis) - self.api_status_checker.status_checked.connect(self.on_api_status_checked) - self.api_status_checker.all_completed.connect(self.on_all_api_status_completed) - self.api_status_checker.start() - else: - self.log_output.append("No APIs found") - except Exception as e: - self.log_output.append(f"Error fetching APIs: {str(e)}") - - def on_api_status_checked(self, url, status): - for i in range(self.tidal_api_dropdown.count()): - if self.tidal_api_dropdown.itemData(i) == url: - status_data = {'status': status} - self.tidal_api_dropdown.setItemData(i, status_data, Qt.ItemDataRole.UserRole + 1) - break - self.tidal_api_dropdown.update() - - def on_all_api_status_completed(self): - self.log_output.append("API status check completed") - - def save_url(self): - self.settings.setValue('spotify_url', self.spotify_url.text().strip()) - self.settings.sync() - - def save_filename_format(self): - if self.artist_title_radio.isChecked(): - self.filename_format = "artist_title" - elif self.title_only_radio.isChecked(): - self.filename_format = "title_only" - else: - self.filename_format = "title_artist" - self.settings.setValue('filename_format', self.filename_format) - self.settings.sync() - - def save_track_numbering(self): - self.use_track_numbers = self.track_number_checkbox.isChecked() - self.settings.setValue('use_track_numbers', self.use_track_numbers) - self.settings.sync() - - def save_artist_subfolder_setting(self): - self.use_artist_subfolders = self.artist_subfolder_checkbox.isChecked() - self.settings.setValue('use_artist_subfolders', self.use_artist_subfolders) - self.settings.sync() - - def save_album_subfolder_setting(self): - self.use_album_subfolders = self.album_subfolder_checkbox.isChecked() - self.settings.setValue('use_album_subfolders', self.use_album_subfolders) - self.settings.sync() - - def save_track_list_format(self): - format_value = self.track_list_format_dropdown.currentData() - self.track_list_format = format_value - self.settings.setValue('track_list_format', format_value) - self.settings.sync() - if self.tracks: - self.update_track_list_display() - - def save_date_format(self): - format_value = self.date_format_dropdown.currentData() - self.date_format = format_value - self.settings.setValue('date_format', format_value) - self.settings.sync() - if self.tracks: - self.update_track_list_display() - - def save_settings(self): - self.settings.setValue('output_path', self.output_dir.text().strip()) - self.settings.sync() - self.log_output.append("Settings saved successfully!") - - def update_timer(self): - self.elapsed_time = self.elapsed_time.addSecs(1) - self.time_label.setText(self.elapsed_time.toString("hh:mm:ss")) - - def fetch_tracks(self): - url = self.spotify_url.text().strip() - - if not url: - self.log_output.append('Warning: Please enter a Spotify URL.') - return - - try: - if hasattr(self, 'fix_error_btn') and self.fix_error_btn.isVisible(): - self.fix_error_btn.hide() - - self.reset_state() - self.reset_ui() - - self.log_output.append('Just a moment. Fetching metadata...') - self.tab_widget.setCurrentWidget(self.process_tab) - - self.metadata_worker = MetadataFetchWorker(url) - self.metadata_worker.finished.connect(self.on_metadata_fetched) - self.metadata_worker.error.connect(self.on_metadata_error) - self.metadata_worker.start() - - except Exception as e: - self.log_output.append(f'Error: Failed to start metadata fetch: {str(e)}') - - def on_metadata_fetched(self, metadata): - try: - url_info = parse_uri(self.spotify_url.text().strip()) - - if url_info["type"] == "track": - self.handle_track_metadata(metadata["track"]) - elif url_info["type"] == "album": - self.handle_album_metadata(metadata) - elif url_info["type"] == "playlist": - self.handle_playlist_metadata(metadata) - elif url_info["type"] == "artist_discography": - self.handle_discography_metadata(metadata) - elif url_info["type"] == "artist": - self.handle_artist_metadata(metadata) - - self.update_button_states() - self.tab_widget.setCurrentIndex(0) - except Exception as e: - self.log_output.append(f'Error: {str(e)}') - - def on_metadata_error(self, error_message): - self.log_output.append(f'Error: {error_message}') - - if "Failed to get raw data" in error_message or "Failed to fetch secrets" in error_message or "Failed to get access token" in error_message: - if not hasattr(self, 'fix_error_btn') or not self.fix_error_btn.isVisible(): - self.show_fix_error_button() - - def show_fix_error_button(self): - if hasattr(self, 'fix_error_btn'): - self.fix_error_btn.show() - - def fix_error_action(self): - self.fix_error_btn.setEnabled(False) - self.fix_error_btn.setText("Fixing...") - - self.scrape_worker = SecretScrapeWorker() - self.scrape_worker.progress.connect(lambda msg: self.log_output.append(msg)) - self.scrape_worker.finished.connect(self.on_scrape_finished) - self.scrape_worker.start() - - def on_scrape_finished(self, success, message): - self.log_output.append(message) - - if hasattr(self, 'fix_error_btn'): - self.fix_error_btn.setEnabled(True) - self.fix_error_btn.setText("Fix Error") - - if success: - self.fix_error_btn.hide() - - if success: - url = self.spotify_url.text().strip() - if url: - self.log_output.append("Retrying fetch...") - QTimer.singleShot(1000, self.fetch_tracks) - - def handle_track_metadata(self, track_data): - track_id = track_data["external_urls"].split("/")[-1] - - track = Track( - external_urls=track_data["external_urls"], - title=track_data["name"], - artists=track_data["artists"], - album=track_data["album_name"], - track_number=1, - duration_ms=track_data.get("duration_ms", 0), - id=track_id, - isrc=track_data.get("isrc", ""), - release_date=track_data.get("release_date", "") - ) - - self.tracks = [track] - self.all_tracks = [track] - self.is_single_track = True - self.is_album = self.is_playlist = False - self.album_or_playlist_name = f"{self.tracks[0].title} - {self.tracks[0].artists}" - - metadata = { - 'title': track_data["name"], - 'artists': track_data["artists"], - 'releaseDate': track_data["release_date"], - 'cover': track_data["images"], - 'duration_ms': track_data.get("duration_ms", 0) - } - self.update_display_after_fetch(metadata) - - def handle_album_metadata(self, album_data): - self.album_or_playlist_name = album_data["album_info"]["name"] - self.tracks = [] - - for track in album_data["track_list"]: - track_id = track["external_urls"].split("/")[-1] - - self.tracks.append(Track( - external_urls=track["external_urls"], - title=track["name"], - artists=track["artists"], - album=self.album_or_playlist_name, - track_number=track["track_number"], - duration_ms=track.get("duration_ms", 0), - id=track_id, - isrc=track.get("isrc", ""), - release_date=track.get("release_date", "") - )) - - self.all_tracks = self.tracks.copy() - self.is_album = True - self.is_playlist = self.is_single_track = False - - metadata = { - 'title': album_data["album_info"]["name"], - 'artists': album_data["album_info"]["artists"], - 'releaseDate': album_data["album_info"]["release_date"], - 'cover': album_data["album_info"]["images"], - 'total_tracks': album_data["album_info"]["total_tracks"] - } - self.update_display_after_fetch(metadata) - - def handle_playlist_metadata(self, playlist_data): - self.album_or_playlist_name = playlist_data["playlist_info"]["owner"]["name"] - self.tracks = [] - - for track in playlist_data["track_list"]: - track_id = track["external_urls"].split("/")[-1] - - self.tracks.append(Track( - external_urls=track["external_urls"], - title=track["name"], - artists=track["artists"], - album=track["album_name"], - track_number=track.get("track_number", len(self.tracks) + 1), - duration_ms=track.get("duration_ms", 0), - id=track_id, - isrc=track.get("isrc", ""), - release_date=track.get("release_date", "") - )) - - self.all_tracks = self.tracks.copy() - self.is_playlist = True - self.is_album = self.is_single_track = False - - metadata = { - 'title': playlist_data["playlist_info"]["owner"]["name"], - 'artists': playlist_data["playlist_info"]["owner"]["display_name"], - 'cover': playlist_data["playlist_info"]["owner"]["images"], - 'followers': playlist_data["playlist_info"]["followers"]["total"], - 'total_tracks': playlist_data["playlist_info"]["tracks"]["total"] - } - self.update_display_after_fetch(metadata) - - def handle_discography_metadata(self, discography_data): - artist_info = discography_data["artist_info"] - self.album_or_playlist_name = f"{artist_info['name']} - Discography ({artist_info['discography_type'].title()})" - self.tracks = [] - - for track in discography_data["track_list"]: - track_id = track["external_urls"].split("/")[-1] if track.get("external_urls") else "" - - self.tracks.append(Track( - external_urls=track.get("external_urls", ""), - title=track["name"], - artists=track["artists"], - album=track["album_name"], - track_number=track.get("track_number", len(self.tracks) + 1), - duration_ms=track.get("duration_ms", 0), - id=track_id, - isrc=track.get("isrc", ""), - release_date=track.get("release_date", "") - )) - - self.all_tracks = self.tracks.copy() - self.is_playlist = True - self.is_album = self.is_single_track = False - - metadata = { - 'title': f"{artist_info['name']} - Discography", - 'artists': f"{artist_info['discography_type'].title()} • {artist_info['total_albums']} albums", - 'cover': artist_info["images"], - 'followers': artist_info.get("followers", 0), - 'total_tracks': len(self.tracks), - 'discography_type': artist_info['discography_type'] - } - self.update_display_after_fetch(metadata) - - def handle_artist_metadata(self, artist_data): - self.reset_state() - - metadata = { - 'title': artist_data["artist"]["name"], - 'artists': f"Followers: {artist_data['artist']['followers']:,}", - 'cover': artist_data["artist"]["images"], - 'followers': artist_data["artist"]["followers"], - 'genres': artist_data["artist"].get("genres", []) - } - - self.update_info_widget_artist_only(metadata) - - def update_display_after_fetch(self, metadata): - self.track_list.setVisible(not self.is_single_track) - - if not self.is_single_track: - self.search_widget.show() - self.update_track_list_display() - else: - self.search_widget.hide() - - self.update_info_widget(metadata) - - def update_info_widget(self, metadata): - self.title_label.setText(metadata['title']) - - if self.is_single_track or self.is_album: - artists = metadata['artists'] if isinstance(metadata['artists'], list) else metadata['artists'].split(", ") - label_text = "Artists" if len(artists) > 1 else "Artist" - artists_text = ", ".join(artists) - self.artists_label.setText(f"{label_text} {artists_text}") - else: - self.artists_label.setText(f"Owner {metadata['artists']}") - - if self.is_playlist and 'followers' in metadata: - self.followers_label.setText(f"Followers {metadata['followers']:,}") - self.followers_label.show() - else: - self.followers_label.hide() - - if metadata.get('releaseDate'): - try: - release_date = metadata['releaseDate'] - if len(release_date) == 4: - date_obj = datetime.strptime(release_date, "%Y") - elif len(release_date) == 7: - date_obj = datetime.strptime(release_date, "%Y-%m") - else: - date_obj = datetime.strptime(release_date, "%Y-%m-%d") - - formatted_date = date_obj.strftime("%d-%m-%Y") - self.release_date_label.setText(f"Released {formatted_date}") - self.release_date_label.show() - except ValueError: - self.release_date_label.setText(f"Released {metadata['releaseDate']}") - self.release_date_label.show() - else: - self.release_date_label.hide() - - if self.is_single_track: - duration = self.format_duration(metadata.get('duration_ms', 0)) - self.type_label.setText(f"Duration {duration}") - elif self.is_album: - total_tracks = metadata.get('total_tracks', 0) - self.type_label.setText(f"Album • {total_tracks} tracks") - elif self.is_playlist: - total_tracks = metadata.get('total_tracks', 0) - if metadata.get('discography_type'): - discography_type = metadata['discography_type'].title() - self.type_label.setText(f"Discography ({discography_type}) • {total_tracks} tracks") - else: - self.type_label.setText(f"Playlist • {total_tracks} tracks") - - self.network_manager.get(QNetworkRequest(QUrl(metadata['cover']))) - - self.info_widget.show() - - def update_info_widget_artist_only(self, metadata): - self.title_label.setText(metadata['title']) - self.artists_label.setText(f"Followers {metadata['followers']:,}") - - if metadata.get('genres'): - genres_text = ", ".join(metadata['genres'][:3]) - if len(metadata['genres']) > 3: - genres_text += f" (+{len(metadata['genres']) - 3} more)" - self.followers_label.setText(f"Genres {genres_text}") - self.followers_label.show() - else: - self.followers_label.hide() - - self.release_date_label.hide() - self.type_label.setText("Artist Profile • No tracks available for download") - - self.network_manager.get(QNetworkRequest(QUrl(metadata['cover']))) - - self.track_list.hide() - self.search_widget.hide() - self.hide_track_buttons() - - self.info_widget.show() - - def reset_info_widget(self): - self.title_label.clear() - self.artists_label.clear() - self.followers_label.clear() - self.release_date_label.clear() - self.type_label.clear() - self.cover_label.clear() - self.info_widget.hide() - - def on_cover_loaded(self, reply): - if reply.error() == QNetworkReply.NetworkError.NoError: - data = reply.readAll() - pixmap = QPixmap() - pixmap.loadFromData(data) - self.cover_label.setPixmap(pixmap) - - def update_button_states(self): - if self.is_single_track: - for btn in [self.download_btn, self.delete_btn]: - btn.hide() - - self.single_track_container.show() - - self.single_download_btn.setEnabled(True) - self.single_delete_btn.setEnabled(True) - - else: - self.single_track_container.hide() - - self.download_btn.show() - self.delete_btn.show() - - self.download_btn.setEnabled(True) - self.delete_btn.setEnabled(True) - - def hide_track_buttons(self): - buttons = [ - self.download_btn, - self.delete_btn - ] - for btn in buttons: - btn.hide() - - if hasattr(self, 'single_track_container'): - self.single_track_container.hide() - - def download_tracks_action(self): - if self.is_single_track: - self.start_download([0]) - else: - selected_items = self.track_list.selectedItems() - - if not selected_items: - reply = QMessageBox.question( - self, - 'Confirm Download All', - f'No tracks selected. Download all {len(self.tracks)} tracks?', - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No - ) - - if reply == QMessageBox.StandardButton.Yes: - self.start_download(range(len(self.tracks))) - else: - selected_indices = [self.track_list.row(item) for item in selected_items] - self.start_download(selected_indices) - - def start_download(self, indices): - self.log_output.clear() - raw_outpath = self.output_dir.text().strip() - outpath = os.path.normpath(raw_outpath) - if not os.path.exists(outpath): - self.log_output.append('Warning: Invalid output directory.') - return - - tracks_to_download = self.tracks if self.is_single_track else [self.tracks[i] for i in indices] - - if self.is_album or self.is_playlist: - name = self.album_or_playlist_name.strip() - folder_name = re.sub(r'[<>:"/\\|?*]', '_', name) - folder_name = folder_name.rstrip('. ') - outpath = os.path.join(outpath, folder_name) - os.makedirs(outpath, exist_ok=True) - - try: - self.start_download_worker(tracks_to_download, outpath) - except Exception as e: - self.log_output.append(f"Error: An error occurred while starting the download: {str(e)}") - - def start_download_worker(self, tracks_to_download, outpath): - service = self.service_dropdown.currentData() - - tidal_api_url = None - if service == "tidal": - selected_api = self.tidal_api_dropdown.currentData() - if selected_api == "auto": - tidal_api_url = "auto" - self.log_output.append("Using auto fallback mode (will try multiple APIs)") - else: - tidal_api_url = selected_api - self.log_output.append(f"Using API: {selected_api}") - - self.worker = DownloadWorker( - tracks_to_download, - outpath, - self.is_single_track, - self.is_album, - self.is_playlist, - self.album_or_playlist_name, - self.filename_format, - self.use_track_numbers, - self.use_artist_subfolders, - self.use_album_subfolders, - service, - tidal_api_url - ) - self.worker.finished.connect(lambda success, message, failed_tracks, successful_tracks, skipped_tracks: self.on_download_finished(success, message, failed_tracks, successful_tracks, skipped_tracks)) - self.worker.progress.connect(self.update_progress) - self.worker.start() - self.start_timer() - self.update_ui_for_download_start() - - def update_ui_for_download_start(self): - self.download_btn.setEnabled(False) - - if hasattr(self, 'single_download_btn'): - self.single_download_btn.setEnabled(False) - if hasattr(self, 'single_delete_btn'): - self.single_delete_btn.setEnabled(False) - - self.stop_btn.show() - self.pause_resume_btn.show() - self.remove_successful_btn.hide() - self.progress_bar.show() - self.progress_bar.setValue(0) - - self.tab_widget.setCurrentWidget(self.process_tab) - - def update_progress(self, message, percentage): - self.log_output.append(message) - self.log_output.moveCursor(QTextCursor.MoveOperation.End) - if percentage > 0: - self.progress_bar.setValue(percentage) - - def stop_download(self): - if hasattr(self, 'worker'): - self.worker.stop() - self.stop_timer() - self.on_download_finished(True, "Download stopped by user.", [], [], []) - - def on_download_finished(self, success, message, failed_tracks, successful_tracks=None, skipped_tracks=None): - self.progress_bar.hide() - self.stop_btn.hide() - self.pause_resume_btn.hide() - self.pause_resume_btn.setText('Pause') - self.stop_timer() - - if successful_tracks is not None: - self.successful_downloads = successful_tracks - if skipped_tracks is not None: - self.skipped_downloads = skipped_tracks - - if (hasattr(self, 'successful_downloads') and self.successful_downloads) or (hasattr(self, 'skipped_downloads') and self.skipped_downloads): - self.remove_successful_btn.show() - else: - self.remove_successful_btn.hide() - - self.download_btn.setEnabled(True) - - if hasattr(self, 'single_download_btn'): - self.single_download_btn.setEnabled(True) - if hasattr(self, 'single_delete_btn'): - self.single_delete_btn.setEnabled(True) - - if success: - self.log_output.append(f"\nStatus: {message}") - if failed_tracks: - self.log_output.append("\nFailed downloads:") - for title, artists, error in failed_tracks: - self.log_output.append(f"• {title} - {artists}") - self.log_output.append(f" Error: {error}\n") - else: - self.log_output.append(f"Error: {message}") - - self.tab_widget.setCurrentWidget(self.process_tab) - - def toggle_pause_resume(self): - if hasattr(self, 'worker'): - if self.worker.is_paused: - self.worker.resume() - self.pause_resume_btn.setText('Pause') - self.timer.start(1000) - else: - self.worker.pause() - self.pause_resume_btn.setText('Resume') - - def remove_successful_downloads(self): - successful_tracks = getattr(self, 'successful_downloads', []) - skipped_tracks = getattr(self, 'skipped_downloads', []) - - if not successful_tracks and not skipped_tracks: - self.log_output.append("No downloaded or skipped tracks to remove.") - return - - tracks_to_remove = [] - - for track in self.tracks: - for successful_track in successful_tracks: - if (track.title == successful_track.title and - track.artists == successful_track.artists and - track.album == successful_track.album): - tracks_to_remove.append(track) - break - - for track in self.tracks: - for skipped_track in skipped_tracks: - if (track.title == skipped_track.title and - track.artists == skipped_track.artists and - track.album == skipped_track.album): - if track not in tracks_to_remove: - tracks_to_remove.append(track) - break - - if tracks_to_remove: - for track in tracks_to_remove: - if track in self.tracks: - self.tracks.remove(track) - if track in self.all_tracks: - self.all_tracks.remove(track) - - self.update_track_list_display() - successful_count = len([t for t in tracks_to_remove if t in successful_tracks]) - skipped_count = len([t for t in tracks_to_remove if t in skipped_tracks]) - - message = f"Removed {len(tracks_to_remove)} tracks from the list" - if successful_count > 0: - message += f" ({successful_count} downloaded" - if skipped_count > 0: - message += f", {skipped_count} already existed" if successful_count > 0 else f" ({skipped_count} already existed" - if successful_count > 0 or skipped_count > 0: - message += ")" - - self.log_output.append(message + ".") - self.tab_widget.setCurrentIndex(0) - else: - self.log_output.append("No matching tracks found in the current list.") - - self.remove_successful_btn.hide() - - def delete_tracks(self): - if self.is_single_track: - self.reset_state() - self.reset_ui() - else: - selected_items = self.track_list.selectedItems() - - if not selected_items: - reply = QMessageBox.question( - self, - 'Confirm Delete All', - f'No tracks selected. Delete all {len(self.tracks)} tracks?', - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No - ) - - if reply == QMessageBox.StandardButton.Yes: - self.reset_state() - self.reset_ui() - else: - selected_indices = [self.track_list.row(item) for item in selected_items] - tracks_to_remove = [self.tracks[i] for i in selected_indices] - - for track in tracks_to_remove: - if track in self.tracks: - self.tracks.remove(track) - if track in self.all_tracks: - self.all_tracks.remove(track) - - self.update_track_list_display() - self.tab_widget.setCurrentIndex(0) - - def start_timer(self): - self.elapsed_time = QTime(0, 0, 0) - self.time_label.setText("00:00:00") - self.time_label.show() - self.timer.start(1000) - - def stop_timer(self): - self.timer.stop() - self.time_label.hide() - - def closeEvent(self, event): - if hasattr(self, 'timer'): - self.timer.stop() - - if hasattr(self, 'service_dropdown'): - for attr_name in ['tidal_status_checker', 'deezer_status_checker']: - if hasattr(self.service_dropdown, attr_name): - checker = getattr(self.service_dropdown, attr_name) - if checker.isRunning(): - checker.quit() - checker.wait() - - if hasattr(self, 'worker') and self.worker and self.worker.isRunning(): - self.worker.stop() - self.worker.quit() - self.worker.wait() - - event.accept() - -if __name__ == '__main__': - try: - if sys.platform == "win32": - import io - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') - sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') - except Exception as e: - pass - - app = QApplication(sys.argv) - - settings = QSettings('SpotiFLAC', 'Settings') - theme_color = settings.value('theme_color', '#2196F3') - - qdarktheme.setup_theme( - custom_colors={ - "[dark]": { - "primary": theme_color, - } - } - ) - ex = SpotiFLACGUI() - ex.show() - sys.exit(app.exec()) \ No newline at end of file diff --git a/app.go b/app.go new file mode 100644 index 0000000..d19c7ad --- /dev/null +++ b/app.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "spotiflac/backend" + "time" +) + +// App struct +type App struct { + ctx context.Context +} + +// NewApp creates a new App application struct +func NewApp() *App { + return &App{} +} + +// startup is called when the app starts. The context is saved +// so we can call the runtime methods +func (a *App) startup(ctx context.Context) { + a.ctx = ctx +} + +// SpotifyMetadataRequest represents the request structure for fetching Spotify metadata +type SpotifyMetadataRequest struct { + URL string `json:"url"` + Batch bool `json:"batch"` + Delay float64 `json:"delay"` + Timeout float64 `json:"timeout"` +} + +// DownloadRequest represents the request structure for downloading tracks +type DownloadRequest struct { + ISRC string `json:"isrc"` + Service string `json:"service"` + Query string `json:"query,omitempty"` + ApiURL string `json:"api_url,omitempty"` + OutputDir string `json:"output_dir,omitempty"` + AudioFormat string `json:"audio_format,omitempty"` +} + +// DownloadResponse represents the response structure for download operations +type DownloadResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + File string `json:"file,omitempty"` + Error string `json:"error,omitempty"` +} + +// GetSpotifyMetadata fetches metadata from Spotify +func (a *App) GetSpotifyMetadata(req SpotifyMetadataRequest) (string, error) { + if req.URL == "" { + return "", fmt.Errorf("URL parameter is required") + } + + if req.Delay == 0 { + req.Delay = 1.0 + } + if req.Timeout == 0 { + req.Timeout = 300.0 + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.Timeout*float64(time.Second))) + defer cancel() + + data, err := backend.GetFilteredSpotifyData(ctx, req.URL, req.Batch, time.Duration(req.Delay*float64(time.Second))) + if err != nil { + return "", fmt.Errorf("failed to fetch metadata: %v", err) + } + + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "", fmt.Errorf("failed to encode response: %v", err) + } + + return string(jsonData), nil +} + +// DownloadTrack downloads a track by ISRC +func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) { + if req.ISRC == "" { + return DownloadResponse{ + Success: false, + Error: "ISRC is required", + }, fmt.Errorf("ISRC is required") + } + + if req.Service == "" { + req.Service = "deezer" + } + + if req.OutputDir == "" { + req.OutputDir = "." + } + + if req.AudioFormat == "" { + req.AudioFormat = "LOSSLESS" + } + + var err error + var filename string + + if req.Service == "tidal" { + searchQuery := req.Query + if searchQuery == "" { + searchQuery = req.ISRC + } + + if req.ApiURL == "" || req.ApiURL == "auto" { + downloader := backend.NewTidalDownloader("") + filename, err = downloader.DownloadWithFallback(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat) + } else { + downloader := backend.NewTidalDownloader(req.ApiURL) + filename, err = downloader.Download(searchQuery, req.ISRC, req.OutputDir, req.AudioFormat) + } + } else { + downloader := backend.NewDeezerDownloader() + err = downloader.DownloadByISRC(req.ISRC, req.OutputDir) + if err == nil { + filename = "Downloaded via Deezer" + } + } + + if err != nil { + return DownloadResponse{ + Success: false, + Error: fmt.Sprintf("Download failed: %v", err), + }, err + } + + return DownloadResponse{ + Success: true, + Message: "Download completed successfully", + File: filename, + }, nil +} + +// OpenFolder opens a folder in the file explorer +func (a *App) OpenFolder(path string) error { + if path == "" { + return fmt.Errorf("path is required") + } + + err := backend.OpenFolderInExplorer(path) + if err != nil { + return fmt.Errorf("failed to open folder: %v", err) + } + + return nil +} + +// GetDefaults returns the default configuration +func (a *App) GetDefaults() map[string]string { + return map[string]string{ + "downloadPath": backend.GetDefaultMusicPath(), + } +} diff --git a/backend/config.go b/backend/config.go new file mode 100644 index 0000000..5e2f793 --- /dev/null +++ b/backend/config.go @@ -0,0 +1,18 @@ +package backend + +import ( + "os" + "path/filepath" +) + +func GetDefaultMusicPath() string { + // Get user's home directory + homeDir, err := os.UserHomeDir() + if err != nil { + // Fallback to Public Music if can't get home dir + return "C:\\Users\\Public\\Music" + } + + // Return path to user's Music folder + return filepath.Join(homeDir, "Music") +} diff --git a/backend/deezer.go b/backend/deezer.go new file mode 100644 index 0000000..ceab07a --- /dev/null +++ b/backend/deezer.go @@ -0,0 +1,234 @@ +package backend + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +type DeezerDownloader struct { + client *http.Client +} + +type DeezerTrack struct { + ID int64 `json:"id"` + Title string `json:"title"` + TitleShort string `json:"title_short"` + Duration int `json:"duration"` + TrackPos int `json:"track_position"` + DiskNumber int `json:"disk_number"` + ISRC string `json:"isrc"` + ReleaseDate string `json:"release_date"` + Artist struct { + Name string `json:"name"` + ID int64 `json:"id"` + } `json:"artist"` + Album struct { + Title string `json:"title"` + ID int64 `json:"id"` + CoverXL string `json:"cover_xl"` + CoverBig string `json:"cover_big"` + } `json:"album"` + Contributors []struct { + Name string `json:"name"` + Role string `json:"role"` + } `json:"contributors"` +} + +type DeezMateResponse struct { + Success bool `json:"success"` + Links struct { + FLAC string `json:"flac"` + } `json:"links"` +} + +func NewDeezerDownloader() *DeezerDownloader { + return &DeezerDownloader{ + client: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) { + url := fmt.Sprintf("https://api.deezer.com/2.0/track/isrc:%s", isrc) + + resp, err := d.client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch track: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + var track DeezerTrack + if err := json.NewDecoder(resp.Body).Decode(&track); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if track.ID == 0 { + return nil, fmt.Errorf("track not found for ISRC: %s", isrc) + } + + return &track, nil +} + +func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) { + url := fmt.Sprintf("https://api.deezmate.com/dl/%d", trackID) + + resp, err := d.client.Get(url) + if err != nil { + return "", fmt.Errorf("failed to get download URL: %w", err) + } + defer resp.Body.Close() + + var apiResp DeezMateResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return "", fmt.Errorf("failed to decode API response: %w", err) + } + + if !apiResp.Success || apiResp.Links.FLAC == "" { + return "", fmt.Errorf("no FLAC download link available") + } + + return apiResp.Links.FLAC, nil +} + +func (d *DeezerDownloader) DownloadFile(url, filepath string) error { + resp, err := d.client.Get(url) + if err != nil { + return fmt.Errorf("failed to download file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + out, err := os.Create(filepath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +func (d *DeezerDownloader) DownloadCoverArt(coverURL, filepath string) error { + if coverURL == "" { + return fmt.Errorf("no cover URL provided") + } + + resp, err := d.client.Get(coverURL) + if err != nil { + return fmt.Errorf("failed to download cover: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("cover download failed with status %d", resp.StatusCode) + } + + out, err := os.Create(filepath) + if err != nil { + return fmt.Errorf("failed to create cover file: %w", err) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + return err +} + +func sanitizeFilename(name string) string { + re := regexp.MustCompile(`[<>:"/\\|?*]`) + sanitized := re.ReplaceAllString(name, "_") + sanitized = strings.TrimSpace(sanitized) + if sanitized == "" { + return "Unknown" + } + return sanitized +} + +func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir string) error { + fmt.Printf("Fetching track info for ISRC: %s\n", isrc) + + track, err := d.GetTrackByISRC(isrc) + if err != nil { + return err + } + + artists := track.Artist.Name + if len(track.Contributors) > 0 { + var mainArtists []string + for _, contrib := range track.Contributors { + if contrib.Role == "Main" { + mainArtists = append(mainArtists, contrib.Name) + } + } + if len(mainArtists) > 0 { + artists = strings.Join(mainArtists, ", ") + } + } + + fmt.Printf("Found track: %s - %s\n", artists, track.Title) + fmt.Printf("Album: %s\n", track.Album.Title) + + downloadURL, err := d.GetDownloadURL(track.ID) + if err != nil { + return err + } + + safeArtist := sanitizeFilename(artists) + safeTitle := sanitizeFilename(track.Title) + filename := fmt.Sprintf("%s - %s.flac", safeArtist, safeTitle) + filepath := filepath.Join(outputDir, filename) + + fmt.Println("Downloading FLAC file...") + if err := d.DownloadFile(downloadURL, filepath); err != nil { + return err + } + + fmt.Printf("Downloaded: %s\n", filepath) + + coverPath := "" + if track.Album.CoverXL != "" { + coverPath = filepath + ".cover.jpg" + fmt.Println("Downloading cover art...") + if err := d.DownloadCoverArt(track.Album.CoverXL, coverPath); err != nil { + fmt.Printf("Warning: Failed to download cover art: %v\n", err) + } else { + defer os.Remove(coverPath) + } + } + + fmt.Println("Embedding metadata and cover art...") + metadata := Metadata{ + Title: track.Title, + Artist: artists, + Album: track.Album.Title, + Date: track.ReleaseDate, + TrackNumber: track.TrackPos, + DiscNumber: track.DiskNumber, + ISRC: track.ISRC, + } + + if err := EmbedMetadata(filepath, metadata, coverPath); err != nil { + return fmt.Errorf("failed to embed metadata: %w", err) + } + + fmt.Println("Metadata embedded successfully!") + return nil +} diff --git a/backend/folder.go b/backend/folder.go new file mode 100644 index 0000000..17fffe4 --- /dev/null +++ b/backend/folder.go @@ -0,0 +1,23 @@ +package backend + +import ( + "os/exec" + "runtime" +) + +func OpenFolderInExplorer(path string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "windows": + cmd = exec.Command("explorer", path) + case "darwin": // macOS + cmd = exec.Command("open", path) + case "linux": + cmd = exec.Command("xdg-open", path) + default: + cmd = exec.Command("xdg-open", path) + } + + return cmd.Start() +} diff --git a/backend/metadata.go b/backend/metadata.go new file mode 100644 index 0000000..7bb8bed --- /dev/null +++ b/backend/metadata.go @@ -0,0 +1,113 @@ +package backend + +import ( + "fmt" + "os" + "strconv" + + "github.com/go-flac/flacpicture" + "github.com/go-flac/flacvorbis" + "github.com/go-flac/go-flac" +) + +type Metadata struct { + Title string + Artist string + Album string + Date string + TrackNumber int + DiscNumber int + ISRC string +} + +func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error { + f, err := flac.ParseFile(filepath) + if err != nil { + return fmt.Errorf("failed to parse FLAC file: %w", err) + } + + var cmtIdx = -1 + for idx, block := range f.Meta { + if block.Type == flac.VorbisComment { + cmtIdx = idx + break + } + } + + cmt := flacvorbis.New() + + if metadata.Title != "" { + _ = cmt.Add(flacvorbis.FIELD_TITLE, metadata.Title) + } + if metadata.Artist != "" { + _ = cmt.Add(flacvorbis.FIELD_ARTIST, metadata.Artist) + } + if metadata.Album != "" { + _ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album) + } + if metadata.Date != "" { + _ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date) + } + if metadata.TrackNumber > 0 { + _ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber)) + } + if metadata.DiscNumber > 0 { + _ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber)) + } + if metadata.ISRC != "" { + _ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC) + } + + cmtBlock := cmt.Marshal() + if cmtIdx < 0 { + f.Meta = append(f.Meta, &cmtBlock) + } else { + f.Meta[cmtIdx] = &cmtBlock + } + + if coverPath != "" && fileExists(coverPath) { + if err := embedCoverArt(f, coverPath); err != nil { + fmt.Printf("Warning: Failed to embed cover art: %v\n", err) + } + } + + if err := f.Save(filepath); err != nil { + return fmt.Errorf("failed to save FLAC file: %w", err) + } + + return nil +} + +func embedCoverArt(f *flac.File, coverPath string) error { + imgData, err := os.ReadFile(coverPath) + if err != nil { + return fmt.Errorf("failed to read cover image: %w", err) + } + + picture, err := flacpicture.NewFromImageData( + flacpicture.PictureTypeFrontCover, + "Cover", + imgData, + "image/jpeg", + ) + if err != nil { + return fmt.Errorf("failed to create picture block: %w", err) + } + + pictureBlock := picture.Marshal() + + for i := len(f.Meta) - 1; i >= 0; i-- { + if f.Meta[i].Type == flac.Picture { + f.Meta = append(f.Meta[:i], f.Meta[i+1:]...) + } + } + + f.Meta = append(f.Meta, &pictureBlock) + + return nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/backend/spotify_metadata.go b/backend/spotify_metadata.go new file mode 100644 index 0000000..3c11ada --- /dev/null +++ b/backend/spotify_metadata.go @@ -0,0 +1,1155 @@ +package backend + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +const ( + spotifyTokenURL = "https://open.spotify.com/api/token" + playlistBaseURL = "https://api.spotify.com/v1/playlists/%s" + albumBaseURL = "https://api.spotify.com/v1/albums/%s" + trackBaseURL = "https://api.spotify.com/v1/tracks/%s" + artistBaseURL = "https://api.spotify.com/v1/artists/%s" + artistAlbumsBaseURL = "https://api.spotify.com/v1/artists/%s/albums" + secretBytesRemotePath = "https://raw.githubusercontent.com/afkarxyz/secretBytes/refs/heads/main/secrets/secretBytes.json" +) + +var ( + errInvalidSpotifyURL = errors.New("invalid or unsupported Spotify URL") +) + +// SpotifyMetadataClient mirrors the behaviour of Doc/getMetadata.py and interacts with Spotify's web API. +type SpotifyMetadataClient struct { + httpClient *http.Client + rng *rand.Rand + rngMu sync.Mutex + userAgent string +} + +// NewSpotifyMetadataClient creates a ready-to-use client with sane defaults. +func NewSpotifyMetadataClient() *SpotifyMetadataClient { + src := rand.NewSource(time.Now().UnixNano()) + c := &SpotifyMetadataClient{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + rng: rand.New(src), + } + c.userAgent = c.randomUserAgent() + return c +} + +// TrackMetadata mirrors the filtered track payload returned by the Python script. +type TrackMetadata struct { + Artists string `json:"artists"` + Name string `json:"name"` + AlbumName string `json:"album_name"` + DurationMS int `json:"duration_ms"` + Images string `json:"images"` + ReleaseDate string `json:"release_date"` + TrackNumber int `json:"track_number"` + ExternalURL string `json:"external_urls"` + ISRC string `json:"isrc"` +} + +// AlbumTrackMetadata holds per-track info for album / playlist formatting. +type AlbumTrackMetadata struct { + Artists string `json:"artists"` + Name string `json:"name"` + AlbumName string `json:"album_name"` + DurationMS int `json:"duration_ms"` + Images string `json:"images"` + ReleaseDate string `json:"release_date"` + TrackNumber int `json:"track_number"` + ExternalURL string `json:"external_urls"` + ISRC string `json:"isrc"` + AlbumType string `json:"album_type,omitempty"` +} + +type TrackResponse struct { + Track TrackMetadata `json:"track"` +} + +type AlbumInfoMetadata struct { + TotalTracks int `json:"total_tracks"` + Name string `json:"name"` + ReleaseDate string `json:"release_date"` + Artists string `json:"artists"` + Images string `json:"images"` + Batch string `json:"batch,omitempty"` +} + +type AlbumResponsePayload struct { + AlbumInfo AlbumInfoMetadata `json:"album_info"` + TrackList []AlbumTrackMetadata `json:"track_list"` +} + +type PlaylistInfoMetadata struct { + Tracks struct { + Total int `json:"total"` + } `json:"tracks"` + Followers struct { + Total int `json:"total"` + } `json:"followers"` + Owner struct { + DisplayName string `json:"display_name"` + Name string `json:"name"` + Images string `json:"images"` + } `json:"owner"` + Batch string `json:"batch,omitempty"` +} + +type PlaylistResponsePayload struct { + PlaylistInfo PlaylistInfoMetadata `json:"playlist_info"` + TrackList []AlbumTrackMetadata `json:"track_list"` +} + +type ArtistInfoMetadata struct { + Name string `json:"name"` + Followers int `json:"followers"` + Genres []string `json:"genres"` + Images string `json:"images"` + ExternalURL string `json:"external_urls"` + DiscographyType string `json:"discography_type"` + TotalAlbums int `json:"total_albums"` + Batch string `json:"batch,omitempty"` +} + +type DiscographyAlbumMetadata struct { + ID string `json:"id"` + Name string `json:"name"` + AlbumType string `json:"album_type"` + ReleaseDate string `json:"release_date"` + TotalTracks int `json:"total_tracks"` + Artists string `json:"artists"` + Images string `json:"images"` + ExternalURL string `json:"external_urls"` +} + +type ArtistDiscographyPayload struct { + ArtistInfo ArtistInfoMetadata `json:"artist_info"` + AlbumList []DiscographyAlbumMetadata `json:"album_list"` + TrackList []AlbumTrackMetadata `json:"track_list"` +} + +type ArtistResponsePayload struct { + Artist struct { + Name string `json:"name"` + Followers int `json:"followers"` + Genres []string `json:"genres"` + Images string `json:"images"` + ExternalURL string `json:"external_urls"` + Popularity int `json:"popularity"` + } `json:"artist"` +} + +type spotifyURI struct { + Type string + ID string + DiscographyGroup string +} + +type secretEntry struct { + Version int `json:"version"` + Secret []int `json:"secret"` +} + +type serverTimeResponse struct { + ServerTime int64 `json:"serverTime"` +} + +type accessTokenResponse struct { + AccessToken string `json:"accessToken"` +} + +type image struct { + URL string `json:"url"` +} + +type externalURL struct { + Spotify string `json:"spotify"` +} + +type externalID struct { + ISRC string `json:"isrc"` +} + +type artist struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type albumSimplified struct { + ID string `json:"id"` + Name string `json:"name"` + AlbumType string `json:"album_type"` + ReleaseDate string `json:"release_date"` + TotalTracks int `json:"total_tracks"` + Images []image `json:"images"` + ExternalURL externalURL `json:"external_urls"` + Artists []artist `json:"artists"` +} + +type trackSimplified struct { + ID string `json:"id"` + Name string `json:"name"` + DurationMS int `json:"duration_ms"` + TrackNumber int `json:"track_number"` + ExternalURL externalURL `json:"external_urls"` + Artists []artist `json:"artists"` +} + +type trackFull struct { + ID string `json:"id"` + Name string `json:"name"` + DurationMS int `json:"duration_ms"` + TrackNumber int `json:"track_number"` + ExternalURL externalURL `json:"external_urls"` + ExternalID externalID `json:"external_ids"` + Album albumSimplified `json:"album"` + Artists []artist `json:"artists"` +} + +type playlistTrackItem struct { + Track *trackFull `json:"track"` +} + +type playlistResponse struct { + Name string `json:"name"` + Images []image `json:"images"` + Owner struct { + DisplayName string `json:"display_name"` + } `json:"owner"` + Followers struct { + Total int `json:"total"` + } `json:"followers"` + Tracks struct { + Items []playlistTrackItem `json:"items"` + Next string `json:"next"` + Total int `json:"total"` + } `json:"tracks"` +} + +type albumResponse struct { + Name string `json:"name"` + ReleaseDate string `json:"release_date"` + TotalTracks int `json:"total_tracks"` + Images []image `json:"images"` + Artists []artist `json:"artists"` + Tracks struct { + Items []trackSimplified `json:"items"` + Next string `json:"next"` + } `json:"tracks"` +} + +type artistResponse struct { + Name string `json:"name"` + Followers struct { + Total int `json:"total"` + } `json:"followers"` + Genres []string `json:"genres"` + Images []image `json:"images"` + ExternalURL externalURL `json:"external_urls"` + Popularity int `json:"popularity"` +} + +type artistAlbumsResponse struct { + Items []albumSimplified `json:"items"` + Next string `json:"next"` +} + +type playlistRaw struct { + Data playlistResponse + BatchEnabled bool + BatchCount int +} + +type albumRaw struct { + Data albumResponse + Token string + BatchEnabled bool + BatchCount int +} + +type discographyRaw struct { + Artist artistResponse + Albums []albumSimplified + Token string + Discography string + BatchEnabled bool + BatchCount int +} + +// GetFilteredSpotifyData is a convenience wrapper that mirrors the Python module's entry point. +func GetFilteredSpotifyData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) { + client := NewSpotifyMetadataClient() + return client.GetFilteredData(ctx, spotifyURL, batch, delay) +} + +// GetFilteredData fetches, normalises, and formats Spotify payloads for the given URL. +func (c *SpotifyMetadataClient) GetFilteredData(ctx context.Context, spotifyURL string, batch bool, delay time.Duration) (interface{}, error) { + parsed, err := parseSpotifyURI(spotifyURL) + if err != nil { + return nil, err + } + + token, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + raw, err := c.getRawSpotifyData(ctx, parsed, token, batch, delay) + if err != nil { + return nil, err + } + + return c.processSpotifyData(ctx, raw, parsed.Type) +} + +func (c *SpotifyMetadataClient) getRawSpotifyData(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (interface{}, error) { + switch parsed.Type { + case "playlist": + return c.fetchPlaylist(ctx, parsed.ID, token, batch, delay) + case "album": + return c.fetchAlbum(ctx, parsed.ID, token, batch, delay) + case "track": + return c.fetchTrack(ctx, parsed.ID, token) + case "artist_discography": + return c.fetchArtistDiscography(ctx, parsed, token, batch, delay) + case "artist": + return c.fetchArtist(ctx, parsed.ID, token) + default: + return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type) + } +} + +func (c *SpotifyMetadataClient) processSpotifyData(ctx context.Context, raw interface{}, dataType string) (interface{}, error) { + switch payload := raw.(type) { + case *playlistRaw: + return c.formatPlaylistData(payload), nil + case *albumRaw: + return c.formatAlbumData(ctx, payload) + case *trackFull: + trackPayload := formatTrackData(payload) + return trackPayload, nil + case *discographyRaw: + return c.formatArtistDiscographyData(ctx, payload) + case *artistResponse: + formatted := formatArtistData(payload) + return formatted, nil + default: + return nil, errors.New("unknown raw payload type") + } +} + +func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, token string, batch bool, delay time.Duration) (*playlistRaw, error) { + var data playlistResponse + if err := c.getJSON(ctx, fmt.Sprintf(playlistBaseURL, playlistID), token, &data); err != nil { + return nil, err + } + + tracksURL := fmt.Sprintf("https://api.spotify.com/v1/playlists/%s/tracks?limit=100", playlistID) + var items []playlistTrackItem + batchDelay := time.Duration(0) + if batch { + batchDelay = delay + } + batches, err := fetchPaging(ctx, c, tracksURL, token, batchDelay, &items) + if err != nil { + return nil, err + } + if len(items) > 0 { + data.Tracks.Items = items + } + + return &playlistRaw{ + Data: data, + BatchEnabled: batch, + BatchCount: batches, + }, nil +} + +func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string, batch bool, delay time.Duration) (*albumRaw, error) { + var data albumResponse + if err := c.getJSON(ctx, fmt.Sprintf(albumBaseURL, albumID), token, &data); err != nil { + return nil, err + } + + tracksURL := fmt.Sprintf("%s/tracks?limit=50", fmt.Sprintf(albumBaseURL, albumID)) + var items []trackSimplified + batchDelay := time.Duration(0) + if batch { + batchDelay = delay + } + batches, err := fetchPaging(ctx, c, tracksURL, token, batchDelay, &items) + if err != nil { + return nil, err + } + if len(items) > 0 { + data.Tracks.Items = items + } + + return &albumRaw{ + Data: data, + Token: token, + BatchEnabled: batch, + BatchCount: batches, + }, nil +} + +func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token string) (*trackFull, error) { + var data trackFull + if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil { + return nil, err + } + return &data, nil +} + +func (c *SpotifyMetadataClient) fetchArtistDiscography(ctx context.Context, parsed spotifyURI, token string, batch bool, delay time.Duration) (*discographyRaw, error) { + var artistData artistResponse + if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, parsed.ID), token, &artistData); err != nil { + return nil, err + } + + includeGroups := parsed.DiscographyGroup + if includeGroups == "" || includeGroups == "all" { + includeGroups = "album,single,compilation" + } + + albumsURL := fmt.Sprintf("%s?include_groups=%s&limit=50", fmt.Sprintf(artistAlbumsBaseURL, parsed.ID), includeGroups) + var albums []albumSimplified + batchDelay := time.Duration(0) + if batch { + batchDelay = delay + } + batches, err := fetchPaging(ctx, c, albumsURL, token, batchDelay, &albums) + if err != nil { + return nil, err + } + + return &discographyRaw{ + Artist: artistData, + Albums: albums, + Token: token, + Discography: parsed.DiscographyGroup, + BatchEnabled: batch, + BatchCount: batches, + }, nil +} + +func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*artistResponse, error) { + var artistData artistResponse + if err := c.getJSON(ctx, fmt.Sprintf(artistBaseURL, artistID), token, &artistData); err != nil { + return nil, err + } + return &artistData, nil +} + +func (c *SpotifyMetadataClient) formatPlaylistData(raw *playlistRaw) PlaylistResponsePayload { + var info PlaylistInfoMetadata + info.Tracks.Total = raw.Data.Tracks.Total + info.Followers.Total = raw.Data.Followers.Total + info.Owner.DisplayName = raw.Data.Owner.DisplayName + info.Owner.Name = raw.Data.Name + info.Owner.Images = firstImageURL(raw.Data.Images) + if raw.BatchEnabled { + info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount)) + } + + tracks := make([]AlbumTrackMetadata, 0, len(raw.Data.Tracks.Items)) + for _, item := range raw.Data.Tracks.Items { + if item.Track == nil { + continue + } + tracks = append(tracks, AlbumTrackMetadata{ + Artists: joinArtists(item.Track.Artists), + Name: item.Track.Name, + AlbumName: item.Track.Album.Name, + DurationMS: item.Track.DurationMS, + Images: firstNonEmpty(firstImageURL(item.Track.Album.Images), info.Owner.Images), + ReleaseDate: item.Track.Album.ReleaseDate, + TrackNumber: item.Track.TrackNumber, + ExternalURL: item.Track.ExternalURL.Spotify, + ISRC: item.Track.ExternalID.ISRC, + }) + } + + return PlaylistResponsePayload{ + PlaylistInfo: info, + TrackList: tracks, + } +} + +func (c *SpotifyMetadataClient) formatAlbumData(ctx context.Context, raw *albumRaw) (*AlbumResponsePayload, error) { + albumImage := firstImageURL(raw.Data.Images) + info := AlbumInfoMetadata{ + TotalTracks: raw.Data.TotalTracks, + Name: raw.Data.Name, + ReleaseDate: raw.Data.ReleaseDate, + Artists: joinArtists(raw.Data.Artists), + Images: albumImage, + } + if raw.BatchEnabled { + info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount)) + } + + tracks := make([]AlbumTrackMetadata, 0, len(raw.Data.Tracks.Items)) + cache := make(map[string]string) + for _, item := range raw.Data.Tracks.Items { + isrc := c.fetchTrackISRC(ctx, item.ID, raw.Token, cache) + tracks = append(tracks, AlbumTrackMetadata{ + Artists: joinArtists(item.Artists), + Name: item.Name, + AlbumName: raw.Data.Name, + DurationMS: item.DurationMS, + Images: albumImage, + ReleaseDate: raw.Data.ReleaseDate, + TrackNumber: item.TrackNumber, + ExternalURL: item.ExternalURL.Spotify, + ISRC: isrc, + }) + } + + return &AlbumResponsePayload{ + AlbumInfo: info, + TrackList: tracks, + }, nil +} + +func (c *SpotifyMetadataClient) formatArtistDiscographyData(ctx context.Context, raw *discographyRaw) (*ArtistDiscographyPayload, error) { + artistImage := firstImageURL(raw.Artist.Images) + discType := raw.Discography + if discType == "" { + discType = "all" + } + + info := ArtistInfoMetadata{ + Name: raw.Artist.Name, + Followers: raw.Artist.Followers.Total, + Genres: raw.Artist.Genres, + Images: artistImage, + ExternalURL: raw.Artist.ExternalURL.Spotify, + DiscographyType: discType, + TotalAlbums: len(raw.Albums), + } + if raw.BatchEnabled { + info.Batch = strconv.Itoa(maxInt(1, raw.BatchCount)) + } + + albumList := make([]DiscographyAlbumMetadata, 0, len(raw.Albums)) + allTracks := make([]AlbumTrackMetadata, 0) + isrcCache := make(map[string]string) + + for _, alb := range raw.Albums { + albumImage := firstImageURL(alb.Images) + albumList = append(albumList, DiscographyAlbumMetadata{ + ID: alb.ID, + Name: alb.Name, + AlbumType: alb.AlbumType, + ReleaseDate: alb.ReleaseDate, + TotalTracks: alb.TotalTracks, + Artists: joinArtists(alb.Artists), + Images: albumImage, + ExternalURL: alb.ExternalURL.Spotify, + }) + + tracks, err := c.collectAlbumTracks(ctx, alb.ID, raw.Token) + if err != nil { + fmt.Printf("Error getting tracks for album %s: %v\n", alb.Name, err) + continue + } + + for _, tr := range tracks { + isrc := c.fetchTrackISRC(ctx, tr.ID, raw.Token, isrcCache) + allTracks = append(allTracks, AlbumTrackMetadata{ + Artists: joinArtists(tr.Artists), + Name: tr.Name, + AlbumName: alb.Name, + AlbumType: alb.AlbumType, + DurationMS: tr.DurationMS, + Images: albumImage, + ReleaseDate: alb.ReleaseDate, + TrackNumber: tr.TrackNumber, + ExternalURL: tr.ExternalURL.Spotify, + ISRC: isrc, + }) + } + } + + return &ArtistDiscographyPayload{ + ArtistInfo: info, + AlbumList: albumList, + TrackList: allTracks, + }, nil +} + +func formatArtistData(raw *artistResponse) ArtistResponsePayload { + if raw == nil { + return ArtistResponsePayload{} + } + payload := ArtistResponsePayload{} + payload.Artist.Name = raw.Name + payload.Artist.Followers = raw.Followers.Total + payload.Artist.Genres = raw.Genres + payload.Artist.Images = firstImageURL(raw.Images) + payload.Artist.ExternalURL = raw.ExternalURL.Spotify + payload.Artist.Popularity = raw.Popularity + return payload +} + +func formatTrackData(raw *trackFull) TrackResponse { + if raw == nil { + return TrackResponse{} + } + return TrackResponse{ + Track: TrackMetadata{ + Artists: joinArtists(raw.Artists), + Name: raw.Name, + AlbumName: raw.Album.Name, + DurationMS: raw.DurationMS, + Images: firstImageURL(raw.Album.Images), + ReleaseDate: raw.Album.ReleaseDate, + TrackNumber: raw.TrackNumber, + ExternalURL: raw.ExternalURL.Spotify, + ISRC: raw.ExternalID.ISRC, + }, + } +} + +func (c *SpotifyMetadataClient) collectAlbumTracks(ctx context.Context, albumID, token string) ([]trackSimplified, error) { + url := fmt.Sprintf("%s/tracks?limit=50", fmt.Sprintf(albumBaseURL, albumID)) + var tracks []trackSimplified + _, err := fetchPaging(ctx, c, url, token, 0, &tracks) + if err != nil { + return nil, err + } + return tracks, nil +} + +func (c *SpotifyMetadataClient) fetchTrackISRC(ctx context.Context, trackID, token string, cache map[string]string) string { + if trackID == "" || token == "" { + return "" + } + if isrc, ok := cache[trackID]; ok { + return isrc + } + + var data struct { + ExternalID externalID `json:"external_ids"` + } + if err := c.getJSON(ctx, fmt.Sprintf(trackBaseURL, trackID), token, &data); err != nil { + return "" + } + cache[trackID] = data.ExternalID.ISRC + return cache[trackID] +} + +func fetchPaging[T any](ctx context.Context, client *SpotifyMetadataClient, nextURL, token string, delay time.Duration, dest *[]T) (int, error) { + batches := 0 + for nextURL != "" { + select { + case <-ctx.Done(): + return batches, ctx.Err() + default: + } + + var page struct { + Items []T `json:"items"` + Next string `json:"next"` + } + if err := client.getJSON(ctx, nextURL, token, &page); err != nil { + return batches, err + } + + *dest = append(*dest, page.Items...) + nextURL = stripLocaleParam(page.Next) + batches++ + + if nextURL != "" && delay > 0 { + if err := sleepWithContext(ctx, delay); err != nil { + return batches, err + } + } + } + return batches, nil +} + +func (c *SpotifyMetadataClient) getJSON(ctx context.Context, endpoint, token string, dst interface{}) error { + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + headers := c.baseHeaders() + for key, values := range headers { + for _, v := range values { + req.Header.Add(key, v) + } + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return err + } + + if resp.StatusCode == http.StatusTooManyRequests { + if err := sleepWithContext(ctx, parseRetryAfter(resp.Header.Get("Retry-After"))); err != nil { + return err + } + continue + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("spotify API returned status %d for %s", resp.StatusCode, endpoint) + } + + return json.Unmarshal(body, dst) + } +} + +func (c *SpotifyMetadataClient) baseHeaders() http.Header { + h := http.Header{} + h.Set("User-Agent", c.userAgent) + h.Set("Accept", "application/json") + h.Set("Accept-Language", "en-US,en;q=0.9") + h.Set("sec-ch-ua-platform", "\"Windows\"") + h.Set("sec-fetch-dest", "empty") + h.Set("sec-fetch-mode", "cors") + h.Set("sec-fetch-site", "same-origin") + h.Set("Referer", "https://open.spotify.com/") + h.Set("Origin", "https://open.spotify.com") + return h +} + +func (c *SpotifyMetadataClient) randomUserAgent() string { + c.rngMu.Lock() + defer c.rngMu.Unlock() + + macMajor := c.randRange(11, 15) + macMinor := c.randRange(4, 9) + webkitMajor := c.randRange(530, 537) + webkitMinor := c.randRange(30, 37) + chromeMajor := c.randRange(80, 105) + chromeBuild := c.randRange(3000, 4500) + chromePatch := c.randRange(60, 125) + safariMajor := c.randRange(530, 537) + safariMinor := c.randRange(30, 36) + + return fmt.Sprintf( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_%d_%d) AppleWebKit/%d.%d (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/%d.%d", + macMajor, + macMinor, + webkitMajor, + webkitMinor, + chromeMajor, + chromeBuild, + chromePatch, + safariMajor, + safariMinor, + ) +} + +func (c *SpotifyMetadataClient) randRange(min, max int) int { + if max <= min { + return min + } + return c.rng.Intn(max-min) + min +} + +func (c *SpotifyMetadataClient) getAccessToken(ctx context.Context) (string, error) { + code, serverTime, version, err := c.generateTOTP(ctx) + if err != nil { + return "", err + } + + timestampMS := time.Now().UnixMilli() + params := url.Values{} + params.Set("reason", "init") + params.Set("productType", "web-player") + params.Set("totp", code) + params.Set("totpServerTime", strconv.FormatInt(serverTime, 10)) + params.Set("totpVer", strconv.Itoa(version)) + params.Set("sTime", strconv.FormatInt(serverTime, 10)) + params.Set("cTime", strconv.FormatInt(timestampMS, 10)) + params.Set("buildVer", "web-player_2025-07-02_1720000000000_12345678") + params.Set("buildDate", "2025-07-02") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, spotifyTokenURL, nil) + if err != nil { + return "", err + } + req.URL.RawQuery = params.Encode() + req.Header = c.baseHeaders() + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to get access token. Status code: %d", resp.StatusCode) + } + + var token accessTokenResponse + if err := json.Unmarshal(body, &token); err != nil { + return "", err + } + if token.AccessToken == "" { + return "", errors.New("failed to get access token: empty token received") + } + return token.AccessToken, nil +} + +func (c *SpotifyMetadataClient) generateTOTP(ctx context.Context) (string, int64, int, error) { + secrets, _, err := c.fetchSecretBytes(ctx) + if err != nil { + return "", 0, 0, err + } + if len(secrets) == 0 { + return "", 0, 0, errors.New("no secrets available") + } + + latest := secrets[0] + for _, entry := range secrets[1:] { + if entry.Version > latest.Version { + latest = entry + } + } + + builder := strings.Builder{} + for idx, val := range latest.Secret { + processed := val ^ ((idx % 33) + 9) + builder.WriteString(strconv.Itoa(processed)) + } + + utfBytes := []byte(builder.String()) + hexStr := hex.EncodeToString(utfBytes) + secretBytes, err := hex.DecodeString(hexStr) + if err != nil { + return "", 0, 0, err + } + b32Secret := base32.StdEncoding.EncodeToString(secretBytes) + + serverTime, err := c.fetchServerTime(ctx) + if err != nil { + return "", 0, 0, err + } + + code, err := computeTOTP(b32Secret, serverTime) + if err != nil { + return "", 0, 0, err + } + + return code, serverTime, latest.Version, nil +} + +func (c *SpotifyMetadataClient) fetchSecretBytes(ctx context.Context) ([]secretEntry, bool, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, secretBytesRemotePath, nil) + if err == nil { + resp, err := c.httpClient.Do(req) + if err == nil { + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr == nil && resp.StatusCode == http.StatusOK { + var secrets []secretEntry + if jsonErr := json.Unmarshal(body, &secrets); jsonErr == nil { + return secrets, false, nil + } + } + } + } + + home, err := os.UserHomeDir() + if err != nil { + return nil, false, fmt.Errorf("GitHub fetch failed and could not resolve home directory: %w", err) + } + localPath := filepath.Join(home, ".spotify-secret", "secretBytes.json") + data, err := os.ReadFile(localPath) + if err != nil { + return nil, false, fmt.Errorf("failed to fetch secrets from both GitHub and local: %w", err) + } + + var secrets []secretEntry + if err := json.Unmarshal(data, &secrets); err != nil { + return nil, false, fmt.Errorf("failed to process local secrets: %w", err) + } + return secrets, true, nil +} + +func (c *SpotifyMetadataClient) fetchServerTime(ctx context.Context) (int64, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://open.spotify.com/api/server-time", nil) + if err != nil { + return 0, err + } + req.Header = c.serverTimeHeaders() + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, err + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return 0, err + } + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("failed to get server time. Status code: %d", resp.StatusCode) + } + + var payload serverTimeResponse + if err := json.Unmarshal(body, &payload); err != nil { + return 0, err + } + if payload.ServerTime == 0 { + return 0, errors.New("failed to fetch server time from Spotify") + } + return payload.ServerTime, nil +} + +func (c *SpotifyMetadataClient) serverTimeHeaders() http.Header { + h := http.Header{} + h.Set("Host", "open.spotify.com") + h.Set("User-Agent", c.randomUserAgent()) + h.Set("Accept", "*/*") + return h +} + +func computeTOTP(b32Secret string, timestamp int64) (string, error) { + normalized := strings.ToUpper(strings.ReplaceAll(b32Secret, " ", "")) + key, err := base32.StdEncoding.DecodeString(normalized) + if err != nil { + return "", err + } + + // Normalise milliseconds if necessary. + if timestamp > 1_000_000_000_000 { + timestamp /= 1000 + } + + counter := uint64(timestamp / 30) + var buf [8]byte + binary.BigEndian.PutUint64(buf[:], counter) + + mac := hmac.New(sha1.New, key) + if _, err := mac.Write(buf[:]); err != nil { + return "", err + } + sum := mac.Sum(nil) + if len(sum) < 20 { + return "", errors.New("unexpected hmac length for TOTP") + } + + offset := sum[len(sum)-1] & 0x0f + binaryCode := (int(sum[offset])&0x7f)<<24 | + (int(sum[offset+1])&0xff)<<16 | + (int(sum[offset+2])&0xff)<<8 | + (int(sum[offset+3]) & 0xff) + otp := binaryCode % 1_000_000 + return fmt.Sprintf("%06d", otp), nil +} + +func parseSpotifyURI(input string) (spotifyURI, error) { + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return spotifyURI{}, errInvalidSpotifyURL + } + + if strings.HasPrefix(trimmed, "spotify:") { + parts := strings.Split(trimmed, ":") + if len(parts) == 3 { + switch parts[1] { + case "album", "track", "playlist", "artist": + return spotifyURI{Type: parts[1], ID: parts[2]}, nil + } + } + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return spotifyURI{}, err + } + + if parsed.Host == "embed.spotify.com" { + if parsed.RawQuery == "" { + return spotifyURI{}, errInvalidSpotifyURL + } + qs, _ := url.ParseQuery(parsed.RawQuery) + embedded := qs.Get("uri") + if embedded == "" { + return spotifyURI{}, errInvalidSpotifyURL + } + return parseSpotifyURI(embedded) + } + + if parsed.Scheme == "" && parsed.Host == "" { + id := strings.Trim(strings.TrimSpace(parsed.Path), "/") + if id == "" { + return spotifyURI{}, errInvalidSpotifyURL + } + return spotifyURI{Type: "playlist", ID: id}, nil + } + + if parsed.Host != "open.spotify.com" && parsed.Host != "play.spotify.com" { + return spotifyURI{}, errInvalidSpotifyURL + } + + parts := cleanPathParts(parsed.Path) + if len(parts) == 0 { + return spotifyURI{}, errInvalidSpotifyURL + } + + if parts[0] == "embed" { + parts = parts[1:] + } + if len(parts) == 0 { + return spotifyURI{}, errInvalidSpotifyURL + } + if strings.HasPrefix(parts[0], "intl-") { + parts = parts[1:] + } + if len(parts) == 0 { + return spotifyURI{}, errInvalidSpotifyURL + } + + if len(parts) == 2 { + switch parts[0] { + case "album", "track", "playlist", "artist": + return spotifyURI{Type: parts[0], ID: parts[1]}, nil + } + } + + if len(parts) == 4 && parts[2] == "playlist" { + return spotifyURI{Type: "playlist", ID: parts[3]}, nil + } + + if len(parts) >= 3 && parts[0] == "artist" { + if len(parts) >= 3 && parts[2] == "discography" { + discType := "all" + if len(parts) >= 4 { + candidate := parts[3] + if candidate == "all" || candidate == "album" || candidate == "single" || candidate == "compilation" { + discType = candidate + } + } + return spotifyURI{Type: "artist_discography", ID: parts[1], DiscographyGroup: discType}, nil + } + return spotifyURI{Type: "artist", ID: parts[1]}, nil + } + + return spotifyURI{}, errInvalidSpotifyURL +} + +func cleanPathParts(path string) []string { + raw := strings.Split(path, "/") + parts := make([]string, 0, len(raw)) + for _, part := range raw { + if part != "" { + parts = append(parts, part) + } + } + return parts +} + +func stripLocaleParam(raw string) string { + if raw == "" { + return "" + } + if idx := strings.Index(raw, "&locale="); idx != -1 { + return raw[:idx] + } + if idx := strings.Index(raw, "?locale="); idx != -1 { + return raw[:idx] + } + return raw +} + +func firstImageURL(images []image) string { + if len(images) == 0 { + return "" + } + return images[0].URL +} + +func joinArtists(artists []artist) string { + if len(artists) == 0 { + return "" + } + names := make([]string, 0, len(artists)) + for _, a := range artists { + if a.Name != "" { + names = append(names, a.Name) + } + } + return strings.Join(names, ", ") +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +func parseRetryAfter(value string) time.Duration { + if value == "" { + return 5 * time.Second + } + secs, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return 5 * time.Second + } + return time.Duration(secs+1) * time.Second +} + +func sleepWithContext(ctx context.Context, d time.Duration) error { + if d <= 0 { + return nil + } + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/backend/tidal.go b/backend/tidal.go new file mode 100644 index 0000000..2e6c7cc --- /dev/null +++ b/backend/tidal.go @@ -0,0 +1,434 @@ +package backend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +type TidalDownloader struct { + client *http.Client + timeout time.Duration + maxRetries int + clientID string + clientSecret string + apiURL string +} + +type TidalSearchResponse struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + TotalNumberOfItems int `json:"totalNumberOfItems"` + Items []TidalTrack `json:"items"` +} + +type TidalTrack struct { + ID int64 `json:"id"` + Title string `json:"title"` + ISRC string `json:"isrc"` + AudioQuality string `json:"audioQuality"` + TrackNumber int `json:"trackNumber"` + VolumeNumber int `json:"volumeNumber"` + Duration int `json:"duration"` + Copyright string `json:"copyright"` + Explicit bool `json:"explicit"` + Album struct { + Title string `json:"title"` + Cover string `json:"cover"` + ReleaseDate string `json:"releaseDate"` + } `json:"album"` + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + Artist struct { + Name string `json:"name"` + } `json:"artist"` + MediaMetadata struct { + Tags []string `json:"tags"` + } `json:"mediaMetadata"` +} + +type TidalAPIResponse struct { + OriginalTrackURL string `json:"OriginalTrackUrl"` +} + +type TidalAPIInfo struct { + URL string `json:"url"` + Status string `json:"status"` +} + +func NewTidalDownloader(apiURL string) *TidalDownloader { + clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") + clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=") + + return &TidalDownloader{ + client: &http.Client{ + Timeout: 60 * time.Second, + }, + timeout: 30 * time.Second, + maxRetries: 3, + clientID: string(clientID), + clientSecret: string(clientSecret), + apiURL: apiURL, + } +} + +func (t *TidalDownloader) GetAvailableAPIs() ([]string, error) { + resp, err := http.Get("https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/tidal.json") + if err != nil { + return nil, fmt.Errorf("failed to fetch API list: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to fetch API list: HTTP %d", resp.StatusCode) + } + + var apiList []string + if err := json.NewDecoder(resp.Body).Decode(&apiList); err != nil { + return nil, fmt.Errorf("failed to decode API list: %w", err) + } + + var apis []string + for _, api := range apiList { + apis = append(apis, "https://"+api) + } + + return apis, nil +} + +func (t *TidalDownloader) GetAccessToken() (string, error) { + data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID) + + req, err := http.NewRequest("POST", "https://auth.tidal.com/v1/oauth2/token", strings.NewReader(data)) + if err != nil { + return "", err + } + + req.SetBasicAuth(t.clientID, t.clientSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := t.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("failed to get access token: HTTP %d", resp.StatusCode) + } + + var result struct { + AccessToken string `json:"access_token"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + return result.AccessToken, nil +} + +func (t *TidalDownloader) SearchTracks(query string) (*TidalSearchResponse, error) { + token, err := t.GetAccessToken() + if err != nil { + return nil, fmt.Errorf("failed to get access token: %w", err) + } + + // URL encode the query parameter + searchURL := fmt.Sprintf("https://api.tidal.com/v1/search/tracks?query=%s&limit=25&offset=0&countryCode=US", url.QueryEscape(query)) + + req, err := http.NewRequest("GET", searchURL, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := t.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("search failed: HTTP %d - %s", resp.StatusCode, string(body)) + } + + var result TidalSearchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return &result, nil +} + +func (t *TidalDownloader) GetTrackInfo(query, isrc string) (*TidalTrack, error) { + fmt.Printf("Fetching: %s", query) + if isrc != "" { + fmt.Printf(" (ISRC: %s)", isrc) + } + fmt.Println() + + result, err := t.SearchTracks(query) + if err != nil { + return nil, err + } + + if len(result.Items) == 0 { + return nil, fmt.Errorf("no tracks found for query: %s", query) + } + + var selectedTrack *TidalTrack + + if isrc != "" { + var isrcMatches []TidalTrack + for _, item := range result.Items { + if item.ISRC == isrc { + isrcMatches = append(isrcMatches, item) + } + } + + if len(isrcMatches) > 1 { + for _, item := range isrcMatches { + for _, tag := range item.MediaMetadata.Tags { + if tag == "HIRES_LOSSLESS" { + selectedTrack = &item + break + } + } + if selectedTrack != nil { + break + } + } + if selectedTrack == nil { + selectedTrack = &isrcMatches[0] + } + } else if len(isrcMatches) == 1 { + selectedTrack = &isrcMatches[0] + } else { + selectedTrack = &result.Items[0] + } + } else { + selectedTrack = &result.Items[0] + } + + if selectedTrack == nil { + return nil, fmt.Errorf("track not found") + } + + fmt.Printf("Found: %s (%s)\n", selectedTrack.Title, selectedTrack.AudioQuality) + return selectedTrack, nil +} + +func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (string, error) { + fmt.Println("Fetching URL...") + + url := fmt.Sprintf("%s/track/?id=%d&quality=%s", t.apiURL, trackID, quality) + + resp, err := t.client.Get(url) + if err != nil { + return "", fmt.Errorf("failed to get download URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("API returned status code: %d", resp.StatusCode) + } + + var apiResponses []TidalAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResponses); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + if len(apiResponses) == 0 { + return "", fmt.Errorf("no download URL in response") + } + + for _, item := range apiResponses { + if item.OriginalTrackURL != "" { + fmt.Println("URL found") + return item.OriginalTrackURL, nil + } + } + + return "", fmt.Errorf("download URL not found in response") +} + +func (t *TidalDownloader) DownloadAlbumArt(albumID string) ([]byte, error) { + albumID = strings.ReplaceAll(albumID, "-", "/") + artURL := fmt.Sprintf("https://resources.tidal.com/images/%s/1280x1280.jpg", albumID) + + resp, err := t.client.Get(artURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("failed to download album art: HTTP %d", resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +func (t *TidalDownloader) DownloadFile(url, filepath string) error { + resp, err := t.client.Get(url) + if err != nil { + return fmt.Errorf("failed to download file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("download failed with status %d", resp.StatusCode) + } + + out, err := os.Create(filepath) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + fmt.Println("Download complete") + return nil +} + +func (t *TidalDownloader) Download(query, isrc, outputDir, quality string) (string, error) { + if outputDir != "." { + if err := os.MkdirAll(outputDir, 0755); err != nil { + return "", fmt.Errorf("directory error: %w", err) + } + } + + trackInfo, err := t.GetTrackInfo(query, isrc) + if err != nil { + return "", err + } + + if trackInfo.ID == 0 { + return "", fmt.Errorf("no track ID found") + } + + var artists []string + if len(trackInfo.Artists) > 0 { + for _, artist := range trackInfo.Artists { + if artist.Name != "" { + artists = append(artists, artist.Name) + } + } + } else if trackInfo.Artist.Name != "" { + artists = append(artists, trackInfo.Artist.Name) + } + + artistName := "Unknown Artist" + if len(artists) > 0 { + artistName = strings.Join(artists, ", ") + } + artistName = sanitizeFilename(artistName) + + trackTitle := sanitizeFilename(trackInfo.Title) + if trackTitle == "" { + trackTitle = fmt.Sprintf("track_%d", trackInfo.ID) + } + + outputFilename := filepath.Join(outputDir, fmt.Sprintf("%s - %s.flac", artistName, trackTitle)) + + if fileInfo, err := os.Stat(outputFilename); err == nil && fileInfo.Size() > 0 { + fmt.Printf("File already exists: %s (%.2f MB)\n", outputFilename, float64(fileInfo.Size())/(1024*1024)) + return outputFilename, nil + } + + downloadURL, err := t.GetDownloadURL(trackInfo.ID, quality) + if err != nil { + return "", err + } + + fmt.Printf("Downloading to: %s\n", outputFilename) + if err := t.DownloadFile(downloadURL, outputFilename); err != nil { + return "", err + } + + fmt.Println("Adding metadata...") + + coverPath := "" + if trackInfo.Album.Cover != "" { + coverPath = outputFilename + ".cover.jpg" + albumArt, err := t.DownloadAlbumArt(trackInfo.Album.Cover) + if err != nil { + fmt.Printf("Warning: Failed to download album art: %v\n", err) + } else { + if err := os.WriteFile(coverPath, albumArt, 0644); err != nil { + fmt.Printf("Warning: Failed to save album art: %v\n", err) + } else { + defer os.Remove(coverPath) + fmt.Println("Album art downloaded") + } + } + } + + releaseYear := "" + if len(trackInfo.Album.ReleaseDate) >= 4 { + releaseYear = trackInfo.Album.ReleaseDate[:4] + } + + metadata := Metadata{ + Title: trackInfo.Title, + Artist: artistName, + Album: trackInfo.Album.Title, + Date: releaseYear, + TrackNumber: trackInfo.TrackNumber, + DiscNumber: trackInfo.VolumeNumber, + ISRC: trackInfo.ISRC, + } + + if err := EmbedMetadata(outputFilename, metadata, coverPath); err != nil { + fmt.Printf("Tagging failed: %v\n", err) + } else { + fmt.Println("Metadata saved") + } + + fmt.Println("Done") + return outputFilename, nil +} + +func (t *TidalDownloader) DownloadWithFallback(query, isrc, outputDir, quality string) (string, error) { + apis, err := t.GetAvailableAPIs() + if err != nil { + return "", fmt.Errorf("no APIs available for fallback: %w", err) + } + + var lastError error + for i, apiURL := range apis { + fmt.Printf("[Auto Fallback %d/%d] Trying: %s\n", i+1, len(apis), apiURL) + + fallbackDownloader := NewTidalDownloader(apiURL) + + result, err := fallbackDownloader.Download(query, isrc, outputDir, quality) + if err == nil { + fmt.Printf("✓ Success with: %s\n", apiURL) + return result, nil + } + + lastError = err + errMsg := err.Error() + if len(errMsg) > 80 { + errMsg = errMsg[:80] + } + fmt.Printf("✗ Failed with %s: %s\n", apiURL, errMsg) + } + + return "", fmt.Errorf("all %d APIs failed. Last error: %v", len(apis), lastError) +} diff --git a/deezerDL.py b/deezerDL.py deleted file mode 100644 index 6c4e422..0000000 --- a/deezerDL.py +++ /dev/null @@ -1,241 +0,0 @@ -import requests -import asyncio -import os -import sys -from mutagen.flac import FLAC -from random import randrange - -def get_random_user_agent(): - return f"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_{randrange(11, 15)}_{randrange(4, 9)}) AppleWebKit/{randrange(530, 537)}.{randrange(30, 37)} (KHTML, like Gecko) Chrome/{randrange(80, 105)}.0.{randrange(3000, 4500)}.{randrange(60, 125)} Safari/{randrange(530, 537)}.{randrange(30, 36)}" - -class DeezerDownloader: - def __init__(self): - self.session = requests.Session() - self.session.headers.update({ - 'User-Agent': get_random_user_agent() - }) - self.progress_callback = None - - def set_progress_callback(self, callback): - self.progress_callback = callback - - def get_track_by_isrc(self, isrc): - try: - url = f"https://api.deezer.com/2.0/track/isrc:{isrc}" - response = self.session.get(url) - response.raise_for_status() - - data = response.json() - - if 'error' in data: - print(f"Error from Deezer API: {data['error']['message']}") - return None - - return data - except requests.exceptions.RequestException as e: - print(f"Error fetching track data: {e}") - return None - - def extract_metadata(self, track_data): - metadata = {} - - metadata['title'] = track_data.get('title', '') - metadata['title_short'] = track_data.get('title_short', '') - metadata['duration'] = track_data.get('duration', 0) - metadata['track_position'] = track_data.get('track_position', 1) - metadata['disk_number'] = track_data.get('disk_number', 1) - metadata['isrc'] = track_data.get('isrc', '') - metadata['release_date'] = track_data.get('release_date', '') - metadata['explicit_lyrics'] = track_data.get('explicit_lyrics', False) - - if 'artist' in track_data: - metadata['artist'] = track_data['artist'].get('name', '') - metadata['artist_id'] = track_data['artist'].get('id', '') - - if 'contributors' in track_data: - artists = [] - for contributor in track_data['contributors']: - if contributor.get('role') == 'Main': - artists.append(contributor.get('name', '')) - metadata['artists'] = ', '.join(artists) if artists else metadata.get('artist', '') - - if 'album' in track_data: - album = track_data['album'] - metadata['album'] = album.get('title', '') - metadata['album_id'] = album.get('id', '') - metadata['cover_url'] = album.get('cover_xl', album.get('cover_big', '')) - metadata['cover_md5'] = album.get('md5_image', '') - - metadata['deezer_link'] = track_data.get('link', '') - metadata['preview_url'] = track_data.get('preview', '') - - return metadata - - def download_cover_art(self, cover_url, filename): - if not cover_url: - return None - - try: - response = self.session.get(cover_url) - response.raise_for_status() - - cover_path = f"{filename}_cover.jpg" - with open(cover_path, 'wb') as f: - f.write(response.content) - - return cover_path - except Exception as e: - print(f"Error downloading cover art: {e}") - return None - - def embed_metadata(self, file_path, metadata, cover_path=None): - try: - audio = FLAC(file_path) - - audio.clear() - - if metadata.get('title'): - audio['TITLE'] = metadata['title'] - if metadata.get('artists'): - audio['ARTIST'] = metadata['artists'] - elif metadata.get('artist'): - audio['ARTIST'] = metadata['artist'] - if metadata.get('album'): - audio['ALBUM'] = metadata['album'] - if metadata.get('release_date'): - audio['DATE'] = metadata['release_date'] - if metadata.get('track_position'): - audio['TRACKNUMBER'] = str(metadata['track_position']) - if metadata.get('disk_number'): - audio['DISCNUMBER'] = str(metadata['disk_number']) - if metadata.get('isrc'): - audio['ISRC'] = metadata['isrc'] - - if cover_path and os.path.exists(cover_path): - with open(cover_path, 'rb') as f: - cover_data = f.read() - - from mutagen.flac import Picture - picture = Picture() - picture.type = 3 - picture.mime = 'image/jpeg' - picture.desc = 'Cover' - picture.data = cover_data - audio.add_picture(picture) - - audio.save() - print(f"Metadata embedded successfully in {file_path}") - - except Exception as e: - print(f"Error embedding metadata: {e}") - - async def download_by_isrc(self, isrc, output_dir="."): - print(f"Fetching track info for ISRC: {isrc}") - - track_data = self.get_track_by_isrc(isrc) - if not track_data: - print("Failed to get track data from Deezer API") - return False - - metadata = self.extract_metadata(track_data) - print(f"Found track: {metadata.get('artists', 'Unknown')} - {metadata.get('title', 'Unknown')}") - - track_id = track_data.get('id') - if not track_id: - print("No track ID found in Deezer API response") - return False - - print(f"Using track ID: {track_id}") - - api_url = f"https://api.deezmate.com/dl/{track_id}" - print(f"Requesting download links from: {api_url}") - - try: - response = self.session.get(api_url) - response.raise_for_status() - api_data = response.json() - - if not api_data.get('success'): - print("API request failed") - return False - - links = api_data.get('links', {}) - flac_url = links.get('flac') - - if not flac_url: - print("No FLAC download link found in API response") - return False - - print(f"Successfully obtained FLAC download URL") - - except Exception as e: - print(f"Error getting download URL from API: {e}") - return False - - print("Downloading FLAC file...") - try: - response = self.session.get(flac_url) - response.raise_for_status() - - safe_title = "".join(c for c in metadata.get('title', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip() - safe_artist = "".join(c for c in metadata.get('artists', 'Unknown') if c.isalnum() or c in (' ', '-', '_')).rstrip() - filename = f"{safe_artist} - {safe_title}.flac" - file_path = os.path.join(output_dir, filename) - - with open(file_path, 'wb') as f: - f.write(response.content) - - downloaded = len(response.content) - print(f"File size: {downloaded} bytes ({downloaded / (1024*1024):.2f} MB)") - - if self.progress_callback: - self.progress_callback(downloaded, downloaded) - - print(f"Downloaded: {file_path}") - - cover_path = None - if metadata.get('cover_url'): - print("Downloading cover art...") - cover_path = self.download_cover_art(metadata['cover_url'], - os.path.join(output_dir, f"{safe_artist} - {safe_title}")) - - print("Embedding metadata...") - self.embed_metadata(file_path, metadata, cover_path) - - if cover_path and os.path.exists(cover_path): - os.remove(cover_path) - - print(f"Successfully downloaded and tagged: {filename}") - return True - - except Exception as e: - print(f"Error downloading file: {e}") - return False - -async def main(): - print("=== DeezerDL - Deezer Downloader ===") - downloader = DeezerDownloader() - - isrc = "USAT22409172" - output_dir = "." - - success = await downloader.download_by_isrc(isrc, output_dir) - if success: - print("Download completed successfully!") - else: - print("Download failed!") - -if __name__ == "__main__": - try: - import sys - if sys.platform == "win32": - import os - os.system("chcp 65001 > nul") - try: - sys.stdout.reconfigure(encoding='utf-8') - except: - pass - except: - pass - - asyncio.run(main()) \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..2b0833f --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..fc4549a --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + SpotiFLAC + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1aed2b3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,50 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "generate-icon": "node scripts/generate-icon.js" + }, + "dependencies": { + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@tailwindcss/vite": "^4.1.17", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.554.0", + "next-themes": "^0.4.6", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.17" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.6", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "sharp": "^0.34.5", + "tw-animate-css": "^1.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.47.0", + "vite": "^7.2.4" + } +} diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 new file mode 100644 index 0000000..4fcb36d --- /dev/null +++ b/frontend/package.json.md5 @@ -0,0 +1 @@ +1c863b339b3c07aabe6b968fcd4e46ab \ No newline at end of file diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..38ab8c9 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,3782 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-label': + specifier: ^2.1.8 + version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-progress': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-radio-group': + specifier: ^1.3.8 + version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-select': + specifier: ^2.2.6 + version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-tabs': + specifier: ^1.1.13 + version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tailwindcss/vite': + specifier: ^4.1.17 + version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^0.554.0 + version: 0.554.0(react@19.2.0) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + tailwind-merge: + specifier: ^3.4.0 + version: 3.4.0 + tailwindcss: + specifier: ^4.1.17 + version: 4.1.17 + devDependencies: + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.1 + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 + '@types/react': + specifier: ^19.2.6 + version: 19.2.6 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.6) + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)) + eslint: + specifier: ^9.39.1 + version: 9.39.1(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: ^0.4.24 + version: 0.4.24(eslint@9.39.1(jiti@2.6.1)) + globals: + specifier: ^16.5.0 + version: 16.5.0 + sharp: + specifier: ^0.34.5 + version: 0.34.5 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.47.0 + version: 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: ^7.2.4 + version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.1': + resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} + + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.3': + resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.8': + resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.8': + resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rolldown/pluginutils@1.0.0-beta.47': + resolution: {integrity: sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==} + + '@rollup/rollup-android-arm-eabi@4.53.3': + resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.53.3': + resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.53.3': + resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.53.3': + resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.53.3': + resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.53.3': + resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.53.3': + resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.53.3': + resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.53.3': + resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.53.3': + resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.53.3': + resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.53.3': + resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.53.3': + resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.53.3': + resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.53.3': + resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.53.3': + resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.53.3': + resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.53.3': + resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.1.17': + resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==} + + '@tailwindcss/oxide-android-arm64@4.1.17': + resolution: {integrity: sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.17': + resolution: {integrity: sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.17': + resolution: {integrity: sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.17': + resolution: {integrity: sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': + resolution: {integrity: sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': + resolution: {integrity: sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.17': + resolution: {integrity: sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.17': + resolution: {integrity: sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.17': + resolution: {integrity: sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.17': + resolution: {integrity: sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': + resolution: {integrity: sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.17': + resolution: {integrity: sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.17': + resolution: {integrity: sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==} + engines: {node: '>= 10'} + + '@tailwindcss/vite@4.1.17': + resolution: {integrity: sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.6': + resolution: {integrity: sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==} + + '@typescript-eslint/eslint-plugin@8.47.0': + resolution: {integrity: sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.47.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.47.0': + resolution: {integrity: sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.47.0': + resolution: {integrity: sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.47.0': + resolution: {integrity: sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.47.0': + resolution: {integrity: sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.47.0': + resolution: {integrity: sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.47.0': + resolution: {integrity: sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.47.0': + resolution: {integrity: sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.47.0': + resolution: {integrity: sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.47.0': + resolution: {integrity: sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@5.1.1': + resolution: {integrity: sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.8.30: + resolution: {integrity: sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==} + hasBin: true + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.0: + resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001756: + resolution: {integrity: sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + electron-to-chromium@1.5.259: + resolution: {integrity: sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==} + + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.24: + resolution: {integrity: sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.39.1: + resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.554.0: + resolution: {integrity: sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.1: + resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.53.3: + resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tailwind-merge@3.4.0: + resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} + + tailwindcss@4.1.17: + resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.47.0: + resolution: {integrity: sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + vite@7.2.4: + resolution: {integrity: sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))': + dependencies: + eslint: 9.39.1(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.1': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.1': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.3': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.4': + dependencies: + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@floating-ui/utils@0.2.10': {} + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.0.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.7.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.6)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-context@1.1.2(@types/react@19.2.6)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-context@1.1.3(@types/react@19.2.6)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(@types/react@19.2.6)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.6)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.6)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.6)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/rect': 1.1.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + aria-hidden: 1.2.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-remove-scroll: 2.7.1(@types/react@19.2.6)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.6)(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-slot@1.2.4(@types/react@19.2.6)(react@19.2.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.6)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.6)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.6)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.6)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.6)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.6)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.6)(react@19.2.0)': + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.6)(react@19.2.0)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.6)(react@19.2.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0) + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/rect@1.1.1': {} + + '@rolldown/pluginutils@1.0.0-beta.47': {} + + '@rollup/rollup-android-arm-eabi@4.53.3': + optional: true + + '@rollup/rollup-android-arm64@4.53.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.53.3': + optional: true + + '@rollup/rollup-darwin-x64@4.53.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.53.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.53.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.53.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.53.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.53.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.53.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.53.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.53.3': + optional: true + + '@tailwindcss/node@4.1.17': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.17 + + '@tailwindcss/oxide-android-arm64@4.1.17': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.17': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.17': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.17': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.17': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.17': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.17': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.17': + optional: true + + '@tailwindcss/oxide@4.1.17': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.17 + '@tailwindcss/oxide-darwin-arm64': 4.1.17 + '@tailwindcss/oxide-darwin-x64': 4.1.17 + '@tailwindcss/oxide-freebsd-x64': 4.1.17 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.17 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.17 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.17 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.17 + '@tailwindcss/oxide-linux-x64-musl': 4.1.17 + '@tailwindcss/oxide-wasm32-wasi': 4.1.17 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.17 + + '@tailwindcss/vite@4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@tailwindcss/node': 4.1.17 + '@tailwindcss/oxide': 4.1.17 + tailwindcss: 4.1.17 + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.6)': + dependencies: + '@types/react': 19.2.6 + + '@types/react@19.2.6': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.47.0 + '@typescript-eslint/type-utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.47.0 + eslint: 9.39.1(jiti@2.6.1) + graphemer: 1.4.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.47.0 + '@typescript-eslint/types': 8.47.0 + '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.47.0 + debug: 4.4.3 + eslint: 9.39.1(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.47.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3) + '@typescript-eslint/types': 8.47.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.47.0': + dependencies: + '@typescript-eslint/types': 8.47.0 + '@typescript-eslint/visitor-keys': 8.47.0 + + '@typescript-eslint/tsconfig-utils@8.47.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.47.0 + '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.1(jiti@2.6.1) + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.47.0': {} + + '@typescript-eslint/typescript-estree@8.47.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.47.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.47.0(typescript@5.9.3) + '@typescript-eslint/types': 8.47.0 + '@typescript-eslint/visitor-keys': 8.47.0 + debug: 4.4.3 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.3 + ts-api-utils: 2.1.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.47.0 + '@typescript-eslint/types': 8.47.0 + '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.47.0': + dependencies: + '@typescript-eslint/types': 8.47.0 + eslint-visitor-keys: 4.2.1 + + '@vitejs/plugin-react@5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.47 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2) + transitivePeerDependencies: + - supports-color + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.8.30: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.0: + dependencies: + baseline-browser-mapping: 2.8.30 + caniuse-lite: 1.0.30001756 + electron-to-chromium: 1.5.259 + node-releases: 2.0.27 + update-browserslist-db: 1.1.4(browserslist@4.28.0) + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001756: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + electron-to-chromium@1.5.259: {} + + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.1(jiti@2.6.1)): + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + eslint: 9.39.1(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.1.12 + zod-validation-error: 4.0.2(zod@4.1.12) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.4.24(eslint@9.39.1(jiti@2.6.1)): + dependencies: + eslint: 9.39.1(jiti@2.6.1) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.1(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.39.1 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-nonce@1.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + isexe@2.0.0: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.554.0(react@19.2.0): + dependencies: + react: 19.2.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + node-releases@2.0.27: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + + react-refresh@0.18.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.6)(react@19.2.0): + dependencies: + react: 19.2.0 + react-style-singleton: 2.2.3(@types/react@19.2.6)(react@19.2.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.6 + + react-remove-scroll@2.7.1(@types/react@19.2.6)(react@19.2.0): + dependencies: + react: 19.2.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.6)(react@19.2.0) + react-style-singleton: 2.2.3(@types/react@19.2.6)(react@19.2.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.6)(react@19.2.0) + use-sidecar: 1.1.3(@types/react@19.2.6)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.6 + + react-style-singleton@2.2.3(@types/react@19.2.6)(react@19.2.0): + dependencies: + get-nonce: 1.0.1 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.6 + + react@19.2.0: {} + + resolve-from@4.0.0: {} + + reusify@1.1.0: {} + + rollup@4.53.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.3 + '@rollup/rollup-android-arm64': 4.53.3 + '@rollup/rollup-darwin-arm64': 4.53.3 + '@rollup/rollup-darwin-x64': 4.53.3 + '@rollup/rollup-freebsd-arm64': 4.53.3 + '@rollup/rollup-freebsd-x64': 4.53.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 + '@rollup/rollup-linux-arm-musleabihf': 4.53.3 + '@rollup/rollup-linux-arm64-gnu': 4.53.3 + '@rollup/rollup-linux-arm64-musl': 4.53.3 + '@rollup/rollup-linux-loong64-gnu': 4.53.3 + '@rollup/rollup-linux-ppc64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-musl': 4.53.3 + '@rollup/rollup-linux-s390x-gnu': 4.53.3 + '@rollup/rollup-linux-x64-gnu': 4.53.3 + '@rollup/rollup-linux-x64-musl': 4.53.3 + '@rollup/rollup-openharmony-arm64': 4.53.3 + '@rollup/rollup-win32-arm64-msvc': 4.53.3 + '@rollup/rollup-win32-ia32-msvc': 4.53.3 + '@rollup/rollup-win32-x64-gnu': 4.53.3 + '@rollup/rollup-win32-x64-msvc': 4.53.3 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.3: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + source-map-js@1.2.1: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tailwind-merge@3.4.0: {} + + tailwindcss@4.1.17: {} + + tapable@2.3.0: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.1.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.8.1: {} + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.47.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.1(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.1.4(browserslist@4.28.0): + dependencies: + browserslist: 4.28.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.6)(react@19.2.0): + dependencies: + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.6 + + use-sidecar@1.1.3(@types/react@19.2.6)(react@19.2.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.6 + + vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.53.3 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.1 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.1.12): + dependencies: + zod: 4.1.12 + + zod@4.1.12: {} diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..da7bd7f --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/scripts/generate-icon.js b/frontend/scripts/generate-icon.js new file mode 100644 index 0000000..b975dd8 --- /dev/null +++ b/frontend/scripts/generate-icon.js @@ -0,0 +1,33 @@ +import sharp from 'sharp'; +import { readFileSync, mkdirSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rootDir = join(__dirname, '..', '..'); + +const svgPath = join(rootDir, 'frontend', 'public', 'icon.svg'); +const outputPath = join(rootDir, 'build', 'appicon.png'); + +async function generateIcon() { + try { + // Ensure build directory exists + mkdirSync(join(rootDir, 'build'), { recursive: true }); + + // Read SVG + const svgBuffer = readFileSync(svgPath); + + // Convert SVG to PNG (1024x1024 for Wails) + await sharp(svgBuffer) + .resize(1024, 1024) + .png() + .toFile(outputPath); + + console.log('✓ Icon generated:', outputPath); + } catch (error) { + console.error('✗ Failed to generate icon:', error.message); + process.exit(1); + } +} + +generateIcon(); diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..c3590a1 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,1022 @@ +import { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { fetchSpotifyMetadata, downloadTrack } from "@/lib/api"; +import type { SpotifyMetadataResponse, TrackMetadata } from "@/types/api"; +import { Settings } from "@/components/Settings"; +import { getSettings } from "@/lib/settings"; +import { applyTheme } from "@/lib/themes"; +import { Download, Search, Loader2, CheckCircle } from "lucide-react"; +import { toast } from "sonner"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +function App() { + const [spotifyUrl, setSpotifyUrl] = useState(""); + const [loading, setLoading] = useState(false); + const [metadata, setMetadata] = useState(null); + const [selectedTracks, setSelectedTracks] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [downloadProgress, setDownloadProgress] = useState(0); + const [isDownloading, setIsDownloading] = useState(false); + const [downloadingTrack, setDownloadingTrack] = useState(null); + const [bulkDownloadType, setBulkDownloadType] = useState<'all' | 'selected' | null>(null); + const [downloadedTracks, setDownloadedTracks] = useState>(new Set()); + const [showTimeoutDialog, setShowTimeoutDialog] = useState(false); + const [timeoutValue, setTimeoutValue] = useState(60); + const [pendingUrl, setPendingUrl] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [hasUpdate, setHasUpdate] = useState(false); + const shouldStopDownloadRef = useRef(false); + + const ITEMS_PER_PAGE = 50; + const CURRENT_VERSION = "5.5"; + + useEffect(() => { + const settings = getSettings(); + if (settings.darkMode) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + applyTheme(settings.theme); + + // Check for updates + checkForUpdates(); + }, []); + + const checkForUpdates = async () => { + try { + const response = await fetch('https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/version.json'); + const data = await response.json(); + const latestVersion = data.version; + + // Compare versions (simple string comparison works for x.y format) + if (latestVersion > CURRENT_VERSION) { + setHasUpdate(true); + } + } catch (err) { + // Silently fail if update check fails + console.error('Failed to check for updates:', err); + } + }; + + useEffect(() => { + // Clear selection, search, downloaded tracks, and reset page when metadata changes + setSelectedTracks([]); + setSearchQuery(""); + setDownloadedTracks(new Set()); + setCurrentPage(1); + }, [metadata]); + + const downloadWithAutoFallback = async (isrc: string, settings: any, trackName?: string, artistName?: string, folderName?: string) => { + let service = settings.downloader; + + // Build query for Tidal (title + artist) + const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; + + // Sanitize folder name (remove illegal characters for Windows) + const sanitizedFolderName = folderName + ? folderName.replace(/[<>:"/\\|?*]/g, '_').trim() + : undefined; + + // Build output directory with folder name if provided + const outputDir = sanitizedFolderName + ? `${settings.downloadPath}\\${sanitizedFolderName}` + : settings.downloadPath; + + // If auto mode, try Tidal first + if (service === "auto") { + try { + const tidalResponse = await downloadTrack({ + isrc, + service: "tidal", + query, + output_dir: outputDir, + }); + + if (tidalResponse.success) { + return tidalResponse; + } + + // Tidal failed, try Deezer + service = "deezer"; + } catch (tidalErr) { + service = "deezer"; + } + } + + // Use selected service or fallback to Deezer + return await downloadTrack({ + isrc, + service: service as "deezer" | "tidal", + query, + output_dir: outputDir, + }); + }; + + const handleFetchMetadata = async () => { + if (!spotifyUrl.trim()) { + toast.error("Please enter a Spotify URL"); + return; + } + + let urlToFetch = spotifyUrl.trim(); + const isArtistUrl = urlToFetch.includes('/artist/'); + + // Auto-convert artist URL to discography + if (isArtistUrl && !urlToFetch.includes('/discography')) { + urlToFetch = urlToFetch.replace(/\/$/, '') + '/discography/all'; + setSpotifyUrl(urlToFetch); + } + + // Show timeout dialog only for artist URLs + if (isArtistUrl) { + setPendingUrl(urlToFetch); + setShowTimeoutDialog(true); + } else { + // Directly fetch for non-artist URLs (track, album, playlist) + await fetchMetadataDirectly(urlToFetch); + } + }; + + const fetchMetadataDirectly = async (url: string) => { + setLoading(true); + setMetadata(null); + + try { + const data = await fetchSpotifyMetadata(url); + setMetadata(data); + toast.success("Metadata fetched successfully"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to fetch metadata"); + } finally { + setLoading(false); + } + }; + + const handleConfirmFetch = async () => { + setShowTimeoutDialog(false); + setLoading(true); + setMetadata(null); + + try { + const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue); + setMetadata(data); + toast.success("Metadata fetched successfully"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to fetch metadata"); + } finally { + setLoading(false); + } + }; + + const handleDownloadTrack = async (isrc: string, trackName?: string, artistName?: string) => { + if (!isrc) { + toast.error("No ISRC found for this track"); + return; + } + + const settings = getSettings(); + setDownloadingTrack(isrc); + + try { + const response = await downloadWithAutoFallback(isrc, settings, trackName, artistName); + + if (response.success) { + toast.success(response.message); + setDownloadedTracks(prev => new Set(prev).add(isrc)); + } else { + toast.error(response.error); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : "Download failed"); + } finally { + setDownloadingTrack(null); + } + }; + + const handleDownloadSelected = async () => { + if (selectedTracks.length === 0) { + toast.error("No tracks selected"); + return; + } + + const settings = getSettings(); + setIsDownloading(true); + setBulkDownloadType('selected'); + setDownloadProgress(0); + + let successCount = 0; + let errorCount = 0; + const total = selectedTracks.length; + + // Get all tracks and folder name from metadata + let allTracks: TrackMetadata[] = []; + let folderName: string | undefined; + + if (metadata && "track_list" in metadata) { + allTracks = metadata.track_list; + + // Get folder name from album or playlist + if ("album_info" in metadata) { + folderName = metadata.album_info.name; + } else if ("playlist_info" in metadata) { + folderName = metadata.playlist_info.owner.name; + } + } + + for (let i = 0; i < selectedTracks.length; i++) { + // Check if user clicked Stop + if (shouldStopDownloadRef.current) { + toast.info(`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.`); + break; + } + + const isrc = selectedTracks[i]; + const track = allTracks.find(t => t.isrc === isrc); + + setDownloadingTrack(isrc); // Show spinner on this track + + try { + const response = await downloadWithAutoFallback( + isrc, + settings, + track?.name, + track?.artists, + folderName + ); + + if (response.success) { + successCount++; + setDownloadedTracks(prev => new Set(prev).add(isrc)); + } else { + errorCount++; + } + } catch (err) { + errorCount++; + } + + setDownloadProgress(Math.round(((i + 1) / total) * 100)); + } + + setDownloadingTrack(null); // Clear spinner + setIsDownloading(false); + setBulkDownloadType(null); + shouldStopDownloadRef.current = false; // Reset flag + + if (errorCount === 0) { + toast.success(`Downloaded ${successCount} tracks successfully`); + } else { + toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`); + } + + setSelectedTracks([]); + }; + + const handleDownloadAll = async (tracks: TrackMetadata[], folderName?: string) => { + const tracksWithIsrc = tracks.filter(track => track.isrc); + + if (tracksWithIsrc.length === 0) { + toast.error("No tracks available for download"); + return; + } + + const settings = getSettings(); + setIsDownloading(true); + setBulkDownloadType('all'); + setDownloadProgress(0); + + let successCount = 0; + let errorCount = 0; + const total = tracksWithIsrc.length; + + for (let i = 0; i < tracksWithIsrc.length; i++) { + // Check if user clicked Stop + if (shouldStopDownloadRef.current) { + toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.`); + break; + } + + const track = tracksWithIsrc[i]; + + setDownloadingTrack(track.isrc); // Show spinner on this track + + try { + const response = await downloadWithAutoFallback( + track.isrc, + settings, + track.name, + track.artists, + folderName + ); + + if (response.success) { + successCount++; + setDownloadedTracks(prev => new Set(prev).add(track.isrc)); + } else { + errorCount++; + } + } catch (err) { + errorCount++; + } + + setDownloadProgress(Math.round(((i + 1) / total) * 100)); + } + + setDownloadingTrack(null); // Clear spinner + setIsDownloading(false); + setBulkDownloadType(null); + shouldStopDownloadRef.current = false; // Reset flag + + if (errorCount === 0) { + toast.success(`Downloaded ${successCount} tracks successfully`); + } else { + toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`); + } + }; + + const toggleTrackSelection = (isrc: string) => { + setSelectedTracks(prev => + prev.includes(isrc) + ? prev.filter(id => id !== isrc) + : [...prev, isrc] + ); + }; + + const toggleSelectAll = (tracks: TrackMetadata[]) => { + const tracksWithIsrc = tracks.filter(track => track.isrc).map(track => track.isrc); + if (selectedTracks.length === tracksWithIsrc.length) { + setSelectedTracks([]); + } else { + setSelectedTracks(tracksWithIsrc); + } + }; + + const formatDuration = (ms: number) => { + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + const handleSearchChange = (value: string) => { + setSearchQuery(value); + setCurrentPage(1); // Reset to first page when searching + }; + + const handleStopDownload = () => { + shouldStopDownloadRef.current = true; + toast.info('Stopping download...'); + }; + + const renderDownloadProgress = () => { + if (!isDownloading) return null; + + return ( +
+
+ + +
+

+ {downloadProgress}% complete ({bulkDownloadType === 'all' ? 'Downloading all tracks' : 'Downloading selected tracks'}) +

+
+ ); + }; + + const renderTrackList = (tracks: TrackMetadata[], showCheckboxes: boolean = false, hideAlbumColumn: boolean = false) => { + const filteredTracks = tracks.filter(track => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return ( + track.name.toLowerCase().includes(query) || + track.artists.toLowerCase().includes(query) || + track.album_name.toLowerCase().includes(query) + ); + }); + + // Pagination + const totalPages = Math.ceil(filteredTracks.length / ITEMS_PER_PAGE); + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + const endIndex = startIndex + ITEMS_PER_PAGE; + const paginatedTracks = filteredTracks.slice(startIndex, endIndex); + + const tracksWithIsrc = filteredTracks.filter(track => track.isrc); + const allSelected = tracksWithIsrc.length > 0 && tracksWithIsrc.every(track => selectedTracks.includes(track.isrc)); + + return ( +
+
+
+ + + + {showCheckboxes && ( + + )} + + + {!hideAlbumColumn && } + + + + + + {paginatedTracks.map((track, index) => ( + + {showCheckboxes && ( + + )} + + + {!hideAlbumColumn && ( + + )} + + + + ))} + +
+ toggleSelectAll(filteredTracks)} + /> + #TitleAlbumDurationActions
+ {track.isrc && ( + toggleTrackSelection(track.isrc)} + /> + )} + + {startIndex + index + 1} + +
+ {track.images && ( + {track.name} + )} +
+
+ {track.name} + {downloadedTracks.has(track.isrc) && ( + + )} +
+ {track.artists} +
+
+
+ {track.album_name} + + {formatDuration(track.duration_ms)} + + {track.isrc && ( + + )} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( + + + + { + e.preventDefault(); + if (currentPage > 1) setCurrentPage(currentPage - 1); + }} + className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} + /> + + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + { + e.preventDefault(); + setCurrentPage(page); + }} + isActive={currentPage === page} + className="cursor-pointer" + > + {page} + + + ))} + + + { + e.preventDefault(); + if (currentPage < totalPages) setCurrentPage(currentPage + 1); + }} + className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'} + /> + + + + )} +
+ ); + }; + + const renderMetadata = () => { + if (!metadata) return null; + + if ("track" in metadata) { + const { track } = metadata; + return ( + + +
+ {track.images && ( + {track.name} + )} +
+
+

{track.name}

+

{track.artists}

+
+
+
+

Album

+

{track.album_name}

+
+
+

Release Date

+

{track.release_date}

+
+
+ {track.isrc && ( +
+ +
+ )} +
+
+
+
+ ); + } + + if ("album_info" in metadata) { + const { album_info, track_list } = metadata; + return ( +
+ + +
+ {album_info.images && ( + {album_info.name} + )} +
+
+

Album

+

{album_info.name}

+
+ {album_info.artists} + + {album_info.release_date} + + {album_info.total_tracks} songs +
+
+
+ + {selectedTracks.length > 0 && ( + + )} +
+ {renderDownloadProgress()} +
+
+
+
+
+
+ + handleSearchChange(e.target.value)} + className="pl-10" + /> +
+ {renderTrackList(track_list, true, true)} +
+
+ ); + } + + if ("playlist_info" in metadata) { + const { playlist_info, track_list } = metadata; + return ( +
+ + +
+ {playlist_info.owner.images && ( + {playlist_info.owner.name} + )} +
+
+

Playlist

+

{playlist_info.owner.name}

+
+ {playlist_info.owner.display_name} + + {playlist_info.tracks.total} songs + + {playlist_info.followers.total.toLocaleString()} followers +
+
+
+ + {selectedTracks.length > 0 && ( + + )} +
+ {renderDownloadProgress()} +
+
+
+
+
+
+ + handleSearchChange(e.target.value)} + className="pl-10" + /> +
+ {renderTrackList(track_list, true)} +
+
+ ); + } + + if ("artist_info" in metadata) { + const { artist_info, album_list, track_list } = metadata; + return ( +
+ + +
+ {artist_info.images && ( + {artist_info.name} + )} +
+

Artist

+

{artist_info.name}

+
+ {artist_info.followers.toLocaleString()} followers + {artist_info.genres.length > 0 && ( + <> + + {artist_info.genres.join(", ")} + + )} +
+
+
+
+
+ + {album_list.length > 0 && ( +
+

Discography

+
+ {album_list.map((album) => ( +
+
+ {album.images && ( + {album.name} + )} +
+

{album.name}

+

+ {album.release_date?.split('-')[0]} • {album.album_type} +

+
+ ))} +
+
+ )} + + {track_list.length > 0 && ( +
+
+

Popular Tracks

+
+ + {selectedTracks.length > 0 && ( + + )} +
+
+ {renderDownloadProgress()} +
+ + handleSearchChange(e.target.value)} + className="pl-10" + /> +
+ {renderTrackList(track_list, true)} +
+ )} +
+ ); + } + + if ("artist" in metadata) { + const { artist } = metadata; + return ( + + + Artist: {artist.name} + + {artist.followers.toLocaleString()} followers • Popularity: {artist.popularity} + + + + {artist.images && ( + {artist.name} + )} + {artist.genres.length > 0 && ( +
+ +

{artist.genres.join(", ")}

+
+ )} +
+
+ ); + } + + return null; + }; + + return ( +
+
+
+
+
+ SpotiFLAC +

SpotiFLAC

+
+ + + v{CURRENT_VERSION} + + + {hasUpdate && ( + + + + + )} +
+
+

+ Get Spotify tracks in true FLAC from Tidal/Deezer — no account required. +

+
+
+ + +
+
+ + {/* Timeout Dialog */} + + + + Fetch Settings + + Set timeout for fetching metadata. Longer timeout is recommended for artists with large discography. + + +
+
+ + setTimeoutValue(Number(e.target.value))} + /> +

+ Default: 60 seconds. For large discographies, try 300-600 seconds (5-10 minutes). +

+
+
+ + + + +
+
+ + + +
+ +
+ setSpotifyUrl(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleFetchMetadata()} + /> + +
+

+ Supports track, album, playlist, and artist URLs +

+
+
+
+ + {metadata && renderMetadata()} +
+
+ ); +} + +export default App; diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx new file mode 100644 index 0000000..73f4fcc --- /dev/null +++ b/frontend/src/components/Settings.tsx @@ -0,0 +1,280 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Settings as SettingsIcon, FolderOpen } from "lucide-react"; +import { getSettings, getSettingsWithDefaults, saveSettings, type Settings as SettingsType } from "@/lib/settings"; +import { themes, applyTheme } from "@/lib/themes"; +import { OpenFolder } from "../../wailsjs/go/main/App"; + +export function Settings() { + const [open, setOpen] = useState(false); + const [savedSettings, setSavedSettings] = useState(getSettings()); + const [tempSettings, setTempSettings] = useState(savedSettings); + const [, setIsLoadingDefaults] = useState(false); + + // Apply saved settings + useEffect(() => { + if (savedSettings.darkMode) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + applyTheme(savedSettings.theme); + }, [savedSettings.darkMode, savedSettings.theme]); + + // Apply temp settings for preview when dialog is open + useEffect(() => { + if (open) { + if (tempSettings.darkMode) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + applyTheme(tempSettings.theme); + } + }, [open, tempSettings.darkMode, tempSettings.theme]); + + useEffect(() => { + // Load settings with defaults from backend on mount + const loadDefaults = async () => { + if (!savedSettings.downloadPath) { + setIsLoadingDefaults(true); + const settingsWithDefaults = await getSettingsWithDefaults(); + setSavedSettings(settingsWithDefaults); + setTempSettings(settingsWithDefaults); + setIsLoadingDefaults(false); + } + }; + loadDefaults(); + }, []); + + // Reset temp settings when dialog opens + useEffect(() => { + if (open) { + setTempSettings(savedSettings); + } + }, [open, savedSettings]); + + const handleSave = () => { + saveSettings(tempSettings); + setSavedSettings(tempSettings); + setOpen(false); + }; + + const handleCancel = () => { + // Revert to saved settings + if (savedSettings.darkMode) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + applyTheme(savedSettings.theme); + + setTempSettings(savedSettings); + setOpen(false); + }; + + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + // Dialog is closing, revert to saved settings + if (savedSettings.darkMode) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + applyTheme(savedSettings.theme); + setTempSettings(savedSettings); + } + setOpen(newOpen); + }; + + const handleDownloadPathChange = (value: string) => { + setTempSettings((prev) => ({ ...prev, downloadPath: value })); + }; + + const handleDownloaderChange = (value: "auto" | "deezer" | "tidal") => { + setTempSettings((prev) => ({ ...prev, downloader: value })); + }; + + const handleThemeChange = (value: string) => { + setTempSettings((prev) => ({ ...prev, theme: value })); + }; + + const toggleDarkMode = () => { + setTempSettings((prev) => ({ ...prev, darkMode: !prev.darkMode })); + }; + + const handleBrowseFolder = async () => { + if (!tempSettings.downloadPath) { + alert("Please enter a download path first"); + return; + } + + try { + // Call backend to open folder in file explorer + await OpenFolder(tempSettings.downloadPath); + } catch (error) { + console.error("Error opening folder:", error); + alert(`Error opening folder: ${error}`); + } + }; + + return ( + + + + + + + Settings + +
+ {/* Download Path */} +
+ +
+ handleDownloadPathChange(e.target.value)} + placeholder="C:\Users\YourUsername\Music" + /> + +
+
+ + {/* Source Selection */} +
+ + +
+ + {/* File Settings */} +
+

File Settings

+ + {/* Filename Format */} +
+ + setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))} + className="flex gap-4" + > +
+ + +
+
+ + +
+
+ + +
+
+
+ + {/* Subfolder Options */} +
+
+ setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))} + /> + +
+
+ setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))} + /> + +
+
+ setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))} + /> + +
+
+
+ + {/* Dark Mode Toggle */} +
+ + +
+ + {/* Theme Selection */} +
+ + +
+
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/frontend/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..7dfdb90 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,40 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps { + asChild?: boolean +} + +function Badge({ className, variant, asChild = false, ...props }: BadgeProps) { + const Comp = asChild ? Slot : "div" + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..21409a0 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,60 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..0e2a6cd --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..6cb123b --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..8916905 --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..ef7133a --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/frontend/src/components/ui/pagination.tsx b/frontend/src/components/ui/pagination.tsx new file mode 100644 index 0000000..9071769 --- /dev/null +++ b/frontend/src/components/ui/pagination.tsx @@ -0,0 +1,127 @@ +import * as React from "react" +import { + ChevronLeftIcon, + ChevronRightIcon, + MoreHorizontalIcon, +} from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Pagination({ className, ...props }: React.ComponentProps<"nav">) { + return ( +