From c93f7916b55c013d2a48af732ca09f78e8243f21 Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Wed, 12 Jun 2024 11:15:40 +0200 Subject: [PATCH 01/13] Move API endpoints to /api --- src/tribler/core/components.py | 18 +++++++++--------- .../restapi/search_endpoint.py | 2 +- .../core/database/restapi/database_endpoint.py | 2 +- .../knowledge/restapi/knowledge_endpoint.py | 2 +- .../restapi/create_torrent_endpoint.py | 2 +- .../libtorrent/restapi/downloads_endpoint.py | 2 +- .../libtorrent/restapi/libtorrent_endpoint.py | 2 +- .../libtorrent/restapi/torrentinfo_endpoint.py | 2 +- src/tribler/core/restapi/events_endpoint.py | 2 +- src/tribler/core/restapi/ipv8_endpoint.py | 2 +- src/tribler/core/restapi/settings_endpoint.py | 2 +- src/tribler/core/restapi/shutdown_endpoint.py | 2 +- .../core/restapi/statistics_endpoint.py | 2 +- src/tribler/core/session.py | 6 +++--- src/tribler/gui/event_request_manager.py | 2 +- src/tribler/gui/network/request.py | 2 +- .../core/restapi/test_events_endpoint.py | 2 +- .../core/restapi/test_ipv8_endpoint.py | 2 +- 18 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/tribler/core/components.py b/src/tribler/core/components.py index 42352d5092..e5e5f13caf 100644 --- a/src/tribler/core/components.py +++ b/src/tribler/core/components.py @@ -117,7 +117,7 @@ def finalize(self, ipv8: IPv8, session: Session, community: Community) -> None: """ When we are done launching, register our REST API. """ - session.rest_manager.get_endpoint("/search").content_discovery_community = community + session.rest_manager.get_endpoint("/api/search").content_discovery_community = community def get_endpoints(self) -> list[RESTEndpoint]: """ @@ -161,10 +161,10 @@ def finalize(self, ipv8: IPv8, session: Session, community: Community) -> None: """ When we are done launching, register our REST API. """ - session.rest_manager.get_endpoint("/downloads").mds = session.mds - session.rest_manager.get_endpoint("/statistics").mds = session.mds + session.rest_manager.get_endpoint("/api/downloads").mds = session.mds + session.rest_manager.get_endpoint("/api/statistics").mds = session.mds - db_endpoint = session.rest_manager.get_endpoint("/metadata") + db_endpoint = session.rest_manager.get_endpoint("/api/metadata") db_endpoint.download_manager = session.download_manager db_endpoint.mds = session.mds db_endpoint.tribler_db = session.db @@ -193,7 +193,7 @@ def finalize(self, ipv8: IPv8, session: Session, community: Community) -> None: """ When we are done launching, register our REST API. """ - endpoint = session.rest_manager.get_endpoint("/knowledge") + endpoint = session.rest_manager.get_endpoint("/api/knowledge") endpoint.db = session.db endpoint.community = community @@ -263,7 +263,7 @@ def finalize(self, ipv8: IPv8, session: Session, community: Community) -> None: When we are done launching, register our REST API. """ community.register_task("Start torrent checker", session.torrent_checker.initialize) - session.rest_manager.get_endpoint("/metadata").torrent_checker = session.torrent_checker + session.rest_manager.get_endpoint("/api/metadata").torrent_checker = session.torrent_checker @set_in_session("dht_discovery_community") @@ -278,7 +278,7 @@ def finalize(self, ipv8: IPv8, session: Session, community: Community) -> None: """ When we are done launching, register our REST API. """ - session.rest_manager.get_endpoint("/ipv8").endpoints["/dht"].dht = community + session.rest_manager.get_endpoint("/api/ipv8").endpoints["/dht"].dht = community @set_in_session("tunnel_community") @@ -314,8 +314,8 @@ def finalize(self, ipv8: IPv8, session: Session, community: Community) -> None: """ When we are done launching, register our REST API. """ - session.rest_manager.get_endpoint("/downloads").tunnel_community = community - session.rest_manager.get_endpoint("/ipv8").endpoints["/tunnel"].tunnels = community + session.rest_manager.get_endpoint("/api/downloads").tunnel_community = community + session.rest_manager.get_endpoint("/api/ipv8").endpoints["/tunnel"].tunnels = community @after("ContentDiscoveryComponent", "TorrentCheckerComponent") diff --git a/src/tribler/core/content_discovery/restapi/search_endpoint.py b/src/tribler/core/content_discovery/restapi/search_endpoint.py index f4188a4428..46f5e3e5b9 100644 --- a/src/tribler/core/content_discovery/restapi/search_endpoint.py +++ b/src/tribler/core/content_discovery/restapi/search_endpoint.py @@ -36,7 +36,7 @@ class SearchEndpoint(RESTEndpoint): This endpoint is responsible for searching in channels and torrents present in the local Tribler database. """ - path = "/search" + path = "/api/search" def __init__(self, middlewares: tuple = (), client_max_size: int = MAX_REQUEST_SIZE) -> None: """ diff --git a/src/tribler/core/database/restapi/database_endpoint.py b/src/tribler/core/database/restapi/database_endpoint.py index aee3264492..d863d5d4ee 100644 --- a/src/tribler/core/database/restapi/database_endpoint.py +++ b/src/tribler/core/database/restapi/database_endpoint.py @@ -74,7 +74,7 @@ class DatabaseEndpoint(RESTEndpoint): / """ - path = "/metadata" + path = "/api/metadata" def __init__(self, middlewares: tuple = (), client_max_size: int = MAX_REQUEST_SIZE) -> None: """ diff --git a/src/tribler/core/knowledge/restapi/knowledge_endpoint.py b/src/tribler/core/knowledge/restapi/knowledge_endpoint.py index a5cfb4be54..baae30ceb2 100644 --- a/src/tribler/core/knowledge/restapi/knowledge_endpoint.py +++ b/src/tribler/core/knowledge/restapi/knowledge_endpoint.py @@ -36,7 +36,7 @@ class KnowledgeEndpoint(RESTEndpoint): Top-level endpoint for knowledge management. """ - path = "/knowledge" + path = "/api/knowledge" def __init__(self, middlewares: tuple = (), client_max_size: int = MAX_REQUEST_SIZE) -> None: """ diff --git a/src/tribler/core/libtorrent/restapi/create_torrent_endpoint.py b/src/tribler/core/libtorrent/restapi/create_torrent_endpoint.py index 825cee8e62..84501d03b5 100644 --- a/src/tribler/core/libtorrent/restapi/create_torrent_endpoint.py +++ b/src/tribler/core/libtorrent/restapi/create_torrent_endpoint.py @@ -47,7 +47,7 @@ class CreateTorrentEndpoint(RESTEndpoint): See: http://www.bittorrent.org/beps/bep_0012.html """ - path = "/createtorrent" + path = "/api/createtorrent" def __init__(self, download_manager: DownloadManager, client_max_size: int = MAX_REQUEST_SIZE) -> None: """ diff --git a/src/tribler/core/libtorrent/restapi/downloads_endpoint.py b/src/tribler/core/libtorrent/restapi/downloads_endpoint.py index fe67e863db..bdca411fe5 100644 --- a/src/tribler/core/libtorrent/restapi/downloads_endpoint.py +++ b/src/tribler/core/libtorrent/restapi/downloads_endpoint.py @@ -57,7 +57,7 @@ class DownloadsEndpoint(RESTEndpoint): starting, pausing and stopping downloads. """ - path = "/downloads" + path = "/api/downloads" def __init__(self, download_manager: DownloadManager, metadata_store: MetadataStore | None = None, tunnel_community: TriblerTunnelCommunity | None = None) -> None: diff --git a/src/tribler/core/libtorrent/restapi/libtorrent_endpoint.py b/src/tribler/core/libtorrent/restapi/libtorrent_endpoint.py index 9c4e37cfe9..ed1af54190 100644 --- a/src/tribler/core/libtorrent/restapi/libtorrent_endpoint.py +++ b/src/tribler/core/libtorrent/restapi/libtorrent_endpoint.py @@ -23,7 +23,7 @@ class LibTorrentEndpoint(RESTEndpoint): Endpoint for getting information about libtorrent sessions and settings. """ - path = "/libtorrent" + path = "/api/libtorrent" def __init__(self, download_manager: DownloadManager) -> None: """ diff --git a/src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py b/src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py index 1314e1ef57..a950031fa6 100644 --- a/src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py +++ b/src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py @@ -87,7 +87,7 @@ class TorrentInfoEndpoint(RESTEndpoint): This endpoint is responsible for handing all requests regarding torrent info in Tribler. """ - path = "/torrentinfo" + path = "/api/torrentinfo" def __init__(self, download_manager: DownloadManager) -> None: """ diff --git a/src/tribler/core/restapi/events_endpoint.py b/src/tribler/core/restapi/events_endpoint.py index 917189896c..b645b14ce9 100644 --- a/src/tribler/core/restapi/events_endpoint.py +++ b/src/tribler/core/restapi/events_endpoint.py @@ -48,7 +48,7 @@ class EventsEndpoint(RESTEndpoint): indicates the type of the event. Individual events are separated by a newline character. """ - path = "/events" + path = "/api/events" def __init__(self, notifier: Notifier, public_key: str | None = None) -> None: """ diff --git a/src/tribler/core/restapi/ipv8_endpoint.py b/src/tribler/core/restapi/ipv8_endpoint.py index 5bed31ff16..db64bf34da 100644 --- a/src/tribler/core/restapi/ipv8_endpoint.py +++ b/src/tribler/core/restapi/ipv8_endpoint.py @@ -8,4 +8,4 @@ class IPv8RootEndpoint(RootEndpoint, RESTEndpoint): Make the IPv8 REST endpoint Tribler-compatible. """ - path = "/ipv8" + path = "/api/ipv8" diff --git a/src/tribler/core/restapi/settings_endpoint.py b/src/tribler/core/restapi/settings_endpoint.py index b4ca16a0ad..e5df86097c 100644 --- a/src/tribler/core/restapi/settings_endpoint.py +++ b/src/tribler/core/restapi/settings_endpoint.py @@ -13,7 +13,7 @@ class SettingsEndpoint(RESTEndpoint): This endpoint is responsible for handing all requests regarding settings and configuration. """ - path = "/settings" + path = "/api/settings" def __init__(self, tribler_config: TriblerConfigManager, download_manager: DownloadManager = None) -> None: """ diff --git a/src/tribler/core/restapi/shutdown_endpoint.py b/src/tribler/core/restapi/shutdown_endpoint.py index e583c72358..bb8b212da5 100644 --- a/src/tribler/core/restapi/shutdown_endpoint.py +++ b/src/tribler/core/restapi/shutdown_endpoint.py @@ -13,7 +13,7 @@ class ShutdownEndpoint(RESTEndpoint): With this endpoint you can shut down Tribler. """ - path = "/shutdown" + path = "/api/shutdown" def __init__(self, shutdown_callback: Callable[[], None]) -> None: """ diff --git a/src/tribler/core/restapi/statistics_endpoint.py b/src/tribler/core/restapi/statistics_endpoint.py index a5d67bdd82..d2c9939393 100644 --- a/src/tribler/core/restapi/statistics_endpoint.py +++ b/src/tribler/core/restapi/statistics_endpoint.py @@ -20,7 +20,7 @@ class StatisticsEndpoint(RESTEndpoint): This endpoint is responsible for handing requests regarding statistics in Tribler. """ - path = "/statistics" + path = "/api/statistics" def __init__(self, middlewares: tuple = (), client_max_size: int = MAX_REQUEST_SIZE) -> None: """ diff --git a/src/tribler/core/session.py b/src/tribler/core/session.py index e21673bff5..61eb095cbf 100644 --- a/src/tribler/core/session.py +++ b/src/tribler/core/session.py @@ -141,10 +141,10 @@ async def start(self) -> None: await self.ipv8.start() # REST (2/2) - self.rest_manager.get_endpoint("/ipv8").initialize(self.ipv8) - self.rest_manager.get_endpoint("/statistics").ipv8 = self.ipv8 + self.rest_manager.get_endpoint("/api/ipv8").initialize(self.ipv8) + self.rest_manager.get_endpoint("/api/statistics").ipv8 = self.ipv8 if self.config.get("statistics"): - self.rest_manager.get_endpoint("/ipv8").endpoints["/overlays"].enable_overlay_statistics(True, None, True) + self.rest_manager.get_endpoint("/api/ipv8").endpoints["/overlays"].enable_overlay_statistics(True, None, True) async def shutdown(self) -> None: """ diff --git a/src/tribler/gui/event_request_manager.py b/src/tribler/gui/event_request_manager.py index cb0179f5ac..1da373f48c 100644 --- a/src/tribler/gui/event_request_manager.py +++ b/src/tribler/gui/event_request_manager.py @@ -72,7 +72,7 @@ def create_request(self) -> QNetworkRequest | None: logger.warning("Can't create a request: api_port is not set (%d).", self.api_port) return - url = QUrl(f"http://127.0.0.1:{self.api_port}/events") + url = QUrl(f"http://127.0.0.1:{self.api_port}/api/events") request = QNetworkRequest(url) request.setRawHeader(b'X-Api-Key', self.api_key.encode('ascii')) return request diff --git a/src/tribler/gui/network/request.py b/src/tribler/gui/network/request.py index 1bac598094..405a6bc3d5 100644 --- a/src/tribler/gui/network/request.py +++ b/src/tribler/gui/network/request.py @@ -61,7 +61,7 @@ def __init__( super().__init__() self.logger = logging.getLogger(self.__class__.__name__) - self.endpoint = endpoint + self.endpoint = 'api/' + endpoint self.url_params = url_params self.priority = priority diff --git a/src/tribler/test_unit/core/restapi/test_events_endpoint.py b/src/tribler/test_unit/core/restapi/test_events_endpoint.py index 2798bf9163..cb09a649d9 100644 --- a/src/tribler/test_unit/core/restapi/test_events_endpoint.py +++ b/src/tribler/test_unit/core/restapi/test_events_endpoint.py @@ -19,7 +19,7 @@ def __init__(self, endpoint: EventsEndpoint, count: int = 1) -> None: Create a new GetEventsRequest. """ self.payload_writer = MockStreamWriter(endpoint, count=count) - super().__init__({}, "GET", "/events", payload_writer=self.payload_writer) + super().__init__({}, "GET", "/api/events", payload_writer=self.payload_writer) class MockStreamWriter(AbstractStreamWriter): diff --git a/src/tribler/test_unit/core/restapi/test_ipv8_endpoint.py b/src/tribler/test_unit/core/restapi/test_ipv8_endpoint.py index 79fcf88566..3909d2b28a 100644 --- a/src/tribler/test_unit/core/restapi/test_ipv8_endpoint.py +++ b/src/tribler/test_unit/core/restapi/test_ipv8_endpoint.py @@ -15,7 +15,7 @@ def test_binding(self) -> None: endpoint = IPv8RootEndpoint() endpoint.setup_routes() - self.assertEqual("/ipv8", endpoint.path) + self.assertEqual("/api/ipv8", endpoint.path) self.assertIn("/asyncio", endpoint.endpoints) self.assertIn("/attestation", endpoint.endpoints) self.assertIn("/dht", endpoint.endpoints) From 3e6a3e20012d97e369b5384f4b81b1e35dd4d95d Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Wed, 12 Jun 2024 11:18:50 +0200 Subject: [PATCH 02/13] Added torrent_status_changed event --- .../core/libtorrent/download_manager/download.py | 10 +++++++++- src/tribler/core/notifier.py | 1 + src/tribler/core/restapi/events_endpoint.py | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/tribler/core/libtorrent/download_manager/download.py b/src/tribler/core/libtorrent/download_manager/download.py index f6c2971344..cbf638510e 100644 --- a/src/tribler/core/libtorrent/download_manager/download.py +++ b/src/tribler/core/libtorrent/download_manager/download.py @@ -573,9 +573,17 @@ def update_lt_status(self, lt_status: lt.torrent_status) -> None: """ Update libtorrent stats and check if the download should be stopped. """ - self.lt_status = lt_status + old_status = self.get_state().get_status() + self.lt_status = lt_status state = self.get_state() + + # Notify the GUI if the status has changed + if self.notifier and not self.hidden and state.get_status() != old_status: + self.notifier.notify(Notification.torrent_status_changed, + infohash=hexlify(self.tdef.get_infohash()).decode(), + status=state.get_status().name) + if state.get_status() == DownloadStatus.SEEDING: mode = self.download_manager.config.get("libtorrent/download_defaults/seeding_mode") seeding_ratio = self.download_manager.config.get("libtorrent/download_defaults/seeding_ratio") diff --git a/src/tribler/core/notifier.py b/src/tribler/core/notifier.py index afc125ed6d..55a9a5cea1 100644 --- a/src/tribler/core/notifier.py +++ b/src/tribler/core/notifier.py @@ -24,6 +24,7 @@ class Notification(Enum): """ torrent_finished = Desc("torrent_finished", ["infohash", "name", "hidden"], [str, str, bool]) + torrent_status_changed = Desc("torrent_status_changed", ["infohash", "status"], [str, str]) tribler_shutdown_state = Desc("tribler_shutdown_state", ["state"], [str]) tribler_new_version = Desc("tribler_new_version", ["version"], [str]) channel_discovered = Desc("channel_discovered", ["data"], [dict]) diff --git a/src/tribler/core/restapi/events_endpoint.py b/src/tribler/core/restapi/events_endpoint.py index b645b14ce9..b580e4ac22 100644 --- a/src/tribler/core/restapi/events_endpoint.py +++ b/src/tribler/core/restapi/events_endpoint.py @@ -19,6 +19,7 @@ from ipv8.messaging.anonymization.tunnel import Circuit topics_to_send_to_gui = [ + Notification.torrent_status_changed, Notification.tunnel_removed, Notification.watch_folder_corrupt_file, Notification.tribler_new_version, From 9d7bc8ebbf59051bffed964ba6cbd94b0ff8f5aa Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Wed, 12 Jun 2024 11:23:20 +0200 Subject: [PATCH 03/13] Allow uploading torrents to the TorrentInfoEndpoint --- .../restapi/torrentinfo_endpoint.py | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py b/src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py index a950031fa6..71b998b14b 100644 --- a/src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py +++ b/src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py @@ -95,7 +95,8 @@ def __init__(self, download_manager: DownloadManager) -> None: """ super().__init__() self.download_manager = download_manager - self.app.add_routes([web.get("", self.get_torrent_info)]) + self.app.add_routes([web.get("", self.get_torrent_info), + web.put("", self.get_torrent_info_from_file)]) @docs( tags=["Libtorrent"], @@ -219,3 +220,32 @@ async def get_torrent_info(self, request: Request) -> RESTResponse: # noqa: C90 return RESTResponse({"metainfo": hexlify(json_dump.encode()).decode(), "download_exists": download and not download_is_metainfo_request}) + + @docs( + tags=["Libtorrent"], + summary="Return metainfo from a torrent found at a provided .torrent file.", + responses={ + 200: { + "description": "Return a hex-encoded json-encoded string with torrent metainfo", + "schema": schema(GetMetainfoResponse={"metainfo": String}) + } + } + ) + async def get_torrent_info_from_file(self, request: web.Request) -> RESTResponse: + """ + Return metainfo from a torrent found at a provided .torrent file. + """ + tdef = TorrentDef.load_from_memory(await request.read()) + infohash = tdef.get_infohash() + + # Check if the torrent is already in the downloads + download = self.download_manager.downloads.get(infohash) + metainfo_lookup = self.download_manager.metainfo_requests.get(infohash) + metainfo_download = metainfo_lookup.download if metainfo_lookup else None + requesting_metainfo = download == metainfo_download + + metainfo_unicode = recursive_unicode(deepcopy(tdef.get_metainfo()), ignore_errors=True) + metainfo_json = json.dumps(metainfo_unicode, ensure_ascii=False) + return RESTResponse({"infohash": hexlify(infohash).decode(), + "metainfo": hexlify(metainfo_json.encode('utf-8')).decode(), + "download_exists": download and not requesting_metainfo}) From efd3b2ff2a334a9dc8f55bef91b327176e716b62 Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Wed, 12 Jun 2024 11:27:22 +0200 Subject: [PATCH 04/13] Allow uploading torrents to the DownloadsEndpoint --- .../libtorrent/restapi/downloads_endpoint.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/tribler/core/libtorrent/restapi/downloads_endpoint.py b/src/tribler/core/libtorrent/restapi/downloads_endpoint.py index bdca411fe5..8f7c10b185 100644 --- a/src/tribler/core/libtorrent/restapi/downloads_endpoint.py +++ b/src/tribler/core/libtorrent/restapi/downloads_endpoint.py @@ -19,6 +19,7 @@ from tribler.core.libtorrent.download_manager.download_manager import DownloadManager from tribler.core.libtorrent.download_manager.download_state import DOWNLOAD, UPLOAD, DownloadStatus from tribler.core.libtorrent.download_manager.stream import STREAM_PAUSE_TIME, Stream, StreamChunk +from tribler.core.libtorrent.torrentdef import TorrentDef from tribler.core.restapi.rest_endpoint import ( HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, @@ -400,17 +401,29 @@ async def add_download(self, request: Request) -> RESTResponse: """ Start a download from a provided URI. """ - params = await request.json() - uri = params.get("uri") - if not uri: - return RESTResponse({"error": "uri parameter missing"}, status=HTTP_BAD_REQUEST) + tdef = uri = None + if request.content_type == 'applications/x-bittorrent': + params = dict(request.query.items()) + if 'anon_hops' in params: + params['anon_hops'] = int(params['anon_hops']) + if 'safe_seeding' in params: + params['safe_seeding'] = params['safe_seeding'] != 'false' + tdef = TorrentDef.load_from_memory(await request.read()) + else: + params = await request.json() + uri = params.get("uri") + if not uri: + return RESTResponse({"error": "uri parameter missing"}, status=HTTP_BAD_REQUEST) download_config, error = self.create_dconfig_from_params(params) if error: return RESTResponse({"error": error}, status=HTTP_BAD_REQUEST) try: - download = await self.download_manager.start_download_from_uri(uri, config=download_config) + if tdef: + download = await self.download_manager.start_download(tdef=tdef, config=download_config) + elif uri: + download = await self.download_manager.start_download_from_uri(uri, config=download_config) except Exception as e: return RESTResponse({"error": str(e)}, status=HTTP_INTERNAL_SERVER_ERROR) From fec2f27770a35cfefe1ffd759a9fa961bc8a101c Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Wed, 12 Jun 2024 11:29:19 +0200 Subject: [PATCH 05/13] Added WebUI/File endpoints --- requirements.txt | 1 + src/tribler/core/restapi/file_endpoint.py | 90 ++++++++++++++++++++++ src/tribler/core/restapi/rest_manager.py | 13 +++- src/tribler/core/restapi/webui_endpoint.py | 42 ++++++++++ src/tribler/core/session.py | 4 + 5 files changed, 147 insertions(+), 3 deletions(-) create mode 100644 src/tribler/core/restapi/file_endpoint.py create mode 100644 src/tribler/core/restapi/webui_endpoint.py diff --git a/requirements.txt b/requirements.txt index 44fca87aef..2890f61618 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ ipv8-rust-tunnels libtorrent==1.2.19 lz4 pony +pywin32;sys_platform=="win32" PyQt5 numpy diff --git a/src/tribler/core/restapi/file_endpoint.py b/src/tribler/core/restapi/file_endpoint.py new file mode 100644 index 0000000000..dfba471945 --- /dev/null +++ b/src/tribler/core/restapi/file_endpoint.py @@ -0,0 +1,90 @@ +import contextlib +import logging +import sys +from pathlib import Path + +import win32api +from aiohttp import web + +from tribler.core.restapi.rest_endpoint import HTTP_NOT_FOUND, RESTEndpoint, RESTResponse + + +class FileEndpoint(RESTEndpoint): + """ + This endpoint allows clients to view the server's file structure remotely. + """ + + path = '/api/files' + + def __init__(self) -> None: + """ + Create a new file endpoint. + """ + super().__init__() + self._logger = logging.getLogger(self.__class__.__name__) + self.app.add_routes([web.get('/browse', self.browse), + web.get('/list', self.list)]) + + async def browse(self, request: web.Request) -> RESTResponse: + """ + Return all files/directories found in the specified path. + """ + path = request.query.get('path', "") + show_files = request.query.get('files') == "1" + + # Deal with getting the drives on Windows + if path == "/" and sys.platform == 'win32': + paths = [] + for drive in win32api.GetLogicalDriveStrings().split("\000"): + if not drive: + continue + paths.append( + { + "name": drive, + "path": drive, + "dir": True, + } + ) + return RESTResponse({"current": "Root", + "paths": paths}) + + # Move up until we find a directory + path = Path(path).resolve() + while not path.is_dir(): + path = path.parent + + # Get all files/subdirs + results = [] + for file in path.iterdir(): + if not file.is_dir() and not show_files: + continue + with contextlib.suppress(PermissionError): + results.append({"name": file.name, "path": str(file.resolve()), "dir": file.is_dir()}) + + results.sort(key=lambda f: not f["dir"]) + + # Get parent path (if available) + results.insert(0, { + "name": "..", + "path": str(path.parent.resolve()) if path != path.parent else "/", + "dir": True, + }) + + return RESTResponse({"current": str(path.resolve()), + "paths": results}) + + async def list(self, request: web.Request) -> RESTResponse: + """ + Return all files found in the specified path. + """ + path = Path(request.query.get('path', "")) + recursively = request.query.get('recursively') != "0" + + if not path.exists(): + return RESTResponse({"error": f"Directory {path} does not exist"}, status=HTTP_NOT_FOUND) + + results = [{"name": file.name, + "path": str(file.resolve())} + for file in path.glob(f"{'**/' if recursively else ''}*") if file.is_file()] + + return RESTResponse({"paths": results}) diff --git a/src/tribler/core/restapi/rest_manager.py b/src/tribler/core/restapi/rest_manager.py index 51f0a231b5..7b67a7cbf2 100644 --- a/src/tribler/core/restapi/rest_manager.py +++ b/src/tribler/core/restapi/rest_manager.py @@ -70,7 +70,7 @@ def authenticate(self, request: Request) -> bool: """ Is the given request authenticated using an API key. """ - if any(request.path.startswith(path) for path in ["/docs", "/static", "/debug-ui"]): + if any(request.path.startswith(path) for path in ["/docs", "/static", "/ui"]): return True # The api key can either be in the headers or as part of the url query api_key = request.headers.get("X-Api-Key") or request.query.get("apikey") or request.cookies.get("api_key") @@ -111,6 +111,13 @@ async def error_middleware(request: Request, handler: Callable[[Request], Awaita return response +@web.middleware +async def ui_middleware(request: Request, handler: Callable[[Request], Awaitable[RESTResponse]]) -> RESTResponse: + if not any(request.path.startswith(path) for path in ["/docs", "/static", "/ui", "/api"]): + raise web.HTTPFound('/ui' + request.rel_url.path) + return await handler(request) + + @web.middleware async def required_components_middleware(request: Request, handler: Callable[[Request], Awaitable[RESTResponse]]) -> RESTResponse: @@ -142,8 +149,8 @@ def __init__(self, config: TriblerConfigManager, shutdown_timeout: int = 1) -> N """ super().__init__() self._logger = logging.getLogger(self.__class__.__name__) - self.root_endpoint = RootEndpoint(middlewares=(ApiKeyMiddleware(config.get("api/key")), error_middleware, - required_components_middleware)) + self.root_endpoint = RootEndpoint(middlewares=(ApiKeyMiddleware(config.get("api/key")), ui_middleware, + error_middleware, required_components_middleware)) self.runner: web.AppRunner | None = None self.site: web.TCPSite | None = None self.site_https: web.TCPSite | None = None diff --git a/src/tribler/core/restapi/webui_endpoint.py b/src/tribler/core/restapi/webui_endpoint.py new file mode 100644 index 0000000000..d065e09c7d --- /dev/null +++ b/src/tribler/core/restapi/webui_endpoint.py @@ -0,0 +1,42 @@ +import logging +import mimetypes +import pathlib + +from aiohttp import ClientSession, web + +from tribler.core.restapi.rest_endpoint import RESTEndpoint, RESTResponse + + +class WebUIEndpoint(RESTEndpoint): + """ + This endpoint serves files used by the web UI. + """ + + path = '/ui' + + def __init__(self) -> None: + """ + Create a new webUI endpoint. + """ + super().__init__() + self._logger = logging.getLogger(self.__class__.__name__) + self.app.add_routes([web.get('/{path:.*}', self.return_files)]) + + self.webui_root = (pathlib.Path(__file__).absolute() / "../../../ui/").resolve() + self.has_dist = (self.webui_root / 'dist').exists() + self.session = ClientSession() if not self.has_dist else None + + async def return_files(self, request: web.Request) -> RESTResponse: + """ + Return the file at the requested path. + """ + path = request.match_info['path'] or 'index.html' + + if self.session: + async with self.session.get(f'http://localhost:5173/{path}') as response: + return web.Response(body=await response.read(), content_type=response.content_type) + else: + resource = self.webui_root / 'dist' / path + response = web.FileResponse(resource) + response.content_type = 'application/javascript' if path.endswith('.tsx') else mimetypes.guess_type(path)[0] + return response diff --git a/src/tribler/core/session.py b/src/tribler/core/session.py index 61eb095cbf..c892432b47 100644 --- a/src/tribler/core/session.py +++ b/src/tribler/core/session.py @@ -25,11 +25,13 @@ from tribler.core.libtorrent.restapi.torrentinfo_endpoint import TorrentInfoEndpoint from tribler.core.notifier import Notification, Notifier from tribler.core.restapi.events_endpoint import EventsEndpoint +from tribler.core.restapi.file_endpoint import FileEndpoint from tribler.core.restapi.ipv8_endpoint import IPv8RootEndpoint from tribler.core.restapi.rest_manager import RESTManager from tribler.core.restapi.settings_endpoint import SettingsEndpoint from tribler.core.restapi.shutdown_endpoint import ShutdownEndpoint from tribler.core.restapi.statistics_endpoint import StatisticsEndpoint +from tribler.core.restapi.webui_endpoint import WebUIEndpoint from tribler.core.socks5.server import Socks5Server if TYPE_CHECKING: @@ -109,6 +111,8 @@ def register_rest_endpoints(self) -> None: """ Register all core REST endpoints without initializing them. """ + self.rest_manager.add_endpoint(WebUIEndpoint()) + self.rest_manager.add_endpoint(FileEndpoint()) self.rest_manager.add_endpoint(CreateTorrentEndpoint(self.download_manager)) self.rest_manager.add_endpoint(DownloadsEndpoint(self.download_manager)) self.rest_manager.add_endpoint(EventsEndpoint(self.notifier)) From f0c00e9da3ee5a43a48befe4b41f5e0ef3540872 Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Thu, 13 Jun 2024 22:57:16 +0200 Subject: [PATCH 06/13] Disable the Qt GUI --- requirements.txt | 2 + src/run_tribler.py | 53 ++++++++++++++++------ src/tribler/core/restapi/ipv8_endpoint.py | 8 ++++ src/tribler/core/restapi/rest_manager.py | 2 + src/tribler/core/restapi/webui_endpoint.py | 8 ++++ 5 files changed, 58 insertions(+), 15 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2890f61618..b455843c82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,9 @@ configobj ipv8-rust-tunnels libtorrent==1.2.19 lz4 +pillow pony +pystray pywin32;sys_platform=="win32" PyQt5 diff --git a/src/run_tribler.py b/src/run_tribler.py index a877327965..6cde856ed7 100644 --- a/src/run_tribler.py +++ b/src/run_tribler.py @@ -6,9 +6,16 @@ import logging.config import os import sys +import threading import typing +import webbrowser from pathlib import Path +import pystray +from PIL import Image +from tribler.core.session import Session +from tribler.tribler_config import TriblerConfigManager + logger = logging.getLogger(__name__) @@ -18,7 +25,6 @@ class Arguments(typing.TypedDict): """ torrent: str - core: bool log_level: str @@ -28,7 +34,6 @@ def parse_args() -> Arguments: """ parser = argparse.ArgumentParser(prog='Tribler [Experimental]', description='Run Tribler BitTorrent client') parser.add_argument('torrent', help='torrent file to download', default='', nargs='?') - parser.add_argument('--core', action="store_true", help="run core process") parser.add_argument('--log-level', default="INFO", action="store_true", help="set the log level", dest="log_level") return vars(parser.parse_args()) @@ -44,12 +49,10 @@ def get_root_state_directory(requested_path: os.PathLike | None) -> Path: return root_state_dir -def main() -> None: +async def main() -> None: """ - The main script entry point for either the GUI or the core process. + The main script entry point. """ - asyncio.set_event_loop(asyncio.SelectorEventLoop()) - parsed_args = parse_args() logging.basicConfig(level=parsed_args["log_level"], stream=sys.stdout) logger.info("Run Tribler: %s", parsed_args) @@ -58,16 +61,36 @@ def main() -> None: logger.info("Root state dir: %s", root_state_dir) api_port, api_key = int(os.environ.get('CORE_API_PORT', '-1')), os.environ.get('CORE_API_KEY') + logger.info("Start tribler core. API port: %d. API key: %s.", api_port, api_key) + + config = TriblerConfigManager(root_state_dir / "configuration.json") + config.set("state_dir", str(root_state_dir)) + + if config.get("api/refresh_port_on_start"): + config.set("api/http_port", 0) + config.set("api/https_port", 0) - # Check whether we need to start the core or the user interface - if parsed_args["core"]: - from tribler.core.start_core import run_core - run_core(api_port, api_key, root_state_dir) - else: - # GUI - from tribler.gui.start_gui import run_gui - run_gui(api_port, api_key, root_state_dir) + if api_key != config.get("api/key"): + config.set("api/key", api_key) + config.write() + + session = Session(config) + await session.start() + + image_path = Path(__file__).absolute() / "../tribler/ui/public/tribler.png" + image = Image.open(image_path.resolve()) + real_api_port = config.get("api/http_port") + menu = (pystray.MenuItem('Open', lambda: webbrowser.open_new_tab(f'http://localhost:{real_api_port}')), + pystray.MenuItem('Quit', lambda: session.shutdown_event.set())) + icon = pystray.Icon("Tribler", icon=image, title="Tribler", menu=menu) + threading.Thread(target=icon.run).start() + + await session.shutdown_event.wait() + await session.shutdown() + icon.stop() + logger.info("Tribler shutdown completed") if __name__ == "__main__": - main() + asyncio.set_event_loop(asyncio.SelectorEventLoop()) + asyncio.run(main()) diff --git a/src/tribler/core/restapi/ipv8_endpoint.py b/src/tribler/core/restapi/ipv8_endpoint.py index db64bf34da..24d8978c4d 100644 --- a/src/tribler/core/restapi/ipv8_endpoint.py +++ b/src/tribler/core/restapi/ipv8_endpoint.py @@ -9,3 +9,11 @@ class IPv8RootEndpoint(RootEndpoint, RESTEndpoint): """ path = "/api/ipv8" + + def __init__(self) -> None: + """ + Create a new IPv8 endpoint. + """ + RESTEndpoint.__init__(self) + RootEndpoint.__init__(self) + diff --git a/src/tribler/core/restapi/rest_manager.py b/src/tribler/core/restapi/rest_manager.py index 7b67a7cbf2..adc84b028f 100644 --- a/src/tribler/core/restapi/rest_manager.py +++ b/src/tribler/core/restapi/rest_manager.py @@ -264,4 +264,6 @@ async def stop(self) -> None: self._logger.info("Stopping...") if self.runner: await self.runner.cleanup() + for endpoint in self.root_endpoint.endpoints.values(): + await endpoint.shutdown_task_manager() self._logger.info("Stopped") diff --git a/src/tribler/core/restapi/webui_endpoint.py b/src/tribler/core/restapi/webui_endpoint.py index d065e09c7d..2b5ecafbe0 100644 --- a/src/tribler/core/restapi/webui_endpoint.py +++ b/src/tribler/core/restapi/webui_endpoint.py @@ -40,3 +40,11 @@ async def return_files(self, request: web.Request) -> RESTResponse: response = web.FileResponse(resource) response.content_type = 'application/javascript' if path.endswith('.tsx') else mimetypes.guess_type(path)[0] return response + + async def shutdown_task_manager(self) -> None: + """ + Shutdown the taskmanager. + """ + await super().shutdown_task_manager() + if self.session: + await self.session.close() From a722b319ec584d3b3a21c23a5681dd92a9a1e784 Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Thu, 13 Jun 2024 23:00:55 +0200 Subject: [PATCH 07/13] Remove Qt GUI --- src/tribler/core/start_core.py | 47 - src/tribler/gui/__init__.py | 3 - src/tribler/gui/app_manager.py | 32 - src/tribler/gui/code_executor.py | 136 - src/tribler/gui/core_manager.py | 273 -- src/tribler/gui/debug_window.py | 826 ---- src/tribler/gui/defs.py | 197 - src/tribler/gui/dialogs/__init__.py | 3 - src/tribler/gui/dialogs/confirmationdialog.py | 97 - .../gui/dialogs/createtorrentdialog.py | 184 - src/tribler/gui/dialogs/dialogcontainer.py | 65 - src/tribler/gui/dialogs/editmetadatadialog.py | 165 - src/tribler/gui/dialogs/feedbackdialog.py | 165 - .../gui/dialogs/startdownloaddialog.py | 299 -- src/tribler/gui/error_handler.py | 120 - src/tribler/gui/event_request_manager.py | 216 - src/tribler/gui/exceptions.py | 18 - src/tribler/gui/images/add.png | Bin 896 -> 0 bytes src/tribler/gui/images/browse_folder.svg | 1 - src/tribler/gui/images/check.svg | 54 - src/tribler/gui/images/debug.png | Bin 4243 -> 0 bytes src/tribler/gui/images/delete.png | Bin 1283 -> 0 bytes src/tribler/gui/images/down_arrow_input.png | Bin 564 -> 0 bytes src/tribler/gui/images/downloads.png | Bin 1819 -> 0 bytes src/tribler/gui/images/edit_orange.png | Bin 2088 -> 0 bytes src/tribler/gui/images/edit_white.png | Bin 2621 -> 0 bytes src/tribler/gui/images/ellipsis.png | Bin 9740 -> 0 bytes src/tribler/gui/images/fire.png | Bin 474 -> 0 bytes src/tribler/gui/images/gear.png | Bin 2499 -> 0 bytes src/tribler/gui/images/loading_animation.svg | 1 - src/tribler/gui/images/magnet.png | Bin 1869 -> 0 bytes src/tribler/gui/images/menu.png | Bin 466 -> 0 bytes src/tribler/gui/images/minus.svg | 69 - src/tribler/gui/images/monochrome_tribler.png | Bin 1484 -> 0 bytes src/tribler/gui/images/page_back.png | Bin 610 -> 0 bytes src/tribler/gui/images/play.png | Bin 1565 -> 0 bytes src/tribler/gui/images/plus.svg | 45 - src/tribler/gui/images/stop.png | Bin 656 -> 0 bytes .../gui/images/toggle-checked-disabled.svg | 13 - src/tribler/gui/images/toggle-checked.svg | 13 - .../gui/images/toggle-unchecked-disabled.svg | 11 - src/tribler/gui/images/toggle-unchecked.svg | 11 - src/tribler/gui/images/toggle-undefined.svg | 13 - src/tribler/gui/images/trash.svg | 61 - src/tribler/gui/images/tribler.png | Bin 14157 -> 0 bytes src/tribler/gui/images/undo.svg | 50 - src/tribler/gui/images/update.svg | 55 - src/tribler/gui/network/__init__.py | 0 src/tribler/gui/network/request.py | 196 - src/tribler/gui/network/request_manager.py | 225 - src/tribler/gui/qt_resources/buttonsdialog.ui | 255 - .../gui/qt_resources/createtorrentdialog.ui | 611 --- src/tribler/gui/qt_resources/debugwindow.ui | 1119 ----- .../gui/qt_resources/edit_metadata_dialog.ui | 615 --- .../gui/qt_resources/feedback_dialog.ui | 360 -- .../gui/qt_resources/loading_list_item.ui | 77 - src/tribler/gui/qt_resources/mainwindow.ui | 4248 ----------------- src/tribler/gui/qt_resources/qtbug.ui | 16 - .../gui/qt_resources/search_results.ui | 172 - .../gui/qt_resources/startdownloaddialog.ui | 554 --- src/tribler/gui/qt_resources/torrents_list.ui | 679 --- src/tribler/gui/single_application.py | 95 - src/tribler/gui/start_gui.py | 29 - src/tribler/gui/tribler_action_menu.py | 36 - src/tribler/gui/tribler_app.py | 95 - src/tribler/gui/tribler_window.py | 925 ---- src/tribler/gui/utilities.py | 590 --- src/tribler/gui/widgets/__init__.py | 3 - .../gui/widgets/channelcontentswidget.py | 447 -- src/tribler/gui/widgets/circlebutton.py | 7 - .../gui/widgets/clickable_line_edit.py | 13 - src/tribler/gui/widgets/clickablewidgets.py | 10 - src/tribler/gui/widgets/createtorrentpage.py | 118 - .../gui/widgets/downloadprogressbar.py | 94 - .../gui/widgets/downloadsdetailstabwidget.py | 218 - src/tribler/gui/widgets/downloadspage.py | 643 --- src/tribler/gui/widgets/downloadwidgetitem.py | 143 - src/tribler/gui/widgets/ellipsebutton.py | 7 - src/tribler/gui/widgets/graphs/__init__.py | 0 src/tribler/gui/widgets/graphs/dataplot.py | 17 - .../gui/widgets/graphs/timeseriesplot.py | 54 - .../gui/widgets/instanttooltipbutton.py | 10 - .../gui/widgets/instanttooltipstyle.py | 15 - src/tribler/gui/widgets/ipv8health.py | 149 - src/tribler/gui/widgets/lazytableview.py | 252 - src/tribler/gui/widgets/loading_list_item.py | 18 - src/tribler/gui/widgets/loadingpage.py | 41 - src/tribler/gui/widgets/popular/__init__.py | 0 .../widgets/popular/popular_torrents_model.py | 8 - src/tribler/gui/widgets/qtbug.py | 16 - .../gui/widgets/search_progress_bar.py | 104 - .../gui/widgets/search_results_model.py | 128 - .../gui/widgets/searchresultswidget.py | 151 - src/tribler/gui/widgets/settingspage.py | 343 -- src/tribler/gui/widgets/tabbuttonpanel.py | 44 - .../gui/widgets/tablecontentdelegate.py | 771 --- src/tribler/gui/widgets/tablecontentmodel.py | 622 --- src/tribler/gui/widgets/tableiconbuttons.py | 62 - src/tribler/gui/widgets/tagbutton.py | 31 - src/tribler/gui/widgets/tagslineedit.py | 512 -- src/tribler/gui/widgets/timeoutprogressbar.py | 32 - src/tribler/gui/widgets/togglebutton.py | 133 - .../gui/widgets/torrentfiletreewidget.py | 914 ---- .../gui/widgets/triblertablecontrollers.py | 241 - src/tribler/gui/widgets/underlinetabbutton.py | 16 - 105 files changed, 20527 deletions(-) delete mode 100644 src/tribler/core/start_core.py delete mode 100644 src/tribler/gui/__init__.py delete mode 100644 src/tribler/gui/app_manager.py delete mode 100644 src/tribler/gui/code_executor.py delete mode 100644 src/tribler/gui/core_manager.py delete mode 100644 src/tribler/gui/debug_window.py delete mode 100644 src/tribler/gui/defs.py delete mode 100644 src/tribler/gui/dialogs/__init__.py delete mode 100644 src/tribler/gui/dialogs/confirmationdialog.py delete mode 100644 src/tribler/gui/dialogs/createtorrentdialog.py delete mode 100644 src/tribler/gui/dialogs/dialogcontainer.py delete mode 100644 src/tribler/gui/dialogs/editmetadatadialog.py delete mode 100644 src/tribler/gui/dialogs/feedbackdialog.py delete mode 100644 src/tribler/gui/dialogs/startdownloaddialog.py delete mode 100644 src/tribler/gui/error_handler.py delete mode 100644 src/tribler/gui/event_request_manager.py delete mode 100644 src/tribler/gui/exceptions.py delete mode 100644 src/tribler/gui/images/add.png delete mode 100644 src/tribler/gui/images/browse_folder.svg delete mode 100644 src/tribler/gui/images/check.svg delete mode 100644 src/tribler/gui/images/debug.png delete mode 100644 src/tribler/gui/images/delete.png delete mode 100644 src/tribler/gui/images/down_arrow_input.png delete mode 100644 src/tribler/gui/images/downloads.png delete mode 100644 src/tribler/gui/images/edit_orange.png delete mode 100644 src/tribler/gui/images/edit_white.png delete mode 100644 src/tribler/gui/images/ellipsis.png delete mode 100644 src/tribler/gui/images/fire.png delete mode 100644 src/tribler/gui/images/gear.png delete mode 100644 src/tribler/gui/images/loading_animation.svg delete mode 100644 src/tribler/gui/images/magnet.png delete mode 100644 src/tribler/gui/images/menu.png delete mode 100644 src/tribler/gui/images/minus.svg delete mode 100644 src/tribler/gui/images/monochrome_tribler.png delete mode 100644 src/tribler/gui/images/page_back.png delete mode 100644 src/tribler/gui/images/play.png delete mode 100644 src/tribler/gui/images/plus.svg delete mode 100644 src/tribler/gui/images/stop.png delete mode 100644 src/tribler/gui/images/toggle-checked-disabled.svg delete mode 100644 src/tribler/gui/images/toggle-checked.svg delete mode 100644 src/tribler/gui/images/toggle-unchecked-disabled.svg delete mode 100644 src/tribler/gui/images/toggle-unchecked.svg delete mode 100644 src/tribler/gui/images/toggle-undefined.svg delete mode 100644 src/tribler/gui/images/trash.svg delete mode 100644 src/tribler/gui/images/tribler.png delete mode 100644 src/tribler/gui/images/undo.svg delete mode 100644 src/tribler/gui/images/update.svg delete mode 100644 src/tribler/gui/network/__init__.py delete mode 100644 src/tribler/gui/network/request.py delete mode 100644 src/tribler/gui/network/request_manager.py delete mode 100644 src/tribler/gui/qt_resources/buttonsdialog.ui delete mode 100644 src/tribler/gui/qt_resources/createtorrentdialog.ui delete mode 100644 src/tribler/gui/qt_resources/debugwindow.ui delete mode 100644 src/tribler/gui/qt_resources/edit_metadata_dialog.ui delete mode 100644 src/tribler/gui/qt_resources/feedback_dialog.ui delete mode 100644 src/tribler/gui/qt_resources/loading_list_item.ui delete mode 100644 src/tribler/gui/qt_resources/mainwindow.ui delete mode 100644 src/tribler/gui/qt_resources/qtbug.ui delete mode 100644 src/tribler/gui/qt_resources/search_results.ui delete mode 100644 src/tribler/gui/qt_resources/startdownloaddialog.ui delete mode 100644 src/tribler/gui/qt_resources/torrents_list.ui delete mode 100644 src/tribler/gui/single_application.py delete mode 100644 src/tribler/gui/start_gui.py delete mode 100644 src/tribler/gui/tribler_action_menu.py delete mode 100644 src/tribler/gui/tribler_app.py delete mode 100644 src/tribler/gui/tribler_window.py delete mode 100644 src/tribler/gui/utilities.py delete mode 100644 src/tribler/gui/widgets/__init__.py delete mode 100644 src/tribler/gui/widgets/channelcontentswidget.py delete mode 100644 src/tribler/gui/widgets/circlebutton.py delete mode 100644 src/tribler/gui/widgets/clickable_line_edit.py delete mode 100644 src/tribler/gui/widgets/clickablewidgets.py delete mode 100644 src/tribler/gui/widgets/createtorrentpage.py delete mode 100644 src/tribler/gui/widgets/downloadprogressbar.py delete mode 100644 src/tribler/gui/widgets/downloadsdetailstabwidget.py delete mode 100644 src/tribler/gui/widgets/downloadspage.py delete mode 100644 src/tribler/gui/widgets/downloadwidgetitem.py delete mode 100644 src/tribler/gui/widgets/ellipsebutton.py delete mode 100644 src/tribler/gui/widgets/graphs/__init__.py delete mode 100644 src/tribler/gui/widgets/graphs/dataplot.py delete mode 100644 src/tribler/gui/widgets/graphs/timeseriesplot.py delete mode 100644 src/tribler/gui/widgets/instanttooltipbutton.py delete mode 100644 src/tribler/gui/widgets/instanttooltipstyle.py delete mode 100644 src/tribler/gui/widgets/ipv8health.py delete mode 100644 src/tribler/gui/widgets/lazytableview.py delete mode 100644 src/tribler/gui/widgets/loading_list_item.py delete mode 100644 src/tribler/gui/widgets/loadingpage.py delete mode 100644 src/tribler/gui/widgets/popular/__init__.py delete mode 100644 src/tribler/gui/widgets/popular/popular_torrents_model.py delete mode 100644 src/tribler/gui/widgets/qtbug.py delete mode 100644 src/tribler/gui/widgets/search_progress_bar.py delete mode 100644 src/tribler/gui/widgets/search_results_model.py delete mode 100644 src/tribler/gui/widgets/searchresultswidget.py delete mode 100644 src/tribler/gui/widgets/settingspage.py delete mode 100644 src/tribler/gui/widgets/tabbuttonpanel.py delete mode 100644 src/tribler/gui/widgets/tablecontentdelegate.py delete mode 100644 src/tribler/gui/widgets/tablecontentmodel.py delete mode 100644 src/tribler/gui/widgets/tableiconbuttons.py delete mode 100644 src/tribler/gui/widgets/tagbutton.py delete mode 100644 src/tribler/gui/widgets/tagslineedit.py delete mode 100644 src/tribler/gui/widgets/timeoutprogressbar.py delete mode 100644 src/tribler/gui/widgets/togglebutton.py delete mode 100644 src/tribler/gui/widgets/torrentfiletreewidget.py delete mode 100644 src/tribler/gui/widgets/triblertablecontrollers.py delete mode 100644 src/tribler/gui/widgets/underlinetabbutton.py diff --git a/src/tribler/core/start_core.py b/src/tribler/core/start_core.py deleted file mode 100644 index 486a8f1782..0000000000 --- a/src/tribler/core/start_core.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -import logging.config -from asyncio import run -from typing import TYPE_CHECKING - -from tribler.core.session import Session -from tribler.tribler_config import TriblerConfigManager - -if TYPE_CHECKING: - from pathlib import Path - -logger = logging.getLogger(__name__) - - -async def run_session(config: TriblerConfigManager) -> None: - """ - Start the Session and wait for it to shut itself down. - """ - session = Session(config) - await session.start() - await session.shutdown_event.wait() - await session.shutdown() - - -def run_core(api_port: int, api_key: str | None, state_dir: Path) -> None: - """ - This method will start a new Tribler session. - Note that there is no direct communication between the GUI process and the core: all communication is performed - through the HTTP API. - - Returns an exit code value, which is non-zero if the Tribler session finished with an error. - """ - logger.info("Start tribler core. API port: %d. API key: %s. State dir: %s.", api_port, api_key, state_dir) - - config = TriblerConfigManager(state_dir / "configuration.json") - config.set("state_dir", str(state_dir)) - - if config.get("api/refresh_port_on_start"): - config.set("api/http_port", 0) - config.set("api/https_port", 0) - - if api_key != config.get("api/key"): - config.set("api/key", api_key) - config.write() - - run(run_session(config)) diff --git a/src/tribler/gui/__init__.py b/src/tribler/gui/__init__.py deleted file mode 100644 index 2f4c4ab6f5..0000000000 --- a/src/tribler/gui/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -This package contains the code for the GUI, written in pyQt. -""" diff --git a/src/tribler/gui/app_manager.py b/src/tribler/gui/app_manager.py deleted file mode 100644 index a4b067fbb4..0000000000 --- a/src/tribler/gui/app_manager.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Optional - -from PyQt5.QtWidgets import QApplication - -from tribler.gui.utilities import connect - - -class AppManager: - """ - A helper class that calls QApplication.quit() - - You should never call `QApplication.quit()` directly. Call `app_manager.quit_application()` instead. - It is necessary to avoid runtime errors like "wrapped C/C++ object of type ... has been deleted". - - After `app_manager.quit_application()` was called, it is not safe to access Qt objects anymore. - If a signal can be emitted during the application shutdown, you can check `app_manager.quitting_app` flag - inside the signal handler to be sure that it is still safe to access Qt objects. - """ - - def __init__(self, app: Optional[QApplication] = None): - self.quitting_app = False - if app is not None: - # app can be None in tests where Qt application is not created - connect(app.aboutToQuit, self.on_about_to_quit) - - def on_about_to_quit(self): - self.quitting_app = True - - def quit_application(self): - if not self.quitting_app: - self.quitting_app = True - QApplication.quit() diff --git a/src/tribler/gui/code_executor.py b/src/tribler/gui/code_executor.py deleted file mode 100644 index 062a195126..0000000000 --- a/src/tribler/gui/code_executor.py +++ /dev/null @@ -1,136 +0,0 @@ -import binascii -import logging -import os -import sys -import traceback -from base64 import b64decode, b64encode -from code import InteractiveConsole -from pathlib import Path - -from PyQt5.QtNetwork import QTcpServer - -from tribler.gui.utilities import connect, take_screenshot - - -class CodeExecutor: - """ - This class is responsible for executing code (when starting Tribler in debug mode). - The protocol to execute code is as follows. - First, a client that wants to execute some code opens a connection with the TCP server and sends the - string: \n - This code will be executed and the result will be sent to the client in the following format: - result \n. - If Tribler crashes, the server sends the following result: crash - - Note that the socket uses the newline as separator. - """ - - def __init__(self, port, shell_variables=None): - self.logger = logging.getLogger(self.__class__.__name__) - self.port = port - self.tcp_server = QTcpServer() - self.sockets = [] - self.stack_trace = None - self.shell = Console(locals=shell_variables or {}, logger=self.logger) - self.started = False - - def on_core_connected(self, _): - self.logger.info('Core connected, starting code executor') - - if self.started: - return - - if not self.tcp_server.listen(port=self.port): - self.logger.error("Unable to start code execution socket! Error: %s", self.tcp_server.errorString()) - else: - connect(self.tcp_server.newConnection, self._on_new_connection) - - self.started = True - self.logger.info('Code executor started') - - def _on_new_connection(self): - self.logger.info("CodeExecutor has new connection") - - while self.tcp_server.hasPendingConnections(): - socket = self.tcp_server.nextPendingConnection() - connect(socket.readyRead, self._on_socket_read_ready) - connect(socket.disconnected, self._on_socket_disconnect(socket)) - self.sockets.append(socket) - - # If Tribler has crashed, notify the other side immediately - if self.stack_trace: - self.on_crash(self.stack_trace) - - def run_code(self, code, task_id): - self.logger.info(f"Run code for task {task_id}") - self.logger.debug(f"Code for execution:\n{code}") - - try: - self.shell.runcode(code) - except SystemExit: - pass - - if self.shell.last_traceback: - self.on_crash(f'{self.shell.last_traceback}\n\ntask_id: {task_id!r}\ncode:\n{code}\n\n(end of code)') - return - - self.logger.info("Code execution with task %s finished:", task_id) - - return_value = b64encode(self.shell.locals.get('return_value', '').encode('utf-8')) - for socket in self.sockets: - socket.write(b"result %s %s\n" % (return_value, task_id)) - - def on_crash(self, exception_text): - self.logger.error(f"Crash in CodeExecutor:\n{exception_text}") - - self.stack_trace = exception_text - for socket in self.sockets: - socket.write(b"crash %s\n" % b64encode(exception_text.encode('utf-8'))) - - def _on_socket_read_ready(self): - data = bytes(self.sockets[0].readAll()) - parts = data.split(b" ") - if len(parts) != 2: - return - - try: - code = b64decode(parts[0]).decode('utf8') - except binascii.Error: - self.logger.error("Invalid base64 code string received!") - return - - task_id = parts[1].replace(b'\n', b'') - self.run_code(code, task_id) - - def _on_socket_disconnect(self, socket): - def on_socket_disconnect_handler(): - self.sockets.remove(socket) - - return on_socket_disconnect_handler - - -class Console(InteractiveConsole): - last_traceback = None - - def __init__(self, locals, logger): # pylint: disable=redefined-builtin - super().__init__(locals=locals) - self.logger = logger - - def showtraceback(self) -> None: - last_type, last_value, last_tb = sys.exc_info() - try: - self.last_traceback = ''.join(traceback.format_exception(last_type, last_value, last_tb)) - self.take_screenshot() - super().showtraceback() # report the error to Sentry - finally: - del last_tb - - def take_screenshot(self): - window = self.locals.get('window') - if not window: - self.logger.warning("Cannot take screenshot, window is not found in locals") - else: - app_tester_dir = self.locals.get('app_tester_dir') - screenshots_dir = Path(app_tester_dir or os.getcwd()) / "screenshots" - self.logger.info(f"Creating screenshot in {screenshots_dir}") - take_screenshot(window, screenshots_dir) diff --git a/src/tribler/gui/core_manager.py b/src/tribler/gui/core_manager.py deleted file mode 100644 index 948b600f21..0000000000 --- a/src/tribler/gui/core_manager.py +++ /dev/null @@ -1,273 +0,0 @@ -from __future__ import annotations - -import logging -import os -import re -import sys -import time -from collections import deque -from pathlib import Path -from typing import Optional - -from PyQt5.QtCore import QObject, QProcess, QProcessEnvironment, QTimer -from PyQt5.QtNetwork import QNetworkRequest - -from tribler.gui.app_manager import AppManager -from tribler.gui.event_request_manager import EventRequestManager -from tribler.gui.exceptions import CoreCrashedError -from tribler.gui.network.request_manager import SHUTDOWN_ENDPOINT, request_manager -from tribler.gui.utilities import connect - -API_PORT_CHECK_INTERVAL = 100 # 0.1 seconds between attempts to retrieve Core API port -API_PORT_CHECK_TIMEOUT = 120 # Stop trying to determine API port after this number of seconds - -CORE_OUTPUT_DEQUE_LENGTH = 10 - - -class CoreManager(QObject): - """ - The CoreManager is responsible for managing the Tribler core (starting/stopping). When we are running the GUI tests, - a fake API will be started. - """ - - def __init__(self, root_state_dir: Path, api_port: Optional[int], api_key: str, - app_manager: AppManager, events_manager: EventRequestManager): - QObject.__init__(self, None) - - self._logger = logging.getLogger(self.__class__.__name__) - self.app_manager = app_manager - self.root_state_dir = root_state_dir - self.core_process: Optional[QProcess] = None - self.api_port = api_port - self.api_key = api_key - - self.check_core_api_port_timer = QTimer() - self.check_core_api_port_timer.setSingleShot(True) - connect(self.check_core_api_port_timer.timeout, self.check_core_api_port) - - self.events_manager = events_manager - - self.upgrade_manager = None - self.core_args = None - self.core_env = None - - self.core_started = False - self.core_started_at: Optional[int] = None - self.core_running = False - self.core_connected = False - self.shutting_down = False - self.core_finished = False - - self.should_quit_app_on_core_finished = False - - self.use_existing_core = True - self.last_core_stdout_output: deque = deque(maxlen=CORE_OUTPUT_DEQUE_LENGTH) - self.last_core_stderr_output: deque = deque(maxlen=CORE_OUTPUT_DEQUE_LENGTH) - - connect(self.events_manager.core_connected, self.on_core_connected) - - def on_core_connected(self, _): - if self.core_finished: - self._logger.warning('Core connected after the core process is already finished') - return - - if self.shutting_down: - self._logger.warning('Core connected after the shutting down is already started') - return - - self.core_connected = True - - def start(self, core_args=None, core_env=None, run_core=True): - """ - First test whether we already have a Tribler process listening on port . - If so, use that one and don't start a new, fresh Core. - """ - if run_core: - self.core_args = core_args - self.core_env = core_env - - # Connect to the events manager - self.start_tribler_core() - self.events_manager.connect_to_core(reschedule_on_err=True) - - def start_tribler_core(self): - self.use_existing_core = False - - core_env = self.core_env - if not core_env: - core_env = QProcessEnvironment.systemEnvironment() - core_env.insert("CORE_API_KEY", self.api_key) - core_env.insert("TSTATEDIR", str(self.root_state_dir)) - core_env.insert("TRIBLER_GUI_PID", str(os.getpid())) - - core_args = self.core_args - if not core_args: - core_args = sys.argv + ['--core'] - if getattr(sys, 'frozen', False): - # remove duplicate tribler.exe from core_args when running complied binary - # https://pyinstaller.org/en/v3.3.1/runtime-information.html#using-sys-executable-and-sys-argv-0 - core_args = core_args[1:] - - self.core_process = QProcess() - self.core_process.setProcessEnvironment(core_env) - self.core_process.setProcessChannelMode(QProcess.SeparateChannels) - connect(self.core_process.started, self.on_core_started) - connect(self.core_process.readyReadStandardOutput, self.on_core_stdout_read_ready) - connect(self.core_process.readyReadStandardError, self.on_core_stderr_read_ready) - connect(self.core_process.finished, self.on_core_finished) - self._logger.info(f'Start Tribler core process {sys.executable} with arguments: {core_args}') - self.core_process.start(sys.executable, core_args) - - def on_core_started(self): - self._logger.info("Core process started") - self.core_started = True - self.core_started_at = time.time() - self.core_running = True - self.check_core_api_port() - - def check_core_api_port(self, *args): - """ - Determines the actual REST API port of the Core process. - - This function is first executed from the `on_core_started` after the physical Core process starts and then - repeatedly executed after API_PORT_CHECK_INTERVAL milliseconds until it retrieves the REST API port value from - the Core process. Shortly after the Core process starts, it adds itself to a process database. At that moment, - the api_port value in the database is not specified yet for the Core process. Then the Core REST manager finds - a suitable port and sets the api_port value in the process database. After that, the `check_core_api_port` - method retrieves the api_port value from the database and asks EventRequestManager to connect to that port. - """ - if not self.core_running or self.core_connected or self.shutting_down: - return - - self.events_manager.connect_to_core(reschedule_on_err=True) - - def on_core_stdout_read_ready(self): - if self.app_manager.quitting_app: - # Reading at this stage can lead to the error "wrapped C/C++ object of type QProcess has been deleted" - return - - raw_output = bytes(self.core_process.readAllStandardOutput()) - output = self.decode_raw_core_output(raw_output).strip() - self.last_core_stdout_output.append(output) - - try: - print(output) # print core output # noqa: T001 - except OSError: - # Possible reason - cannot write to stdout as it was already closed during the application shutdown - pass - - def on_core_stderr_read_ready(self): - if self.app_manager.quitting_app: - # Reading at this stage can lead to the error "wrapped C/C++ object of type QProcess has been deleted" - return - - raw_output = bytes(self.core_process.readAllStandardError()) - output = self.decode_raw_core_output(raw_output).strip() - self.last_core_stderr_output.append(output) - - try: - print(output, file=sys.stderr) # print core output # noqa: T001 - except OSError: - # Possible reason - cannot write to stdout as it was already closed during the application shutdown - pass - - def stop(self, quit_app_on_core_finished=True): - if quit_app_on_core_finished: - self.should_quit_app_on_core_finished = True - - if self.shutting_down: - return - - self.shutting_down = True - self._logger.info("Stopping Core manager") - - if self.core_process and not self.core_finished: - if not self.core_connected: - # If Core is not connected via events_manager it also most probably cannot process API requests. - self._logger.warning('Core is not connected during the CoreManager shutdown, killing it...') - self.kill_core_process() - return - - self.events_manager.shutting_down = True - - def shutdown_request_processed(response): - self._logger.info(f"{SHUTDOWN_ENDPOINT} request was processed by Core. Response: {response}") - - def send_shutdown_request(initial=False): - if initial: - self._logger.info(f"Sending {SHUTDOWN_ENDPOINT} request to Tribler Core") - else: - self._logger.warning(f"Re-sending {SHUTDOWN_ENDPOINT} request to Tribler Core") - - request = request_manager.put( - endpoint=SHUTDOWN_ENDPOINT, - on_success=shutdown_request_processed, - priority=QNetworkRequest.HighPriority - ) - if request: - request.cancellable = False - - send_shutdown_request(initial=True) - - elif self.should_quit_app_on_core_finished: - self._logger.info('Core is not running, quitting GUI application') - self.app_manager.quit_application() - - def kill_core_process(self): - if not self.core_process: - self._logger.warning("Cannot kill the Core process as it is not initialized") - - self.core_process.kill() - finished = self.core_process.waitForFinished() - if not finished: - self._logger.error('Cannot kill the core process') - - def get_last_core_output(self, quoted=True): - output = ''.join(self.last_core_stderr_output) or ''.join(self.last_core_stdout_output) - if quoted: - output = re.sub(r'^', '> ', output, flags=re.MULTILINE) - return output - - @staticmethod - def format_error_message(exit_code: int, exit_status: int) -> str: - message = f"The Tribler core has unexpectedly finished with exit code {exit_code} and status: {exit_status}." - if exit_code == 1: - string_error = "Application error" - else: - try: - string_error = os.strerror(exit_code) - except ValueError: - # On platforms where strerror() returns NULL when given an unknown error number, ValueError is raised. - string_error = 'unknown error number' - message += f'\n\nError message: {string_error}' - return message - - def on_core_finished(self, exit_code, exit_status): - self._logger.info("Core process finished") - self.core_running = False - self.core_connected = False - self.core_finished = True - if self.shutting_down: - if self.should_quit_app_on_core_finished: - self.app_manager.quit_application() - else: - error_message = self.format_error_message(exit_code, exit_status) - self._logger.warning(error_message) - - if not self.app_manager.quitting_app: - # Stop the event manager loop if it is running - if self.events_manager.connect_timer and self.events_manager.connect_timer.isActive(): - self.events_manager.connect_timer.stop() - - raise CoreCrashedError(error_message) - - @staticmethod - def decode_raw_core_output(output: bytes) -> str: - try: - # Let's optimistically try to decode from UTF8. - # If it is not UTF8, we should get UnicodeDecodeError "invalid continuation byte". - return output.decode('utf-8') - except UnicodeDecodeError: - # It may be hard to guess the real encoding on some systems, - # but by using the "backslashreplace" error handler we can keep all the received data. - return output.decode('ascii', errors='backslashreplace') diff --git a/src/tribler/gui/debug_window.py b/src/tribler/gui/debug_window.py deleted file mode 100644 index 650fc5afb5..0000000000 --- a/src/tribler/gui/debug_window.py +++ /dev/null @@ -1,826 +0,0 @@ -import datetime -import json -import logging -import os -import socket -import sys -from binascii import unhexlify -from time import localtime, strftime, time - -import libtorrent -from PyQt5 import QtGui, uic -from PyQt5.QtCore import QTimer, Qt, pyqtSignal -from PyQt5.QtGui import QBrush, QColor -from PyQt5.QtWidgets import QDesktopWidget, QFileDialog, QMainWindow, QMessageBox, QTreeWidgetItem - -from tribler.gui.defs import DEBUG_PANE_REFRESH_TIMEOUT, GB, MB -from tribler.gui.dialogs.confirmationdialog import ConfirmationDialog -from tribler.gui.event_request_manager import received_events as tribler_received_events -from tribler.gui.network.request import Request -from tribler.gui.network.request_manager import request_manager -from tribler.gui.utilities import connect, format_size, get_ui_file_path -from tribler.gui.widgets.graphs.timeseriesplot import TimeSeriesPlot -from tribler.gui.widgets.ipv8health import MonitorWidget - -COLOR_RGB_BLUE = (0, 153, 255) -COLOR_WHITE_HEX = "#FFFFFF" - - -class MemoryPlot(TimeSeriesPlot): - def __init__(self, parent, process='CPU', **kargs): - series = [ - {'name': f'Memory ({process})', 'pen': COLOR_RGB_BLUE, 'symbolBrush': COLOR_RGB_BLUE, 'symbolPen': 'w'} - ] - super().__init__(parent, f'Memory Usage({process})', series, **kargs) - self.setBackground(COLOR_WHITE_HEX) - self.setLabel('left', 'Memory', units='MB') - self.setLimits(yMin=0, yMax=2 * GB) - - -class CPUPlot(TimeSeriesPlot): - def __init__(self, parent, process='Core', **kargs): - series = [{'name': f'CPU ({process})', 'pen': COLOR_RGB_BLUE, 'symbolBrush': COLOR_RGB_BLUE, 'symbolPen': 'w'}] - super().__init__(parent, f'CPU Usage ({process})', series, **kargs) - self.setBackground(COLOR_WHITE_HEX) - self.setLabel('left', 'CPU', units='%') - self.setLimits(yMin=-10, yMax=200) - - -class DebugWindow(QMainWindow): - """ - The debug window shows various statistics about Tribler such as performed requests, IPv8 statistics and - community information. - """ - - resize_event = pyqtSignal() - - def __init__(self, settings, gui_settings, tribler_version): - self._logger = logging.getLogger(self.__class__.__name__) - QMainWindow.__init__(self) - - self.core_cpu_plot = None - self.gui_cpu_plot = None - self.initialized_cpu_plot = False - self.cpu_plot_timer = None - - self.core_memory_plot = None - self.gui_memory_plot = None - self.initialized_memory_plot = False - self.memory_plot_timer = None - - self.tribler_version = tribler_version - self.profiler_enabled = False - self.toggling_profiler = False - - uic.loadUi(get_ui_file_path('debugwindow.ui'), self) - self.setWindowTitle("Tribler debug pane") - - self.window().debug_tab_widget.setCurrentIndex(0) - self.window().ipv8_tab_widget.setCurrentIndex(0) - self.window().tunnel_tab_widget.setCurrentIndex(0) - self.window().dht_tab_widget.setCurrentIndex(0) - connect(self.window().debug_tab_widget.currentChanged, self.tab_changed) - connect(self.window().ipv8_tab_widget.currentChanged, self.ipv8_tab_changed) - connect(self.window().communities_tree_widget.itemClicked, self.on_community_clicked) - connect(self.window().tunnel_tab_widget.currentChanged, self.tunnel_tab_changed) - connect(self.window().dht_tab_widget.currentChanged, self.dht_tab_changed) - connect(self.window().events_tree_widget.itemClicked, self.on_event_clicked) - self.load_general_tab() - - self.window().community_peers_tree_widget.hide() - - # Enable/disable tabs, based on settings - self.window().debug_tab_widget.setTabEnabled(2, settings is not None) - self.window().debug_tab_widget.setTabEnabled(3, settings is not None) - - # IPv8 statistics enabled? - self.ipv8_statistics_enabled = settings['statistics'] - - # Libtorrent tab - self.init_libtorrent_tab() - - # Position to center - frame_geometry = self.frameGeometry() - screen = QDesktopWidget().screenNumber(QDesktopWidget().cursor().pos()) - center_point = QDesktopWidget().screenGeometry(screen).center() - frame_geometry.moveCenter(center_point) - self.move(frame_geometry.topLeft()) - - # Refresh timer - self.refresh_timer = None - self.rest_request = None - self.ipv8_health_widget = None - - # QT settings - self.gui_settings = gui_settings - - def hideEvent(self, hide_event): - self.stop_timer() - self.hide_ipv8_health_widget() - - def showEvent(self, show_event): - if self.ipv8_health_widget and self.ipv8_health_widget.isVisible(): - self.ipv8_health_widget.resume() - request_manager.put("ipv8/asyncio/drift", self.on_ipv8_health_enabled, data={"enable": True}) - - def run_with_timer(self, call_fn, timeout=DEBUG_PANE_REFRESH_TIMEOUT): - call_fn() - self.stop_timer() - self.refresh_timer = QTimer() - self.refresh_timer.setSingleShot(True) - connect( - self.refresh_timer.timeout, - lambda _call_fn=call_fn, _timeout=timeout: self.run_with_timer(_call_fn, timeout=_timeout), - ) - self.refresh_timer.start(timeout) - - def stop_timer(self): - if self.refresh_timer: - try: - self.refresh_timer.stop() - self.refresh_timer.deleteLater() - except RuntimeError: - self._logger.error("Failed to stop refresh timer in Debug pane") - - def init_libtorrent_tab(self): - self.window().libtorrent_tab_widget.setCurrentIndex(0) - connect(self.window().libtorrent_tab_widget.currentChanged, lambda _: self.load_libtorrent_data(export=False)) - - connect(self.window().lt_zero_hop_btn.clicked, lambda _: self.load_libtorrent_data(export=False)) - connect(self.window().lt_one_hop_btn.clicked, lambda _: self.load_libtorrent_data(export=False)) - connect(self.window().lt_two_hop_btn.clicked, lambda _: self.load_libtorrent_data(export=False)) - connect(self.window().lt_three_hop_btn.clicked, lambda _: self.load_libtorrent_data(export=False)) - connect(self.window().lt_export_btn.clicked, lambda _: self.load_libtorrent_data(export=True)) - - self.window().lt_zero_hop_btn.setChecked(True) - - def tab_changed(self, index): - if index == 0: - self.load_general_tab() - elif index == 1: - self.load_requests_tab() - elif index == 3: - self.ipv8_tab_changed(self.window().ipv8_tab_widget.currentIndex()) - elif index == 4: - self.tunnel_tab_changed(self.window().tunnel_tab_widget.currentIndex()) - elif index == 5: - self.dht_tab_changed(self.window().dht_tab_widget.currentIndex()) - elif index == 6: - self.run_with_timer(self.load_events_tab) - elif index == 7: - self.load_libtorrent_data() - - def ipv8_tab_changed(self, index): - if index == 0: - self.run_with_timer(self.load_ipv8_general_tab) - elif index == 1: - self.run_with_timer(self.load_ipv8_communities_tab) - elif index == 2: - self.run_with_timer(self.load_ipv8_community_details_tab) - elif index == 3: - self.run_with_timer(self.load_ipv8_health_monitor) - - def tunnel_tab_changed(self, index): - if index == 0: - self.run_with_timer(self.load_tunnel_circuits_tab) - elif index == 1: - self.run_with_timer(self.load_tunnel_relays_tab) - elif index == 2: - self.run_with_timer(self.load_tunnel_exits_tab) - elif index == 3: - self.run_with_timer(self.load_tunnel_swarms_tab) - elif index == 4: - self.run_with_timer(self.load_tunnel_peers_tab) - - def dht_tab_changed(self, index): - if index == 0: - self.run_with_timer(self.load_dht_statistics_tab) - elif index == 1: - self.run_with_timer(self.load_dht_buckets_tab) - - def system_tab_changed(self, index): - if index == 0: - self.load_open_files_tab() - elif index == 1: - self.load_open_sockets_tab() - elif index == 2: - self.load_threads_tab() - elif index == 3: - self.load_cpu_tab() - elif index == 4: - self.load_memory_tab() - elif index == 5: - self.load_profiler_tab() - - def create_and_add_widget_item(self, key, value, widget): - item = QTreeWidgetItem(widget) - item.setText(0, key) - item.setText(1, f"{value}") - widget.addTopLevelItem(item) - - def load_general_tab(self): - request_manager.get("statistics/tribler", self.on_tribler_statistics) - - def on_tribler_statistics(self, data): - if not data: - return - data = data["tribler_statistics"] - self.window().general_tree_widget.clear() - self.create_and_add_widget_item("Tribler version", self.tribler_version, self.window().general_tree_widget) - self.create_and_add_widget_item( - "Python version", sys.version.replace('\n', ''), self.window().general_tree_widget # to fit in one line - ) - self.create_and_add_widget_item("Libtorrent version", libtorrent.version, self.window().general_tree_widget) - self.create_and_add_widget_item("", "", self.window().general_tree_widget) - - self.create_and_add_widget_item( - "Database size", format_size(data["db_size"]), self.window().general_tree_widget - ) - self.create_and_add_widget_item( - "Number of known torrents", data["num_torrents"], self.window().general_tree_widget - ) - self.create_and_add_widget_item("", "", self.window().general_tree_widget) - - # Show GUI settings - self.show_gui_settings() - - def show_gui_settings(self): - # Empty line at the beginning - self.create_and_add_widget_item("", "", self.window().general_tree_widget) - # Heading: GUI Settings - self.create_and_add_widget_item("GUI Settings:", "", self.window().general_tree_widget) - # Location of the settings file - self.create_and_add_widget_item("Qt file", self.gui_settings.fileName(), self.window().general_tree_widget) - - selected_settings = { - "api_key": lambda val: val, - "api_port": lambda val: val, - "pos": lambda val: f"(x : {val.x()} px, y : {val.y()} px)", - "size": lambda val: f"(width : {val.width()} px, height : {val.height()} px)", - "ask_download_settings": lambda val: val, - "autocommit_enabled": lambda val: val, - "debug": lambda val: val, - "family_filter": lambda val: val, - "first_discover": lambda val: val, - "use_monochrome_icon": lambda val: val, - "recent_download_locations": lambda val: [unhexlify(url).decode('utf-8') for url in val.split(",")], - } - - # List only selected gui settings - for key in self.gui_settings.allKeys(): - if key in selected_settings: - value = selected_settings[key](self.gui_settings.value(key, 'N/A')) - self.create_and_add_widget_item(key, value, self.window().general_tree_widget) - - def load_requests_tab(self): - self.window().requests_tree_widget.clear() - for request in request_manager.performed_requests: - endpoint = request.endpoint - method = request.method - data = request.data - timestamp = request.time - - item = QTreeWidgetItem(self.window().requests_tree_widget) - item.setText(0, f"{method} {repr(endpoint)} {repr(data)}") - item.setText(1, request.status_text) - item.setText(2, f"{strftime('%H:%M:%S', localtime(timestamp))}") - self.window().requests_tree_widget.addTopLevelItem(item) - - def load_ipv8_general_tab(self): - request_manager.get("statistics/ipv8", self.on_ipv8_general_stats) - - def on_ipv8_general_stats(self, data): - if not data: - return - self.window().ipv8_general_tree_widget.clear() - for key, value in data["ipv8_statistics"].items(): - if key in ('total_upload', 'total_download'): - value = f"{value / (1024.0 * 1024.0):.2f} MB" - elif key == 'session_uptime': - value = f"{str(datetime.timedelta(seconds=int(value)))}" - self.create_and_add_widget_item(key, value, self.window().ipv8_general_tree_widget) - - def load_ipv8_communities_tab(self): - request_manager.get("ipv8/overlays", self.on_ipv8_community_stats) - - def _colored_peer_count(self, peer_count, max_peers): - limits = [20, max_peers + 1] - color = 0xF4D03F if peer_count < limits[0] else (0x56F129 if peer_count < limits[1] else 0xF12929) - return QBrush(QColor(color)) - - def on_ipv8_community_stats(self, data): - if not data: - return - - for overlay in data["overlays"]: - item = None - item_exists = False - - # Check if this item is already rendered - for ind in range(self.window().communities_tree_widget.topLevelItemCount()): - existing_item = self.window().communities_tree_widget.topLevelItem(ind) - if existing_item.data(0, Qt.UserRole)["id"] == overlay["id"]: - item = existing_item - item_exists = True - break - - if not item: - # Create a new one - item = QTreeWidgetItem(self.window().communities_tree_widget) - - item.setData(0, Qt.UserRole, overlay) - item.setText(0, overlay["overlay_name"]) - item.setText(1, overlay["id"][:10]) - item.setText(2, overlay["my_peer"][-12:]) - peer_count = len(overlay["peers"]) - item.setText(3, f"{peer_count}") - item.setForeground(3, self._colored_peer_count(peer_count, overlay["max_peers"])) - - if "statistics" in overlay and overlay["statistics"]: - statistics = overlay["statistics"] - item.setText(4, f"{statistics['bytes_up'] / (1024.0 * 1024.0):.3f}") - item.setText(5, f"{statistics['bytes_down'] / (1024.0 * 1024.0):.3f}") - item.setText(6, f"{statistics['num_up']}") - item.setText(7, f"{statistics['num_down']}") - item.setText(8, f"{statistics['diff_time']:.3f}") - else: - item.setText(4, "N/A") - item.setText(5, "N/A") - item.setText(6, "N/A") - item.setText(7, "N/A") - item.setText(8, "N/A") - - if not item_exists: - self.window().communities_tree_widget.addTopLevelItem(item) - map(self.window().communities_tree_widget.resizeColumnToContents, range(10)) - - # Reload the window with peers - selected_items = self.window().communities_tree_widget.selectedItems() - if len(selected_items) > 0: - self.update_community_peers(selected_items[0]) - - def on_community_clicked(self, item, _): - self.window().community_peers_tree_widget.show() - self.update_community_peers(item) - - def update_community_peers(self, item): - self.window().community_peers_tree_widget.clear() - peers_info = item.data(0, Qt.UserRole)["peers"] - - for peer_info in peers_info: - item = QTreeWidgetItem(self.window().community_peers_tree_widget) - item.setText(0, peer_info["ip"]) - item.setText(1, f"{peer_info['port']}") - item.setText(2, peer_info["public_key"]) - self.window().community_peers_tree_widget.addTopLevelItem(item) - - def load_ipv8_community_details_tab(self): - if self.ipv8_statistics_enabled: - self.window().ipv8_statistics_error_label.setHidden(True) - request_manager.get("ipv8/overlays/statistics", self.on_ipv8_community_detail_stats) - else: - self.window().ipv8_statistics_error_label.setHidden(False) - self.window().ipv8_communities_details_widget.setHidden(True) - - def on_ipv8_community_detail_stats(self, data): - if not data: - return - - self.window().ipv8_communities_details_widget.setHidden(False) - self.window().ipv8_communities_details_widget.clear() - for overlay in data["statistics"]: - self.window().ipv8_communities_details_widget.setColumnWidth(0, 250) - - for key, stats in overlay.items(): - header_item = QTreeWidgetItem(self.window().ipv8_communities_details_widget) - header_item.setFirstColumnSpanned(True) - header_item.setBackground(0, QtGui.QColor('#CCCCCC')) - header_item.setText(0, key) - self.window().ipv8_communities_details_widget.addTopLevelItem(header_item) - - for request_id, stat in stats.items(): - stat_item = QTreeWidgetItem(self.window().ipv8_communities_details_widget) - stat_item.setText(0, request_id) - stat_item.setText(1, f"{stat['bytes_up'] / (1024.0 * 1024.0):.3f}") - stat_item.setText(2, f"{stat['bytes_down'] / (1024.0 * 1024.0):.3f}") - stat_item.setText(3, f"{stat['num_up']}") - stat_item.setText(4, f"{stat['num_down']}") - self.window().ipv8_communities_details_widget.addTopLevelItem(stat_item) - - def load_ipv8_health_monitor(self): - """ - Lazy load and enable the IPv8 core health monitor. - """ - if self.ipv8_health_widget is None: - # Add the core monitor widget to the tab widget. - from PyQt5.QtWidgets import QVBoxLayout - - widget = MonitorWidget() - layout = QVBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(widget) - self.window().ipv8_health_monitor_widget.setLayout(layout) - self.window().ipv8_health_monitor_widget.show() - self.ipv8_health_widget = widget - else: - # We already loaded the widget, just resume it. - self.ipv8_health_widget.resume() - # Whether the widget is newly loaded or not, start the measurements. - request_manager.put("ipv8/asyncio/drift", self.on_ipv8_health_enabled, data={"enable": True}) - - def hide_ipv8_health_widget(self): - """ - We need to hide the IPv8 health widget, involving two things: - - 1. Stop the smooth graphical updates in the widget. - 2. Remove the observer from the IPv8 core. - """ - if self.ipv8_health_widget is not None and not self.ipv8_health_widget.is_paused: - self.ipv8_health_widget.pause() - request_manager.put("ipv8/asyncio/drift", data={"enable": False}) - - def on_ipv8_health(self, data): - """ - Measurements came in, send them to the widget for "plotting". - """ - if not data or 'measurements' not in data or self.ipv8_health_widget is None: - return - self.ipv8_health_widget.set_history(data['measurements']) - - def on_ipv8_health_enabled(self, data): - """ - The request to enable IPv8 completed. - - Start requesting measurements. - """ - if not data: - return - - def send_request(): - request_manager.get("ipv8/asyncio/drift", self.on_ipv8_health) - - self.run_with_timer(send_request, 100) - - def add_items_to_tree(self, tree, items, keys): - tree.clear() - for item in items: - widget_item = QTreeWidgetItem(tree) - for index, key in enumerate(keys): - if key in ["bytes_up", "bytes_down"]: - value = format_size(item[key]) - elif key in ["creation_time", "last_lookup"]: - value = str(datetime.timedelta(seconds=int(time() - item[key]))) if item[key] > 0 else '-' - else: - value = str(item[key]) - widget_item.setText(index, value) - tree.addTopLevelItem(widget_item) - - def load_tunnel_circuits_tab(self): - self.window().circuits_tree_widget.setColumnWidth(3, 200) - request_manager.get("ipv8/tunnel/circuits", self.on_tunnel_circuits) - - def on_tunnel_circuits(self, circuits): - if not circuits: - return - - for c in circuits["circuits"]: - c["hops"] = f"{c['actual_hops']} / {c['goal_hops']}" - c["exit_flags"] = c["exit_flags"] if c["state"] == "READY" else "" - - self.add_items_to_tree( - self.window().circuits_tree_widget, - circuits.get("circuits"), - ["circuit_id", "hops", "type", "state", "bytes_up", "bytes_down", "creation_time", "exit_flags"], - ) - - def load_tunnel_relays_tab(self): - request_manager.get("ipv8/tunnel/relays", self.on_tunnel_relays) - - def on_tunnel_relays(self, data): - if data: - self.add_items_to_tree( - self.window().relays_tree_widget, - data["relays"], - ["circuit_from", "circuit_to", "is_rendezvous", "bytes_up", "bytes_down", "creation_time"], - ) - - def load_tunnel_exits_tab(self): - request_manager.get("ipv8/tunnel/exits", self.on_tunnel_exits) - - def on_tunnel_exits(self, data): - if data: - self.add_items_to_tree( - self.window().exits_tree_widget, - data["exits"], - ["circuit_from", "enabled", "bytes_up", "bytes_down", "creation_time"], - ) - - def load_tunnel_swarms_tab(self): - request_manager.get("ipv8/tunnel/swarms", self.on_tunnel_swarms) - - def on_tunnel_swarms(self, data): - if data: - self.add_items_to_tree( - self.window().swarms_tree_widget, - data.get("swarms"), - [ - "info_hash", - "num_seeders", - "num_connections", - "num_connections_incomplete", - "seeding", - "last_lookup", - "bytes_up", - "bytes_down", - ], - ) - - def load_tunnel_peers_tab(self): - self.window().peers_tree_widget.setColumnWidth(2, 300) - request_manager.get("ipv8/tunnel/peers", self.on_tunnel_peers) - - def on_tunnel_peers(self, data): - if data: - self.add_items_to_tree( - self.window().peers_tree_widget, data.get("peers"), ["ip", "port", "mid", "is_key_compatible", "flags"] - ) - - def load_dht_statistics_tab(self): - request_manager.get("ipv8/dht/statistics", self.on_dht_statistics) - - def on_dht_statistics(self, data): - if not data: - return - self.window().dhtstats_tree_widget.clear() - for key, value in data["statistics"].items(): - self.create_and_add_widget_item(key, value, self.window().dhtstats_tree_widget) - - def load_dht_buckets_tab(self): - request_manager.get("ipv8/dht/buckets", self.on_dht_buckets) - - def on_dht_buckets(self, data): - if data: - for bucket in data["buckets"]: - bucket["num_peers"] = len(bucket["peers"]) - ts = bucket["last_changed"] - bucket["last_changed"] = str(datetime.timedelta(seconds=int(time() - ts))) if ts > 0 else '-' - self.add_items_to_tree( - self.window().buckets_tree_widget, - data.get("buckets"), - ["prefix", "last_changed", "num_peers"], - ) - - def on_event_clicked(self, item, _): - event_dict = item.data(0, Qt.UserRole) - self.window().event_text_box.setPlainText(json.dumps(event_dict)) - - def load_events_tab(self): - self.window().events_tree_widget.clear() - for event_dict, timestamp in tribler_received_events: - item = QTreeWidgetItem(self.window().events_tree_widget) - item.setData(0, Qt.UserRole, event_dict) - item.setText(0, f"{event_dict.get('topic', '')}") - item.setText(1, f"{strftime('%H:%M:%S', localtime(timestamp))}") - self.window().events_tree_widget.addTopLevelItem(item) - - def on_core_open_files(self, data): - if not data: - return - core_item = QTreeWidgetItem(self.window().open_files_tree_widget) - core_item.setText(0, "Core (%d)" % len(data["open_files"])) - self.window().open_files_tree_widget.addTopLevelItem(core_item) - - for open_file in data["open_files"]: - item = QTreeWidgetItem() - item.setText(0, open_file["path"]) - item.setText(1, "%d" % open_file["fd"]) - core_item.addChild(item) - - def load_open_sockets_tab(self): - request_manager.get("debug/open_sockets", self.on_core_open_sockets) - - def on_core_open_sockets(self, data): - if not data: - return - self.window().open_sockets_tree_widget.clear() - self.window().open_sockets_label.setText("Sockets opened by core (%d):" % len(data["open_sockets"])) - for open_socket in data["open_sockets"]: - if open_socket["family"] == socket.AF_INET: - family = "AF_INET" - elif open_socket["family"] == socket.AF_INET6: - family = "AF_INET6" - elif open_socket["family"] == socket.AF_UNIX: - family = "AF_UNIX" - else: - family = "-" - - item = QTreeWidgetItem(self.window().open_sockets_tree_widget) - item.setText(0, open_socket["laddr"]) - item.setText(1, open_socket["raddr"]) - item.setText(2, family) - item.setText(3, "SOCK_STREAM" if open_socket["type"] == socket.SOCK_STREAM else "SOCK_DGRAM") - item.setText(4, open_socket["status"]) - self.window().open_sockets_tree_widget.addTopLevelItem(item) - - def load_threads_tab(self): - request_manager.get("debug/threads", self.on_core_threads) - - def on_core_threads(self, data): - if not data: - return - self.window().threads_tree_widget.clear() - for thread_info in data["threads"]: - thread_item = QTreeWidgetItem(self.window().threads_tree_widget) - thread_item.setText(0, "%d" % thread_info["thread_id"]) - thread_item.setText(1, thread_info["thread_name"]) - self.window().threads_tree_widget.addTopLevelItem(thread_item) - - for frame in thread_info["frames"]: - frame_item = QTreeWidgetItem() - frame_item.setText(2, frame) - thread_item.addChild(frame_item) - - def load_cpu_tab(self): - if not self.initialized_cpu_plot: - self.core_cpu_plot = CPUPlot(self.window().tab_system_cpu, process='Core') - self.gui_cpu_plot = CPUPlot(self.window().tab_system_cpu, process='GUI') - - vlayout = self.window().system_cpu_layout.layout() - vlayout.addWidget(self.core_cpu_plot) - vlayout.addWidget(self.gui_cpu_plot) - - self.initialized_cpu_plot = True - - self.refresh_cpu_plot() - - # Start timer - self.cpu_plot_timer = QTimer() - connect(self.cpu_plot_timer.timeout, self.load_cpu_tab) - self.cpu_plot_timer.start(5000) - - def refresh_cpu_plot(self): - # To update the core CPU graph, call Debug REST API to get the history - # and update the CPU graph after receiving the response. - request_manager.get("debug/cpu/history", self.on_core_cpu_history) - - # GUI CPU graph can be simply updated using the data from GuiResourceMonitor object. - self._update_cpu_graph(self.gui_cpu_plot, self.resource_monitor.get_cpu_history_dict()) - - def on_core_cpu_history(self, data): - if not data or "cpu_history" not in data: - return - - self._update_cpu_graph(self.core_cpu_plot, data['cpu_history']) - - def _update_cpu_graph(self, cpu_graph, history_data): - cpu_graph.reset_plot() - for cpu_info in history_data: - process_cpu = [round(cpu_info["cpu"], 2)] - cpu_graph.add_data(cpu_info["time"], process_cpu) - cpu_graph.render_plot() - - def load_memory_tab(self): - if not self.initialized_memory_plot: - self.core_memory_plot = MemoryPlot(self.window().tab_system_memory, process='Core') - self.gui_memory_plot = MemoryPlot(self.window().tab_system_memory, process='GUI') - - vlayout = self.window().system_memory_layout.layout() - vlayout.addWidget(self.core_memory_plot) - vlayout.addWidget(self.gui_memory_plot) - - self.initialized_memory_plot = True - - self.refresh_memory_plot() - - # Start timer - self.memory_plot_timer = QTimer() - connect(self.memory_plot_timer.timeout, self.load_memory_tab) - self.memory_plot_timer.start(5000) - - def load_profiler_tab(self): - self.window().toggle_profiler_button.setEnabled(False) - request_manager.get("debug/profiler", self.on_profiler_info) - - def on_profiler_info(self, data): - if not data: - return - self.window().toggle_profiler_button.setEnabled(True) - self.profiler_enabled = data["state"] == "STARTED" - self.window().toggle_profiler_button.setText(f"{'Stop' if self.profiler_enabled else 'Start'} profiler") - - def on_toggle_profiler_button_clicked(self, checked=False): - if self.toggling_profiler: - return - - self.toggling_profiler = True - self.window().toggle_profiler_button.setEnabled(False) - method = "DELETE" if self.profiler_enabled else "PUT" - request = Request("debug/profiler", self.on_profiler_state_changed, method=method) - request_manager.add(request) - - def on_profiler_state_changed(self, data): - if not data: - return - self.toggling_profiler = False - self.window().toggle_profiler_button.setEnabled(True) - self.load_profiler_tab() - - if 'profiler_file' in data: - QMessageBox.about( - self, "Profiler statistics saved", f"The profiler data has been saved to {data['profiler_file']}." - ) - - def refresh_memory_plot(self): - # To update the core memory graph, call Debug REST API to get the history - # and update the memory graph after receiving the response. - request_manager.get("debug/memory/history", self.on_core_memory_history) - - # GUI memory graph can be simply updated using the data from GuiResourceMonitor object. - self._update_memory_graph(self.gui_memory_plot, self.resource_monitor.get_memory_history_dict()) - - def on_core_memory_history(self, data): - if not data or data.get("memory_history") is None: - return - self._update_memory_graph(self.core_memory_plot, data["memory_history"]) - - def _update_memory_graph(self, memory_graph, history_data): - memory_graph.reset_plot() - for mem_info in history_data: - process_memory = round(mem_info["mem"] / MB, 2) - memory_graph.add_data(mem_info["time"], [process_memory]) - memory_graph.render_plot() - - def closeEvent(self, close_event): - if self.rest_request: - self.rest_request.cancel() - if self.cpu_plot_timer: - self.cpu_plot_timer.stop() - - if self.memory_plot_timer: - self.memory_plot_timer.stop() - - def show(self): - super().show() - - # this will remove minimized status - # and restore window with keeping maximized/normal state - self.window().setWindowState(self.window().windowState() & ~Qt.WindowMinimized | Qt.WindowActive) - self.window().activateWindow() - - def load_libtorrent_data(self, export=False): - tab = self.window().libtorrent_tab_widget.currentIndex() - hop = ( - 0 - if self.window().lt_zero_hop_btn.isChecked() - else 1 - if self.window().lt_one_hop_btn.isChecked() - else 2 - if self.window().lt_two_hop_btn.isChecked() - else 3 - ) - if tab == 0: - self.load_libtorrent_settings_tab(hop, export=export) - elif tab == 1: - self.load_libtorrent_sessions_tab(hop, export=export) - - def load_libtorrent_settings_tab(self, hop, export=False): - request_manager.get(endpoint=f"libtorrent/settings?hop={hop}", - on_success=lambda data: self.on_libtorrent_settings_received(data, export=export)) - self.window().libtorrent_settings_tree_widget.clear() - - def on_libtorrent_settings_received(self, data, export=False): - if not data: - return - for key, value in data["settings"].items(): - item = QTreeWidgetItem(self.window().libtorrent_settings_tree_widget) - item.setText(0, key) - item.setText(1, str(value)) - self.window().libtorrent_settings_tree_widget.addTopLevelItem(item) - if export: - self.save_to_file("libtorrent_settings.json", data) - - def load_libtorrent_sessions_tab(self, hop, export=False): - request_manager.get(endpoint=f"libtorrent/session?hop={hop}", - on_success=lambda data: self.on_libtorrent_session_received(data, export=export)) - self.window().libtorrent_session_tree_widget.clear() - - def on_libtorrent_session_received(self, data, export=False): - if not data: - return - for key, value in data["session"].items(): - item = QTreeWidgetItem(self.window().libtorrent_session_tree_widget) - item.setText(0, key) - item.setText(1, str(value)) - self.window().libtorrent_session_tree_widget.addTopLevelItem(item) - if export: - self.save_to_file("libtorrent_session.json", data) - - def save_to_file(self, filename, data): - base_dir = QFileDialog.getExistingDirectory(self, "Select an export directory", "", QFileDialog.ShowDirsOnly) - if len(base_dir) > 0: - dest_path = os.path.join(base_dir, filename) - try: - with open(dest_path, "w") as torrent_file: - torrent_file.write(json.dumps(data)) - except OSError as exc: - ConfirmationDialog.show_error(self.window(), "Error exporting file", str(exc)) diff --git a/src/tribler/gui/defs.py b/src/tribler/gui/defs.py deleted file mode 100644 index 507c2febfc..0000000000 --- a/src/tribler/gui/defs.py +++ /dev/null @@ -1,197 +0,0 @@ -""" -This file contains various definitions used by the Tribler GUI. -""" -import sys -from collections import namedtuple - -from PyQt5.QtGui import QColor - -from tribler.core.libtorrent.download_manager.download_state import DownloadStatus - -DEFAULT_API_PROTOCOL = "http" -DEFAULT_API_HOST = "127.0.0.1" - -# Define stacked widget page indices -PAGE_SEARCH_RESULTS = 0 -PAGE_SETTINGS = 1 -PAGE_DOWNLOADS = 2 -PAGE_LOADING = 3 -PAGE_POPULAR = 4 - -PAGE_EDIT_CHANNEL_TORRENTS = 2 - -PAGE_SETTINGS_GENERAL = 0 -PAGE_SETTINGS_CONNECTION = 1 -PAGE_SETTINGS_BANDWIDTH = 2 -PAGE_SETTINGS_SEEDING = 3 -PAGE_SETTINGS_ANONYMITY = 4 -PAGE_SETTINGS_DEBUG = 5 - -STATUS_STRING = { - DownloadStatus.ALLOCATING_DISKSPACE: "Allocating disk space", - DownloadStatus.WAITING_FOR_HASHCHECK: "Waiting for check", - DownloadStatus.HASHCHECKING: "Checking", - DownloadStatus.DOWNLOADING: "Downloading", - DownloadStatus.SEEDING: "Seeding", - DownloadStatus.STOPPED: "Stopped", - DownloadStatus.STOPPED_ON_ERROR: "Stopped on error", - DownloadStatus.METADATA: "Waiting for metadata", - DownloadStatus.CIRCUITS: "Building circuits", - DownloadStatus.EXIT_NODES: "Finding exit nodes", -} - -# Definitions of the download filters. For each filter, it is specified which download statuses can be displayed. -DOWNLOADS_FILTER_ALL = 0 -DOWNLOADS_FILTER_DOWNLOADING = 1 -DOWNLOADS_FILTER_COMPLETED = 2 -DOWNLOADS_FILTER_ACTIVE = 3 -DOWNLOADS_FILTER_INACTIVE = 4 -DOWNLOADS_FILTER_CHANNELS = 6 - -DOWNLOADS_FILTER_DEFINITION = { - DOWNLOADS_FILTER_ALL: [ - DownloadStatus.ALLOCATING_DISKSPACE, - DownloadStatus.WAITING_FOR_HASHCHECK, - DownloadStatus.HASHCHECKING, - DownloadStatus.DOWNLOADING, - DownloadStatus.SEEDING, - DownloadStatus.STOPPED, - DownloadStatus.STOPPED_ON_ERROR, - DownloadStatus.METADATA, - DownloadStatus.CIRCUITS, - DownloadStatus.EXIT_NODES, - ], - DOWNLOADS_FILTER_DOWNLOADING: [ - DownloadStatus.DOWNLOADING, - ], - DOWNLOADS_FILTER_COMPLETED: [ - DownloadStatus.SEEDING, - ], - DOWNLOADS_FILTER_ACTIVE: [ - DownloadStatus.ALLOCATING_DISKSPACE, - DownloadStatus.WAITING_FOR_HASHCHECK, - DownloadStatus.HASHCHECKING, - DownloadStatus.DOWNLOADING, - DownloadStatus.SEEDING, - DownloadStatus.METADATA, - DownloadStatus.CIRCUITS, - DownloadStatus.EXIT_NODES, - ], - DOWNLOADS_FILTER_INACTIVE: [ - DownloadStatus.STOPPED, - DownloadStatus.STOPPED_ON_ERROR - ], -} - -BUTTON_TYPE_NORMAL = 0 -BUTTON_TYPE_CONFIRM = 1 - -# Tribler shutdown grace period in milliseconds -SHUTDOWN_WAITING_PERIOD = 30000 - -# Torrent commit status constants -COMMIT_STATUS_NEW = 0 -COMMIT_STATUS_TODELETE = 1 -COMMIT_STATUS_COMMITTED = 2 -COMMIT_STATUS_UPDATED = 6 - -HEALTH_CHECKING = 'Checking..' -HEALTH_DEAD = 'No peers' -HEALTH_ERROR = 'Error' -HEALTH_MOOT = 'Peers found' -HEALTH_GOOD = 'Seeds found' -HEALTH_UNCHECKED = 'Unknown' - -# Interval for refreshing the results in the debug pane -DEBUG_PANE_REFRESH_TIMEOUT = 5000 # 5 seconds - -ContentCategoryTuple = namedtuple("ContentCategoryTuple", "code emoji long_name short_name") - - -class ContentCategories: - # This class contains definitions of content categories and associated representation - # methods, e.g. emojis, names, etc. - # It should never be instanced, but instead used as a collection of classmethods. - - _category_emojis = ( - ('Video', '🎦'), - ('VideoClips', '📹'), - ('Audio', '🎧'), - ('Documents', '📝'), - ('CD/DVD/BD', '📀'), - ('Compressed', '🗜'), - ('Games', '👾'), - ('Pictures', '📷'), - ('Books', '📚'), - ('Comics', '💢'), - ('Software', '💾'), - ('Science', '🔬'), - ('XXX', '💋'), - ('Other', '🤔'), - ) - _category_tuples = tuple( - ContentCategoryTuple(code, emoji, emoji + " " + code, code) for code, emoji in _category_emojis - ) - - _associative_dict = {} - for cat_index, cat_tuple in enumerate(_category_tuples): - _associative_dict[cat_tuple.code] = cat_tuple - _associative_dict[cat_index] = cat_tuple - _associative_dict[cat_tuple.long_name] = cat_tuple - - codes = tuple(t.code for t in _category_tuples) - long_names = tuple(t.long_name for t in _category_tuples) - short_names = tuple(t.short_name for t in _category_tuples) - - @classmethod - def get(cls, item, default=None): - return cls._associative_dict.get(item, default) - - -CATEGORY_SELECTOR_FOR_SEARCH_ITEMS = ("All", "Channels") + ContentCategories.long_names -CATEGORY_SELECTOR_FOR_POPULAR_ITEMS = ("All",) + ContentCategories.long_names - -CONTEXT_MENU_WIDTH = 200 - -BITTORRENT_BIRTHDAY = 994032000 - -# Timeout for metainfo request -METAINFO_MAX_RETRIES = 3 -METAINFO_TIMEOUT = 65000 - -# Sizes -KB = 1024 -MB = 1024 * KB -GB = 1024 * MB -TB = 1024 * GB -PB = 1024 * TB - -DARWIN = sys.platform == 'darwin' -WINDOWS = sys.platform == 'win32' - -# Constants related to the tag widgets -TAG_BACKGROUND_COLOR = QColor("#36311e") -TAG_BORDER_COLOR = QColor("#453e25") -TAG_TEXT_COLOR = QColor("#ecbe42") - -SUGGESTED_TAG_BACKGROUND_COLOR = TAG_BACKGROUND_COLOR -SUGGESTED_TAG_BORDER_COLOR = TAG_TEXT_COLOR -SUGGESTED_TAG_TEXT_COLOR = TAG_TEXT_COLOR - -EDIT_TAG_BACKGROUND_COLOR = QColor("#3B2D06") -EDIT_TAG_BORDER_COLOR = QColor("#271E04") -EDIT_TAG_TEXT_COLOR = SUGGESTED_TAG_TEXT_COLOR - -TAG_HEIGHT = 22 -TAG_TEXT_HORIZONTAL_PADDING = 10 -TAG_TOP_MARGIN = 32 -TAG_HORIZONTAL_MARGIN = 6 - - -UPGRADE_CANCELLED_ERROR_TITLE = "Tribler Upgrade cancelled" - -NO_DISK_SPACE_ERROR_MESSAGE = "Not enough storage space available. \n" \ - "Tribler requires at least %s space to continue. \n\n" \ - "Please free up the required space and re-run Tribler. " - -CORRUPTED_DB_WAS_FIXED_MESSAGE = "The corrupted database file was fixed" diff --git a/src/tribler/gui/dialogs/__init__.py b/src/tribler/gui/dialogs/__init__.py deleted file mode 100644 index 297d719aef..0000000000 --- a/src/tribler/gui/dialogs/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -This package contains dialogs and overlay windows that are used in Tribler. -""" diff --git a/src/tribler/gui/dialogs/confirmationdialog.py b/src/tribler/gui/dialogs/confirmationdialog.py deleted file mode 100644 index 2ad57112ca..0000000000 --- a/src/tribler/gui/dialogs/confirmationdialog.py +++ /dev/null @@ -1,97 +0,0 @@ -from PyQt5 import uic -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QCursor -from PyQt5.QtWidgets import QSizePolicy, QSpacerItem - -from tribler.gui.defs import BUTTON_TYPE_NORMAL -from tribler.gui.dialogs.dialogcontainer import DialogContainer -from tribler.gui.utilities import connect, get_ui_file_path, tr -from tribler.gui.widgets.ellipsebutton import EllipseButton - - -class ConfirmationDialog(DialogContainer): - button_clicked = pyqtSignal(int) - - def __init__(self, parent, title, main_text, buttons, show_input=False, checkbox_text=None): - DialogContainer.__init__(self, parent) - - uic.loadUi(get_ui_file_path('buttonsdialog.ui'), self.dialog_widget) - - self.dialog_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) - - self.dialog_widget.dialog_title_label.setText(title) - - self.dialog_widget.dialog_main_text_label.setText(main_text) - self.dialog_widget.dialog_main_text_label.adjustSize() - self.checkbox = self.dialog_widget.checkbox - - if not show_input: - self.dialog_widget.dialog_input.setHidden(True) - else: - connect(self.dialog_widget.dialog_input.returnPressed, lambda: self.button_clicked.emit(0)) - - if not checkbox_text: - self.dialog_widget.checkbox.setHidden(True) - else: - self.dialog_widget.checkbox.setText(checkbox_text) - - hspacer_left = QSpacerItem(1, 1, QSizePolicy.Expanding, QSizePolicy.Fixed) - self.dialog_widget.dialog_button_container.layout().addSpacerItem(hspacer_left) - - self.buttons = [] - for index in range(len(buttons)): - self.create_button(index, *buttons[index]) - - hspacer_right = QSpacerItem(1, 1, QSizePolicy.Expanding, QSizePolicy.Fixed) - self.dialog_widget.dialog_button_container.layout().addSpacerItem(hspacer_right) - if hasattr(self.window(), 'escape_pressed'): - connect(self.window().escape_pressed, self.close_dialog) - - @classmethod - def show_error(cls, window, title, error_text): - error_dialog = ConfirmationDialog(window, title, error_text, [(tr("CLOSE"), BUTTON_TYPE_NORMAL)]) - - def on_close(checked): - error_dialog.close_dialog() - - connect(error_dialog.button_clicked, on_close) - error_dialog.show() - return error_dialog - - @classmethod - def show_message(cls, window, title, message_text, button_text): - error_dialog = ConfirmationDialog(window, title, message_text, [(button_text, BUTTON_TYPE_NORMAL)]) - - def on_close(checked): - error_dialog.close_dialog() - - connect(error_dialog.button_clicked, on_close) - error_dialog.show() - return error_dialog - - def create_button(self, index, button_text, _): - button = EllipseButton(self.dialog_widget) - button.setText(button_text) - button.setFixedHeight(26) - button.setCursor(QCursor(Qt.PointingHandCursor)) - self.buttons.append(button) - - button.setStyleSheet( - """ - EllipseButton { - border: 1px solid #B5B5B5; - border-radius: 13px; - color: white; - padding-left: 4px; - padding-right: 4px; - } - - EllipseButton::hover { - border: 1px solid white; - color: white; - } - """ - ) - - self.dialog_widget.dialog_button_container.layout().addWidget(button) - connect(button.clicked, lambda _: self.button_clicked.emit(index)) diff --git a/src/tribler/gui/dialogs/createtorrentdialog.py b/src/tribler/gui/dialogs/createtorrentdialog.py deleted file mode 100644 index dc27711d35..0000000000 --- a/src/tribler/gui/dialogs/createtorrentdialog.py +++ /dev/null @@ -1,184 +0,0 @@ -import os -import re -import typing - -from PyQt5 import uic -from PyQt5.QtCore import QDir, pyqtSignal -from PyQt5.QtGui import QValidator -from PyQt5.QtWidgets import QAction, QFileDialog, QSizePolicy, QTreeWidgetItem - -from tribler.gui.defs import BUTTON_TYPE_NORMAL -from tribler.gui.dialogs.confirmationdialog import ConfirmationDialog -from tribler.gui.dialogs.dialogcontainer import DialogContainer -from tribler.gui.network.request_manager import request_manager -from tribler.gui.tribler_action_menu import TriblerActionMenu -from tribler.gui.utilities import connect, get_ui_file_path, is_dir_writable, tr - - -class DownloadFileTreeWidgetItem(QTreeWidgetItem): - def __init__(self, parent): - QTreeWidgetItem.__init__(self, parent) - - -class TorrentNameValidator(QValidator): - """ - Validator used in Torrent name QLineEdit field to disallow multiline text. - If a new line character is detected, then it is converted to space using fixup(). - - Docs: https://doc.qt.io/qtforpython-5/PySide2/QtGui/QValidator.html - """ - ESCAPE_CHARS_REGEX = r'[\n\r\t]' - - def validate(self, text: str, pos: int) -> typing.Tuple['QValidator.State', str, int]: - if re.search(self.ESCAPE_CHARS_REGEX, text): - return QValidator.Intermediate, text, pos - return QValidator.Acceptable, text, pos - - def fixup(self, text: str) -> str: - return re.sub(self.ESCAPE_CHARS_REGEX, ' ', text) - - -def sanitize_filename(filename: str) -> str: - """Removes some selected escape characters from the filename and returns the cleaned value.""" - return re.sub(r'[\n\r\t]', '', filename) - - -class CreateTorrentDialog(DialogContainer): - create_torrent_notification = pyqtSignal(dict) - - def __init__(self, parent): - DialogContainer.__init__(self, parent) - - uic.loadUi(get_ui_file_path('createtorrentdialog.ui'), self.dialog_widget) - - self.dialog_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) - self.dialog_widget.create_torrent_name_field.setValidator(TorrentNameValidator(parent=self)) - connect(self.dialog_widget.btn_cancel.clicked, self.close_dialog) - connect(self.dialog_widget.create_torrent_choose_files_button.clicked, self.on_choose_files_clicked) - connect(self.dialog_widget.create_torrent_choose_dir_button.clicked, self.on_choose_dir_clicked) - connect(self.dialog_widget.btn_create.clicked, self.on_create_clicked) - connect(self.dialog_widget.create_torrent_files_list.customContextMenuRequested, self.on_right_click_file_item) - self.dialog_widget.create_torrent_files_list.clear() - connect(self.dialog_widget.save_directory_chooser.clicked, self.on_select_save_directory) - self.dialog_widget.file_export_dir.setText(os.path.expanduser("~")) - self.dialog_widget.adjustSize() - - self.on_main_window_resize() - - self.name = None - self.rest_request1 = None - self.rest_request2 = None - - def close_dialog(self, checked=False): - if self.rest_request1: - self.rest_request1.cancel() - if self.rest_request2: - self.rest_request2.cancel() - - super().close_dialog() - - def on_choose_files_clicked(self, checked): - filenames, _ = QFileDialog.getOpenFileNames(self.window(), tr("Please select the files"), QDir.homePath()) - - for filename in filenames: - self.dialog_widget.create_torrent_files_list.addItem(filename) - - def on_choose_dir_clicked(self, checked): - chosen_dir = QFileDialog.getExistingDirectory( - self.window(), tr("Please select the directory containing the files"), "", QFileDialog.ShowDirsOnly - ) - - if not chosen_dir: - return - - files = [] - for path, _, dir_files in os.walk(chosen_dir): - for filename in dir_files: - files.append(os.path.join(path, filename)) - - self.dialog_widget.create_torrent_files_list.clear() - for filename in files: - self.dialog_widget.create_torrent_files_list.addItem(filename) - - def on_create_clicked(self, checked): - if self.dialog_widget.create_torrent_files_list.count() == 0: - dialog = ConfirmationDialog( - self.dialog_widget, - tr("Warning!"), - tr("You should add at least one file to your torrent."), - [(tr("CLOSE"), BUTTON_TYPE_NORMAL)], - ) - - connect(dialog.button_clicked, dialog.close_dialog) - dialog.show() - return - - self.dialog_widget.btn_create.setEnabled(False) - - files_list = [] - for ind in range(self.dialog_widget.create_torrent_files_list.count()): - file_str = self.dialog_widget.create_torrent_files_list.item(ind).text() - files_list.append(file_str) - - export_dir = self.dialog_widget.file_export_dir.text() - if not os.path.exists(export_dir): - ConfirmationDialog.show_error( - self.dialog_widget, tr("Cannot save torrent file to %s") % export_dir, tr("Path does not exist") - ) - return - - is_writable, error = is_dir_writable(export_dir) - if not is_writable: - ConfirmationDialog.show_error( - self.dialog_widget, tr("Cannot save torrent file to %s") % export_dir, tr("Error: %s ") % str(error) - ) - return - - torrent_name = self.dialog_widget.create_torrent_name_field.text() - self.name = sanitize_filename(torrent_name) - - description = self.dialog_widget.create_torrent_description_field.toPlainText() - - is_seed = self.dialog_widget.seed_after_adding_checkbox.isChecked() - self.rest_request1 = request_manager.post( - endpoint='createtorrent', - on_success=self.on_torrent_created, - url_params={'download': 1} if is_seed else None, - data={"name": self.name, "description": description, "files": files_list, "export_dir": export_dir}, - ) - self.dialog_widget.edit_channel_create_torrent_progress_label.setText(tr("Creating torrent. Please wait...")) - - def on_torrent_created(self, result): - if not result: - return - self.dialog_widget.btn_create.setEnabled(True) - self.dialog_widget.edit_channel_create_torrent_progress_label.setText(tr("Created torrent")) - if 'torrent' in result: - self.create_torrent_notification.emit({"msg": tr("Torrent successfully created")}) - self.close_dialog() - - def on_select_save_directory(self, checked): - chosen_dir = QFileDialog.getExistingDirectory( - self.window(), tr("Please select the directory containing the files"), "", QFileDialog.ShowDirsOnly - ) - - if not chosen_dir: - return - self.dialog_widget.file_export_dir.setText(chosen_dir) - - def on_remove_entry(self, index): - self.dialog_widget.create_torrent_files_list.takeItem(index) - - def on_right_click_file_item(self, pos): - item_clicked = self.dialog_widget.create_torrent_files_list.itemAt(pos) - if not item_clicked: - return - - selected_item_index = self.dialog_widget.create_torrent_files_list.row(item_clicked) - - remove_action = QAction(tr("Remove file"), self) - connect(remove_action.triggered, lambda index=selected_item_index: self.on_remove_entry(index)) - - menu = TriblerActionMenu(self) - menu.addAction(remove_action) - menu.exec_(self.dialog_widget.create_torrent_files_list.mapToGlobal(pos)) diff --git a/src/tribler/gui/dialogs/dialogcontainer.py b/src/tribler/gui/dialogs/dialogcontainer.py deleted file mode 100644 index 21cd68dcf2..0000000000 --- a/src/tribler/gui/dialogs/dialogcontainer.py +++ /dev/null @@ -1,65 +0,0 @@ -import logging - -from PyQt5.QtCore import QPoint, pyqtSignal -from PyQt5.QtGui import QPainter -from PyQt5.QtWidgets import QStyle, QStyleOption, QWidget - -from tribler.gui.utilities import connect - - -class DialogContainer(QWidget): - close_event = pyqtSignal() - - def __init__(self, parent, left_right_margin=100): - QWidget.__init__(self, parent) - - self.setStyleSheet("background-color: rgba(30, 30, 30, 0.75);") - - self.dialog_widget = QWidget(self) - self.left_right_margin = left_right_margin # The margin at the left and right of the dialog window - self.closed = False - self.logger = logging.getLogger(self.__class__.__name__) - connect(self.window().resize_event, self.on_main_window_resize) - - def paintEvent(self, _): - opt = QStyleOption() - opt.initFrom(self) - painter = QPainter(self) - self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self) - - def close_dialog(self, checked=False): - if self.closed: - return - - try: - self.close_event.emit() - self.setParent(None) - self.deleteLater() - self.closed = True - except RuntimeError: - pass - - def mouseReleaseEvent(self, qevent): - # Close the dialog window if user clicks outside it - if not self.dialog_widget.geometry().contains(qevent.localPos().toPoint()): - self.close_dialog() - - def showEvent(self, _): - # Make sure that the window has proper vertical alignment. - self.on_main_window_resize() - - def on_main_window_resize(self): - try: - if not self or not self.parentWidget(): - return - - self.setFixedSize(self.parentWidget().size()) - self.dialog_widget.setFixedWidth(self.width() - self.left_right_margin) - self.dialog_widget.move( - QPoint( - int(self.geometry().center().x() - self.dialog_widget.geometry().width() // 2), - int(self.geometry().center().y() - self.dialog_widget.geometry().height() // 2), - ) - ) - except RuntimeError: - pass diff --git a/src/tribler/gui/dialogs/editmetadatadialog.py b/src/tribler/gui/dialogs/editmetadatadialog.py deleted file mode 100644 index eab753bc9c..0000000000 --- a/src/tribler/gui/dialogs/editmetadatadialog.py +++ /dev/null @@ -1,165 +0,0 @@ -from typing import Dict, List - -from PyQt5 import uic -from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal -from PyQt5.QtWidgets import QComboBox, QSizePolicy, QWidget - -from tribler.core.database.layers.knowledge import ResourceType -from tribler.core.knowledge.community import MAX_RESOURCE_LENGTH, MIN_RESOURCE_LENGTH -from tribler.gui.defs import TAG_HORIZONTAL_MARGIN -from tribler.gui.dialogs.dialogcontainer import DialogContainer -from tribler.gui.network.request_manager import request_manager -from tribler.gui.utilities import connect, get_languages_file_content, get_objects_with_predicate, get_ui_file_path, tr -from tribler.gui.widgets.tagbutton import TagButton - -METADATA_TABLE_PREDICATES = [ - ResourceType.CONTENT_ITEM, - ResourceType.DESCRIPTION, - ResourceType.DATE, - ResourceType.LANGUAGE -] - - -class EditMetadataDialog(DialogContainer): - """ - This dialog enables a user to edit metadata associated with particular content. - """ - - save_button_clicked = pyqtSignal(QModelIndex, list) - suggestions_loaded = pyqtSignal() - - def __init__(self, parent: QWidget, index: QModelIndex) -> None: - DialogContainer.__init__(self, parent, left_right_margin=400) - self.index: QModelIndex = index - self.data_item = self.index.model().data_items[self.index.row()] - self.infohash = self.data_item["infohash"] - - uic.loadUi(get_ui_file_path('edit_metadata_dialog.ui'), self.dialog_widget) - - self.dialog_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) - connect(self.dialog_widget.close_button.clicked, self.close_dialog) - connect(self.dialog_widget.save_button.clicked, self.on_save_metadata_button_clicked) - connect(self.dialog_widget.edit_tags_input.enter_pressed, lambda: self.on_save_metadata_button_clicked(None)) - connect(self.dialog_widget.edit_tags_input.escape_pressed, self.close_dialog) - - self.dialog_widget.edit_tags_input.setFocus() - self.dialog_widget.error_text_label.hide() - self.dialog_widget.suggestions_container.hide() - - connect(self.dialog_widget.edit_metadata_table.doubleClicked, self.on_edit_metadata_table_item_clicked) - - # Load the languages - self.languages = get_languages_file_content() - - # Fill in the metadata table and make the items in the 2nd column editable - for ind in range(self.dialog_widget.edit_metadata_table.topLevelItemCount()): - item = self.dialog_widget.edit_metadata_table.topLevelItem(ind) - objects = get_objects_with_predicate(self.data_item, METADATA_TABLE_PREDICATES[ind]) - if METADATA_TABLE_PREDICATES[ind] == ResourceType.LANGUAGE: - # We use a drop-down menu to select the language of a torrent - combobox = QComboBox(self) - combobox.addItems(self.languages.values()) - self.dialog_widget.edit_metadata_table.setItemWidget(item, 1, combobox) - if objects and objects[0] in self.languages.keys(): - combobox.setCurrentIndex(list(self.languages.keys()).index(objects[0])) - else: - # Otherwise, we show an editing field - if objects: - item.setText(1, objects[0]) - item.setFlags(item.flags() | Qt.ItemIsEditable) - - if get_objects_with_predicate(self.data_item, ResourceType.TAG): - self.dialog_widget.edit_tags_input.set_tags(get_objects_with_predicate(self.data_item, ResourceType.TAG)) - self.dialog_widget.content_name_label.setText(self.data_item["name"]) - - # Fetch suggestions - request_manager.get(f"knowledge/{self.infohash}/tag_suggestions", on_success=self.on_received_tag_suggestions) - - self.update_window() - - def on_edit_metadata_table_item_clicked(self, index): - if index.column() == 1: - item = self.dialog_widget.edit_metadata_table.topLevelItem(index.row()) - self.dialog_widget.edit_metadata_table.editItem(item, index.column()) - - def show_error_text(self, text: str) -> None: - self.dialog_widget.error_text_label.setText(tr(text)) - self.dialog_widget.error_text_label.setHidden(False) - - def on_save_metadata_button_clicked(self, _) -> None: - statements: List[Dict] = [] - - # Sanity check the entered tags - entered_tags = self.dialog_widget.edit_tags_input.get_entered_tags() - for tag in entered_tags: - if len(tag) < MIN_RESOURCE_LENGTH or len(tag) > MAX_RESOURCE_LENGTH: - error_text = f"Each tag should be at least {MIN_RESOURCE_LENGTH} characters and can be at most " \ - f"{MAX_RESOURCE_LENGTH} characters." - self.show_error_text(error_text) - return - - statements.append({ - "predicate": ResourceType.TAG, - "object": tag, - }) - - # Sanity check the entries in the metadata table and convert them to statements - for ind in range(self.dialog_widget.edit_metadata_table.topLevelItemCount()): - item = self.dialog_widget.edit_metadata_table.topLevelItem(ind) - entered_text: str = item.text(1) - - if METADATA_TABLE_PREDICATES[ind] == ResourceType.LANGUAGE: - combobox = self.dialog_widget.edit_metadata_table.itemWidget(item, 1) - if combobox.currentIndex() != 0: # Ignore the 'unknown' option in the dropdown menu at index zero - statements.append({ - "predicate": METADATA_TABLE_PREDICATES[ind], - "object": list(self.languages.keys())[combobox.currentIndex()], - }) - continue - - if entered_text and (len(entered_text) < MIN_RESOURCE_LENGTH or len(entered_text) > MAX_RESOURCE_LENGTH): - error_text = f"Each metadata item should be at least {MIN_RESOURCE_LENGTH} characters and can be at " \ - f"most {MAX_RESOURCE_LENGTH} characters." - self.show_error_text(error_text) - return - - # Check if the 'year' field is a number - if METADATA_TABLE_PREDICATES[ind] == ResourceType.DATE and entered_text and not entered_text.isdigit(): - error_text = "The year field should contain a valid year." - self.show_error_text(error_text) - return - - if entered_text: - statements.append({ - "predicate": METADATA_TABLE_PREDICATES[ind], - "object": entered_text, - }) - - self.save_button_clicked.emit(self.index, statements) - - def on_received_tag_suggestions(self, data: Dict) -> None: - if self.closed: # The dialog was closed before the request finished - return - - self.suggestions_loaded.emit() - if data["suggestions"]: - self.dialog_widget.suggestions_container.show() - - cur_x = 0 - - for suggestion in data["suggestions"]: - tag_button = TagButton(self.dialog_widget.suggestions, suggestion) - connect(tag_button.clicked, lambda _, btn=tag_button: self.clicked_suggestion(btn)) - tag_button.move(QPoint(cur_x, tag_button.y())) - cur_x += tag_button.width() + TAG_HORIZONTAL_MARGIN - tag_button.show() - - self.update_window() - - def clicked_suggestion(self, tag_button: TagButton) -> None: - self.dialog_widget.edit_tags_input.add_tag(tag_button.text()) - tag_button.setParent(None) - - def update_window(self) -> None: - self.dialog_widget.adjustSize() - self.on_main_window_resize() diff --git a/src/tribler/gui/dialogs/feedbackdialog.py b/src/tribler/gui/dialogs/feedbackdialog.py deleted file mode 100644 index ad32c96f3a..0000000000 --- a/src/tribler/gui/dialogs/feedbackdialog.py +++ /dev/null @@ -1,165 +0,0 @@ -from __future__ import annotations - -import os -import platform -import sys -import time -from typing import Any, Optional, TYPE_CHECKING - -from PyQt5 import uic -from PyQt5.QtWidgets import QAction, QDialog, QMessageBox - -from tribler.gui.tribler_action_menu import TriblerActionMenu -from tribler.gui.utilities import connect, get_ui_file_path, tr - -if TYPE_CHECKING: - from tribler.gui.tribler_window import TriblerWindow - - -def dump(obj: Optional[Any], indent: int = 0) -> str: - """ - Dump a value to a string - Args: - obj: The value to dump - indent: The indentation level - - Returns: - The dumped value - """ - ind = ' ' * indent - - def join(strings): - joined = ',\n'.join(strings) - return f"\n{joined}\n{ind}" - - if isinstance(obj, dict): - items = (f"{ind} {repr(k)}: {dump(v, indent + 2)}" for k, v in obj.items()) - return f'{{{join(items)}}}' - - if isinstance(obj, (list, tuple)): - closing = ['(', ')'] if isinstance(obj, tuple) else ['[', ']'] - items = (f"{ind} {dump(x, indent + 2)}" for x in obj) - return f'{closing[0]}{join(items)}{closing[1]}' - - return repr(obj) - - -def dump_with_name(name: str, value: Optional[str | dict], start: str = '\n\n', delimiter: str = '=' * 40) -> str: - """ - Dump a value to a string with a name - Args: - name: The name of the value - value: The value to dump - start: The start of the string - delimiter: The delimiter to use - - Returns: - The dumped value - """ - text = start + delimiter - text += f'\n{name}:\n' - text += delimiter + '\n' - text += dump(value) - return text - - -class FeedbackDialog(QDialog): - def __init__( # pylint: disable=too-many-arguments, too-many-locals - self, - parent: TriblerWindow, - reported_error: Exception, - tribler_version, - start_time, - stop_application_on_close=True, - additional_tags=None, - ): - QDialog.__init__(self, parent) - uic.loadUi(get_ui_file_path('feedback_dialog.ui'), self) - self.setWindowTitle(reported_error) - - self.core_manager = parent.core_manager - self.process_manager = parent.process_manager - self.reported_error = reported_error - self.stop_application_on_close = stop_application_on_close - self.tribler_version = tribler_version - self.additional_tags = additional_tags or {} - - self.info = { - 'error text': str(self.reported_error), - "comments": self.comments_text_edit.toPlainText(), - "system info": { - 'os.getcwd': f'{os.getcwd()}', - 'sys.executable': f'{sys.executable}', - 'os': os.name, - 'platform.machine': platform.machine(), - 'python.version': sys.version, - 'in_debug': str(__debug__), - 'tribler_uptime': f"{time.time() - start_time}", - 'sys.argv': list(sys.argv), - 'sys.path': list(sys.path) - }, - "environment": os.environ, - "last processes": [str(p) for p in self.process_manager.get_last_processes()] - } - - text = dump_with_name('Stacktrace', str(self.reported_error), start='') - text += dump_with_name('Info', self.info) - text += dump_with_name('Additional tags', self.additional_tags) - text = text.replace('\\n', '\n') - text = self.scrubber.scrub_text(text) - self.error_text_edit.setPlainText(text) - - placeholder = tr( - "What were you doing before this crash happened? " - "This information will help Tribler developers to figure out and fix the issue quickly." - ) - self.comments_text_edit.setPlaceholderText(placeholder) - - connect(self.cancel_button.clicked, self.on_cancel_clicked) - connect(self.send_report_button.clicked, self.on_send_clicked) - - def on_remove_entry(self, index): - self.env_variables_list.takeTopLevelItem(index) - - def on_right_click_item(self, pos): - item_clicked = self.env_variables_list.itemAt(pos) - if not item_clicked: - return - - selected_item_index = self.env_variables_list.indexOfTopLevelItem(item_clicked) - menu = TriblerActionMenu(self) - remove_action = QAction(tr("Remove entry"), self) - connect(remove_action.triggered, lambda checked: self.on_remove_entry(selected_item_index)) - menu.addAction(remove_action) - menu.exec_(self.env_variables_list.mapToGlobal(pos)) - - def on_cancel_clicked(self, checked): - self.close() - - def on_send_clicked(self, checked): - self.send_report_button.setEnabled(False) - self.send_report_button.setText(tr("SENDING...")) - self.on_report_sent() - - def on_report_sent(self): - if self.send_automatically: - self.close() - - success_text = tr("Successfully sent the report! Thanks for your contribution.") - - box = QMessageBox(self.window()) - box.setWindowTitle(tr("Report Sent")) - box.setText(success_text) - box.setStyleSheet("QPushButton { color: white; }") - box.exec_() - - self.close() - - def closeEvent(self, close_event): - # start collecting breadcrumbs while the dialog is closed - self.sentry_reporter.collecting_breadcrumbs_allowed = True - - if self.stop_application_on_close: - self.core_manager.stop() - if self.core_manager.shutting_down and self.core_manager.core_running: - close_event.ignore() diff --git a/src/tribler/gui/dialogs/startdownloaddialog.py b/src/tribler/gui/dialogs/startdownloaddialog.py deleted file mode 100644 index 83c7ca2e4f..0000000000 --- a/src/tribler/gui/dialogs/startdownloaddialog.py +++ /dev/null @@ -1,299 +0,0 @@ -import json -import logging -from binascii import unhexlify -from pathlib import PurePosixPath -from urllib.parse import unquote_plus - -from PyQt5 import uic -from PyQt5.QtCore import QTimer, pyqtSignal -from PyQt5.QtWidgets import QFileDialog, QSizePolicy -from yarl import URL - -from tribler.core.libtorrent.uris import url_to_path -from tribler.gui.defs import METAINFO_MAX_RETRIES, METAINFO_TIMEOUT -from tribler.gui.dialogs.confirmationdialog import ConfirmationDialog -from tribler.gui.dialogs.dialogcontainer import DialogContainer -from tribler.gui.network.request_manager import request_manager -from tribler.gui.utilities import ( - connect, - format_size, - get_gui_setting, - get_image_path, - get_ui_file_path, - is_dir_writable, - tr, -) -from tribler.gui.widgets.torrentfiletreewidget import TORRENT_FILES_TREE_STYLESHEET - - -class StartDownloadDialog(DialogContainer): - button_clicked = pyqtSignal(int) - received_metainfo = pyqtSignal(dict) - - def __init__(self, parent, download_uri): - DialogContainer.__init__(self, parent) - - torrent_name = download_uri - scheme = URL(download_uri).scheme - - if scheme == "file": - torrent_name = url_to_path(torrent_name) - elif scheme == "magnet": - torrent_name = unquote_plus(torrent_name) - - self.download_uri = download_uri - self.has_metainfo = False - self.metainfo_fetch_timer = None - self.metainfo_retries = 0 - - uic.loadUi(get_ui_file_path('startdownloaddialog.ui'), self.dialog_widget) - - self.dialog_widget.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) - - connect(self.dialog_widget.browse_dir_button.clicked, self.on_browse_dir_clicked) - connect(self.dialog_widget.cancel_button.clicked, lambda _: self.button_clicked.emit(0)) - connect(self.dialog_widget.download_button.clicked, self.on_download_clicked) - connect(self.dialog_widget.loading_files_label.clicked, self.on_reload_torrent_info) - connect(self.dialog_widget.anon_download_checkbox.clicked, self.on_reload_torrent_info) - connect(self.dialog_widget.files_list_view.selected_files_changed, self.update_torrent_size_label) - - self.dialog_widget.destination_input.setStyleSheet( - """ - QComboBox { - background-color: #444; - border: none; - color: #C0C0C0; - padding: 4px; - } - QComboBox::drop-down { - width: 20px; - border: 1px solid #999; - border-radius: 2px; - } - QComboBox QAbstractItemView { - selection-background-color: #707070; - color: #C0C0C0; - } - QComboBox::down-arrow { - width: 12px; - height: 12px; - image: url('%s'); - } - """ - % get_image_path('down_arrow_input.png', convert_slashes_to_forward=True) - ) - - if self.window().tribler_settings: - # Set the most recent download locations in the QComboBox - current_settings = get_gui_setting(self.window().gui_settings, "recent_download_locations", "") - if len(current_settings) > 0: - recent_locations = [unhexlify(url).decode('utf-8') for url in current_settings.split(",")] - self.dialog_widget.destination_input.addItems(recent_locations) - else: - self.dialog_widget.destination_input.setCurrentText( - self.window().tribler_settings['libtorrent']['download_defaults']['saveas'] - ) - - self.dialog_widget.torrent_name_label.setText(torrent_name) - - connect(self.dialog_widget.anon_download_checkbox.stateChanged, self.on_anon_download_state_changed) - self.dialog_widget.anon_download_checkbox.setChecked( - self.window().tribler_settings['libtorrent']['download_defaults']['anonymity_enabled'] - ) - self.dialog_widget.safe_seed_checkbox.setChecked( - self.window().tribler_settings['libtorrent']['download_defaults']['safeseeding_enabled'] - ) - - self.dialog_widget.safe_seed_checkbox.setEnabled(self.dialog_widget.anon_download_checkbox.isChecked()) - - self.perform_files_request() - self.dialog_widget.files_list_view.setHidden(True) - self.dialog_widget.adjustSize() - self.on_anon_download_state_changed(None) - - self.on_main_window_resize() - self.total_files_size = None - self.selected_files_size = None - - self.rest_request = None - - def close_dialog(self, checked=False): - if self.closed: - return - - if self.rest_request: - self.rest_request.cancel() - - if self.metainfo_fetch_timer: - self.metainfo_fetch_timer.stop() - - # Loading files label is a clickable label with pyqtsignal which could leak, - # so delete the widget while closing the dialog. - if self.dialog_widget and self.dialog_widget.loading_files_label: - try: - self.dialog_widget.loading_files_label.deleteLater() - except RuntimeError: - logging.debug("Deleting loading files widget in the dialog widget failed.") - - self.window().start_download_dialog_active = False - super().close_dialog() - - def perform_files_request(self): - if self.closed or self.has_metainfo: - return - - direct = not self.dialog_widget.anon_download_checkbox.isChecked() - params = {'uri': self.download_uri} - if direct: - params['hops'] = 0 - self.rest_request = request_manager.get( - 'torrentinfo', - on_success=self.on_received_metainfo, - url_params=params, - capture_errors=False - ) - - if self.metainfo_retries <= METAINFO_MAX_RETRIES: - fetch_mode = tr("directly") if direct else tr("anonymously") - loading_message = tr("Loading torrent files %s...") % fetch_mode - timeout_message = tr("Timeout in fetching files %s. Retrying %i/%i") % ( - fetch_mode, - self.metainfo_retries, - METAINFO_MAX_RETRIES, - ) - - self.dialog_widget.loading_files_label.setText( - loading_message if not self.metainfo_retries else timeout_message - ) - self.metainfo_fetch_timer = QTimer() - connect(self.metainfo_fetch_timer.timeout, self.perform_files_request) - self.metainfo_fetch_timer.setSingleShot(True) - self.metainfo_fetch_timer.start(METAINFO_TIMEOUT) - - self.metainfo_retries += 1 - - def on_received_metainfo(self, response): - if not response or not self or self.closed or self.has_metainfo: - return - if 'error' in response: - if response['error'] == 'metainfo error': - # If it failed to load metainfo for max number of times, show an error message in red. - if self.metainfo_retries > METAINFO_MAX_RETRIES: - self.dialog_widget.loading_files_label.setStyleSheet("color:#ff0000;") - self.dialog_widget.loading_files_label.setText(tr("Failed to load files. Click to retry again.")) - return - self.perform_files_request() - - elif 'code' in response['error'] and response['error']['code'] == 'IOError': - self.dialog_widget.loading_files_label.setText(tr("Unable to read torrent file data")) - else: - self.dialog_widget.loading_files_label.setText(tr("Error: %s") % response['error']) - return - - metainfo = json.loads(unhexlify(response['metainfo'])) - if 'files' in metainfo['info']: # Multi-file torrent - files = [ - {'path': [metainfo['info']['name'], *file['path']], 'length': file['length']} - for file in metainfo['info']['files'] - ] - else: - files = [{'path': PurePosixPath(metainfo['info']['name']).parts, 'length': metainfo['info']['length']}] - - self.dialog_widget.files_list_view.fill_entries(files) - # Add a bit of space between the rows - self.dialog_widget.files_list_view.setStyleSheet( - TORRENT_FILES_TREE_STYLESHEET - + """ - TorrentFileTreeWidget { background-color: #444;} - TorrentFileTreeWidget::item { color: white; padding-bottom: 2px; padding-top: 2px;} - """ - ) - - # Show if the torrent already exists in the downloads - if response.get('download_exists'): - self.dialog_widget.loading_files_label.setStyleSheet("color:#e67300;") - self.dialog_widget.loading_files_label.setText(tr("Note: this torrent already exists in the Downloads")) - - self.has_metainfo = True - self.dialog_widget.files_list_view.setHidden(False) - self.dialog_widget.adjustSize() - self.on_main_window_resize() - - self.received_metainfo.emit(metainfo) - - def update_torrent_size_label(self): - total_files_size = self.dialog_widget.files_list_view.total_files_size - selected_files_size = self.dialog_widget.files_list_view.selected_files_size - if total_files_size == selected_files_size: - label_text = tr("Torrent size: ") + format_size(total_files_size) - else: - label_text = ( - tr("Selected: ") - + format_size(selected_files_size) - + " / " - + tr("Total: ") - + format_size(total_files_size) - ) - self.dialog_widget.loading_files_label.setStyleSheet("color:#ffffff;") - self.dialog_widget.loading_files_label.setText(label_text) - - def on_reload_torrent_info(self, *args): - """ - This method is called when user clicks the QLabel text showing loading or error message. Here, we reset - the number of retries to fetch the metainfo. Note color of QLabel is also reset to white. - """ - if self.has_metainfo: - return - self.dialog_widget.loading_files_label.setStyleSheet("color:#ffffff;") - self.metainfo_retries = 0 - self.perform_files_request() - - def on_browse_dir_clicked(self, checked): - chosen_dir = QFileDialog.getExistingDirectory( - self.window(), tr("Please select the destination directory of your download"), "", QFileDialog.ShowDirsOnly - ) - - if len(chosen_dir) != 0: - self.dialog_widget.destination_input.setCurrentText(chosen_dir) - - is_writable, error = is_dir_writable(chosen_dir) - if not is_writable: - gui_error_message = tr( - "Tribler cannot download to %s directory. Please add proper write " - "permissions to the directory or choose another download directory. [%s]" - ) % (chosen_dir, error) - ConfirmationDialog.show_message( - self.dialog_widget, tr("Insufficient Permissions"), gui_error_message, "OK" - ) - - def on_anon_download_state_changed(self, _): - if self.dialog_widget.anon_download_checkbox.isChecked(): - self.dialog_widget.safe_seed_checkbox.setChecked(True) - self.dialog_widget.safe_seed_checkbox.setEnabled(not self.dialog_widget.anon_download_checkbox.isChecked()) - - def on_download_clicked(self, checked): - if self.has_metainfo and len(self.dialog_widget.files_list_view.get_selected_files_indexes()) == 0: - # User deselected all torrents - ConfirmationDialog.show_error( - self.window(), tr("No files selected"), tr("Please select at least one file to download.") - ) - else: - download_dir = self.dialog_widget.destination_input.currentText() - self.logger.info(f'Download folder: {download_dir}') - if not download_dir: - text = tr("Please specify the path to the download directory") - title = tr("The path is not specified") - ConfirmationDialog.show_message(self.dialog_widget, title, text, "OK") - return - is_writable, error = is_dir_writable(download_dir) - if not is_writable: - gui_error_message = tr( - "Tribler cannot download to %s directory. Please add proper write " - "permissions to the directory or choose another download directory and try " - "to download again. [%s]" - ) % (download_dir, error) - ConfirmationDialog.show_message( - self.dialog_widget, tr("Insufficient Permissions"), gui_error_message, "OK" - ) - else: - self.button_clicked.emit(1) diff --git a/src/tribler/gui/error_handler.py b/src/tribler/gui/error_handler.py deleted file mode 100644 index ab035cefe6..0000000000 --- a/src/tribler/gui/error_handler.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import logging -import traceback -from typing import TYPE_CHECKING - -from tribler.gui.app_manager import AppManager -from tribler.gui.dialogs.feedbackdialog import FeedbackDialog - -if TYPE_CHECKING: - from tribler.gui.tribler_window import TriblerWindow - - -# fmt: off - - -class ErrorHandler: - def __init__(self, tribler_window: TriblerWindow): - logger_name = self.__class__.__name__ - self._logger = logging.getLogger(logger_name) - - self.tribler_window = tribler_window - self.app_manager: AppManager = tribler_window.app_manager - - self._handled_exceptions = set() - self._tribler_stopped = False - - def gui_error(self, exc_type, exc, tb): - self._logger.info(f'Processing GUI error: {exc_type}') - process_manager = self.tribler_window.process_manager - process_manager.current_process.set_error(exc) - - text = "".join(traceback.format_exception(exc_type, exc, tb)) - self._logger.error(text) - - if self._tribler_stopped: - self._logger.info('Tribler has been stopped') - return - - - if exc_type in self._handled_exceptions: - self._logger.info('This exception has been handled already') - return - - self._handled_exceptions.add(exc_type) - - quoted_output = self.tribler_window.core_manager.get_last_core_output() - self._logger.info(f'Last Core output:\n{quoted_output}') - - self._stop_tribler(quoted_output) - - if self.app_manager.quitting_app: - return - - additional_tags = { - 'source': 'gui', - 'tribler_stopped': self._tribler_stopped - } - - FeedbackDialog( - parent=self.tribler_window, - reported_error=exc, - tribler_version=self.tribler_window.tribler_version, - start_time=self.tribler_window.start_time, - stop_application_on_close=self._tribler_stopped, - additional_tags=additional_tags, - ).show() - - def core_error(self, reported_error: Exception): - if self._tribler_stopped or reported_error.__class__ in self._handled_exceptions: - return - - self._handled_exceptions.add(reported_error.type) - self._logger.info(f'Processing Core error: {reported_error}') - process_manager = self.tribler_window.process_manager - process_manager.current_process.set_error(f"Core {reported_error.type}: {reported_error.text}") - - error_text = f'{reported_error.text}\n{reported_error.long_text}' - self._logger.error(error_text) - - if reported_error.should_stop: - self._stop_tribler(error_text) - - SentryScrubber.remove_breadcrumbs(reported_error.event) - gui_sentry_reporter.additional_information.update(reported_error.additional_information) - - additional_tags = { - 'source': 'core', - 'tribler_stopped': self._tribler_stopped - } - - FeedbackDialog( - parent=self.tribler_window, - sentry_reporter=gui_sentry_reporter, - reported_error=reported_error, - tribler_version=self.tribler_window.tribler_version, - start_time=self.tribler_window.start_time, - stop_application_on_close=self._tribler_stopped, - additional_tags=additional_tags, - ).show() - - def _stop_tribler(self, text): - if self._tribler_stopped: - return - - self._tribler_stopped = True - - self.tribler_window.tribler_crashed.emit(text) - self.tribler_window.delete_tray_icon() - - # Stop the download loop - self.tribler_window.downloads_page.stop_refreshing_downloads() - - # Add info about whether we are stopping Tribler or not - self.tribler_window.core_manager.stop(quit_app_on_core_finished=False) - - self.tribler_window.setHidden(True) - - if self.tribler_window.debug_window: - self.tribler_window.debug_window.setHidden(True) diff --git a/src/tribler/gui/event_request_manager.py b/src/tribler/gui/event_request_manager.py deleted file mode 100644 index 1da373f48c..0000000000 --- a/src/tribler/gui/event_request_manager.py +++ /dev/null @@ -1,216 +0,0 @@ -from __future__ import annotations - -import contextlib -import json -import logging -import time -from typing import Optional - -from PyQt5.QtCore import QTimer, QUrl, pyqtSignal -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest - -from tribler.core.notifier import Notifier, Notification -from tribler.gui.exceptions import CoreConnectTimeoutError -from tribler.gui.network.request_manager import request_manager -from tribler.gui.utilities import connect, make_network_errors_dict -from tribler.tribler_config import TriblerConfigManager - -received_events = [] - -CORE_CONNECTION_TIMEOUT = 180 -RECONNECT_INTERVAL_MS = 100 -logger = logging.getLogger(__name__) - - -class EventRequestManager(QNetworkAccessManager): - """ - The EventRequestManager class handles the events connection over which important events in Tribler are pushed. - """ - - node_info_updated = pyqtSignal(object) - received_remote_query_results = pyqtSignal(object) - core_connected = pyqtSignal(object) - new_version_available = pyqtSignal(str) - torrent_finished = pyqtSignal(object) - low_storage_signal = pyqtSignal(object) - tribler_shutdown_signal = pyqtSignal(str) - change_loading_text = pyqtSignal(str) - config_error_signal = pyqtSignal(str) - - def __init__(self, api_port: Optional[int], api_key, error_handler, root_state_dir): - QNetworkAccessManager.__init__(self) - self.api_port = api_port - self.api_key = api_key - self.root_state_dir = root_state_dir - self.request: Optional[QNetworkRequest] = None - self.start_time = time.time() - self.connect_timer = QTimer() - self.current_event_string = "" - self.reply: Optional[QNetworkReply] = None - self.receiving_data = False - self.shutting_down = False - self.error_handler = error_handler - self._logger = logging.getLogger(self.__class__.__name__) - self.network_errors = make_network_errors_dict() - - self.connect_timer.setSingleShot(True) - connect(self.connect_timer.timeout, self.reconnect) - - self.notifier = notifier = Notifier() - notifier.add(Notification.channel_entity_updated, self.on_channel_entity_updated) - notifier.add(Notification.events_start, self.on_events_start) - notifier.add(Notification.tribler_exception, self.on_tribler_exception) - notifier.add(Notification.tribler_new_version, self.on_tribler_new_version) - notifier.add(Notification.torrent_finished, self.on_torrent_finished) - notifier.add(Notification.low_space, self.on_low_space) - notifier.add(Notification.remote_query_results, self.on_remote_query_results) - notifier.add(Notification.tribler_shutdown_state, self.on_tribler_shutdown_state) - notifier.add(Notification.report_config_error, self.on_report_config_error) - - def create_request(self) -> QNetworkRequest | None: - if not self.api_port: - logger.warning("Can't create a request: api_port is not set (%d).", self.api_port) - return - - url = QUrl(f"http://127.0.0.1:{self.api_port}/api/events") - request = QNetworkRequest(url) - request.setRawHeader(b'X-Api-Key', self.api_key.encode('ascii')) - return request - - def set_api_port(self, api_port: int): - self.api_port = api_port - self.request = self.create_request() - - def on_channel_entity_updated(self, channel_update_dict: dict): - self.node_info_updated.emit(channel_update_dict) - - def on_events_start(self, public_key: str, version: str): - self.core_connected.emit(version) - - def on_tribler_exception(self, error: dict): - self.error_handler.core_error(**error) - - def on_tribler_new_version(self, version: str): - self.new_version_available.emit(version) - - def on_torrent_finished(self, infohash: str, name: str, hidden: bool): - self.torrent_finished.emit(dict(infohash=infohash, name=name, hidden=hidden)) - - def on_low_space(self, disk_usage_data: dict): - self.low_storage_signal.emit(disk_usage_data) - - def on_remote_query_results(self, data: dict): - self.received_remote_query_results.emit(data) - - def on_tribler_shutdown_state(self, state: str): - self.tribler_shutdown_signal.emit(state) - - def on_report_config_error(self, error): - self.config_error_signal.emit(error) - - def on_error(self, error: int, reschedule_on_err: bool): - # If the REST API server is not started yet and the port is not opened, the error will be received. - # The specific error can be different on different systems: - # - QNetworkReply.ConnectionRefusedError (code 1); - # - QNetworkReply.HostNotFoundError (code 3); - # - QNetworkReply.TimeoutError (code 4); - # - QNetworkReply.UnknownNetworkError (code 99). - # Tribler GUI should retry on any of these errors. - - # Depending on the system, while the server is not started, the error can be returned with some delay - # (like, five seconds). But don't try to specify a timeout using request.setTransferTimeout(REQUEST_TIMEOUT_MS). - # First, it is unnecessary, as the reply is sent almost immediately after the REST API is started, - # so the GUI will not wait five seconds for that. Also, with TransferTimeout specified, AIOHTTP starts - # raising ConnectionResetError "Cannot write to closing transport". - - if self.shutting_down: - return - - should_retry = reschedule_on_err and time.time() < self.start_time + CORE_CONNECTION_TIMEOUT - error_name = self.network_errors.get(error, error) - self._logger.info(f"Error {error_name} while trying to connect to Tribler Core at port[{self.api_port}]" - + (', will retry' if should_retry else ', will not retry')) - - if reschedule_on_err: - if should_retry: - self.connect_timer.start(RECONNECT_INTERVAL_MS) # Reschedule an attempt - else: - raise CoreConnectTimeoutError( - f"Could not connect with the Tribler Core at port[{self.api_port}] " - f"within {CORE_CONNECTION_TIMEOUT} seconds: " - f"{error_name} (code {error})" - ) - - def on_read_data(self): - if not self.receiving_data: - self.receiving_data = True - self._logger.info('Starts receiving data from Core') - request_manager.set_api_port(self.api_port) - - self.connect_timer.stop() - data = self.reply.readAll() - self.current_event_string += bytes(data).decode() - if len(self.current_event_string) > 0 and self.current_event_string[-2:] == '\n\n': - for event in self.current_event_string.split('\n\n'): - if len(event) == 0: - continue - event = event[5:] if event.startswith('data:') else event - json_dict = json.loads(event) - - received_events.insert(0, (json_dict, time.time())) - if len(received_events) > 100: # Only buffer the last 100 events - received_events.pop() - - topic_name = json_dict.get("topic", "noname") - args = json_dict.get("args", []) - kwargs = json_dict.get("kwargs", {}) - self.notifier.notify(topic_name, *args, **kwargs) - - self.current_event_string = "" - - def on_finished(self): - """ - Somehow, the events connection dropped. Try to reconnect. - """ - if self.shutting_down: - return - self._logger.warning("Events connection dropped, attempting to reconnect") - self.start_time = time.time() - self.connect_timer.start(RECONNECT_INTERVAL_MS) - - def connect_to_core(self, reschedule_on_err=True): - if reschedule_on_err: - self._logger.info(f"Set event request manager timeout to {CORE_CONNECTION_TIMEOUT} seconds") - self.start_time = time.time() - self._connect_to_core(reschedule_on_err) - - def reconnect(self, reschedule_on_err=True): - self._connect_to_core(reschedule_on_err) - - def _connect_to_core(self, reschedule_on_err): - self._logger.info(f"Connecting to events endpoint ({'with' if reschedule_on_err else 'without'} retrying)") - - config_manager = TriblerConfigManager(self.root_state_dir / "configuration.json") - if config_manager.get("api/https_enabled"): - self.set_api_port(config_manager.get("api/https_port")) - else: - self.set_api_port(config_manager.get("api/http_port")) - - if self.reply is not None: - with contextlib.suppress(RuntimeError): - self.reply.deleteLater() - - # A workaround for Qt5 bug. See https://github.com/Tribler/tribler/issues/7018 - self.setNetworkAccessible(QNetworkAccessManager.Accessible) - - if not self.request: - self.request = self.create_request() - - if not self.request: - self.connect_timer.start(RECONNECT_INTERVAL_MS) - return - - self.reply = self.get(self.request) - - connect(self.reply.readyRead, self.on_read_data) - connect(self.reply.error, lambda error: self.on_error(error, reschedule_on_err=reschedule_on_err)) diff --git a/src/tribler/gui/exceptions.py b/src/tribler/gui/exceptions.py deleted file mode 100644 index 4816489857..0000000000 --- a/src/tribler/gui/exceptions.py +++ /dev/null @@ -1,18 +0,0 @@ -class CoreConnectionError(Exception): - pass - - -class CoreConnectTimeoutError(Exception): - pass - - -class CoreCrashedError(Exception): - """This error raises in case of tribler core finished with error""" - - -class TriblerGuiTestException(Exception): - """Can be intentionally generated in GUI by pressing Ctrl+Alt+Shift+G""" - - -class UpgradeError(Exception): - """The error raises by UpgradeManager in GUI process and should stop Tribler""" diff --git a/src/tribler/gui/images/add.png b/src/tribler/gui/images/add.png deleted file mode 100644 index f014cc1845495e6f6da5dad1d911baff1a4fae90..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 896 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H0wgodS2_SGmUKs7M+SzC{oH>NS%G|oWRDy)vIg-2>Wx(>pOP8zc-fP(BLp1!W^4_G956;u|-#Q^1){5)M8Ln>~)ookpU*M%W+PY6I6(Q8TavOujl>4oA!9K>eR3LlJ+ww zH906mNq`qpZ%rE|5Uiwt_8geWnGCkf8V^` zZ+lx}x8tSjUZv^N^0s@7)&tedkyv6vb~{90 zL&}Mtcnp&BH@pkkI3JkUR7+eVN>UO_QmvAUQh^kMk%6I^u7QcJk!gsbv6Zorm9d$& zfsvJg!B)nWRVW&A^HVa@DsgK#5@RC<)F276Aviy+q&%@Gm7%=6TrV>(yEr+qAXP8F VD1G)j8!4b722WQ%mvv4FO#rPSN`C+V diff --git a/src/tribler/gui/images/browse_folder.svg b/src/tribler/gui/images/browse_folder.svg deleted file mode 100644 index 32cfde6463..0000000000 --- a/src/tribler/gui/images/browse_folder.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/tribler/gui/images/check.svg b/src/tribler/gui/images/check.svg deleted file mode 100644 index 383b1a04f1..0000000000 --- a/src/tribler/gui/images/check.svg +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/src/tribler/gui/images/debug.png b/src/tribler/gui/images/debug.png deleted file mode 100644 index ab9362b0693d4ab0d84b30d5d78edb5fc23decb6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4243 zcmZ`+cRbXO|9_t=XLA`jTgb>b*?VLi&NvrQwv!og#+i2#*_(*U=FUhq(UqAMWfYOf zPDZXo`1<_$`{Vb=^Ywf@pRech@%sCfU}0v+NXJD7001MxNDp<+`2Rq2;e2{3RcoKq zg}XXP9RO%ZqyOi8@mveI8ljK?5O(!E7YhJK=N5h)0D|BEu;~N#i$ zcp6Brh}k_;ELMyL`gS2%NLv2G4>kF4C3$b9`I9284&u?xsqlJ-sZM)d_py`Gh0dLY z!1sTqQ5x^iMpqpiX;vh#_Aqi_l;wP5RUsz)t{S4fwj_RAic-z>C8vw=6L$xf(|40s8e<2pjM@41Es95a z85+Pd7`1?d^n>Kw1Wj`uy!2C_R<&l7b)iZCBe2_AO!8IDQU*S-d@XSk6*QD*Y@X^D z0*-?uu^RT`n)>4Ax~WE7q=hITJvXkd?&F_`gb%t}32NqsNbE*QUj=rQ#@@!W-Pl{h;&NtUR{mlCdW%fxOY|tMah4B``sROiDg-Oj^L->^q7w} zJG`rWE=Y44KKIwsd+e4TPRLBqZa2Yv#k(rAa{0n*QBkSC(XeY>b3M-l+#x-*}T*B{(HT04)=jGTz8b zE0;YsVcpprkh}IJF(3NXHk}SS6DUo)39b=Ly3h2!duci}JB=9-48Y}>SoT?e%#}i* z_!pI4`LK2-+)p^LSsv47(n^6RIsPIE6Pb6tKF!-MxMFpSf<(Hd$8WTH#!^=|bWetd zAw0VclJ`CY*G_0vRkwJT=Wemu8x`ifewe|4*(&QsrNrBRhk3`-8bpECi{A>{oC(xU(y;n`IPGza!rIPHmhl0DGuFO)@B82PW(gNfVuQx# z?eg6yB#I$>zSH*|*2kq@U8PhAloE3K52IZ3S?UvZtdxO+4i zV@3{Z7QDZK-+P>+&=Q!{-v`VatUqa{;`rkeS~3hh;#tJM3^D|BT6e|%ff;h?-Wk44 zs0*n-y5MA$ot`7PeEbi&h?9xx4fmmF=)BCz<1|p!b-T~MCH4O4FM91I#plnK6L05q zkJCxn5Tp^A1;qLz((5B}Ev8eTR+PX|}%Bj3SNuU27iK-Oi>{DUE!CK<8J(3u7~6gKC*aSBM=JTl9on|`+93cF_mUC)wQ zb)eBkVVg1^3Zex(_5>|esyKg9JzAQ2mj*R$5>@_ic@yRE$v)cgRfN={D7AJ0hBAKv znEAW3Vv^ente3B9g4Q{$_bE19zV~ zD#_t0t~;+ff;)^0ND0Nq!*=!Mc0?rKzABal)%MVq>Gb4AWN?g^hiz3&S2wO!Adl|J zVb3dGF5LlH9y8~f(L{f#6j>^5=jTni)S;0EEyF|KrTJ(WDl|a;jimiub{lTcqTcgY z8PuV^I+9pqLsn-Oklx>z`tkBj9v4RDkB>ABfU2ut!6+~MOq8DLpFQ#8Dy%7`rKs~6 zSX8&@g@Yk6=pU!mVc8=?L5w9Pl6|Wa{SmhutE=mX$rs9kh5NE6%eD%{h zK);>AvP|gP{rY-pA4)b+*F3(iG3G`kdblR6dNo{f9Pgc2=2Ol^1UXzD%JZ;U4+~(y zD;8Jbo))DraYEIQpX0pnHo*8+@zxIkK*w}!bLJ`i9bY{k@c|k@gwGc*JUaD;eAB{j zwvkSmZ)WdeexcHbhJXAmnVt=Ji*=szD~n{_TVJg5xQNGA|xMJXZ9x^e!7hE~r2F zH$j_3oBAiRe%xhy+L@gs%rb*&_o^oakqjhEP&t|(vSb|L$sw>_>61^ezu_6zt?8ag9&n z?$ugew+4vzqv??{9=a+x9GlteOep1nW+N*fyZMYq3Vkv z6McK>4@0YlR-bfni}`1~jg+(i6ysmJS@p@HSZy>O?nUsZ`fr)JveYz-Z<*KaXmJ34 z0D7OL87d_a;;9tW3=$6BvlstOOU|;yG?&FR${Tay4GLFEQcglIg-Svlzk%$^%b`ql zZ*YgaTy>@0?un&&^KsX1Bt;fN{dJP2$nLlMK#2A|s&T6%w9|6&Yh`S+eb1#Y=I9%S zB`zJtP_^Wxa^24=q5KK_`1pgIZf^G5HIRe)+~e&{l4Iz;+%C2D-FIuYAT_tOrD#1= z-JfNOoPr6nL2vQb4JA9?*~^?|bjTUq(Z<3}PJRF!lqcd|R;FgpY=_K~|G0uvLzG@Y zWv>Sz+8t6H6uC=ArVN>cTAT(K*IJ}U?x9mztRh-P8a(++3V$u+_H%hRAI0_6Ovu}q zzZ9TMG;;9{z*uh%p_@A%^7bA-s+($@-N^KADdX=@-zo8&WwQj2c@iYBW5aXi%ggW| zV{pN`t8#gwOY35-DMM#h{p-DH8?Nf_Ty#iF^rdIR$;SAtTMy53P-ghmUt1z1rhj!9 zfEDJlAgF`x3H!|VQe5^TzO7Q#$}P~sGDL2tx%``;?fHB~j|UaRwEgUQ^_6Rmyg7pC zic9QUCccvfUDbj^6Ejei*5RG$q0^UvWkVTg9@3HGa$9*=UC4G?A2-Sq*0A*J+9ISy zqn&Ob=*_2R+TIe%zCme>vHZaMzM~5jw${6?>xx=;74>Oi z%V$@`%=VObOVof?+l8~kvMNWb&)b!ntH3igp8f3$+N_FDoV5;|4R|mQsw&#iT7knn zHOpQkdDXSxGW9{b#kC?YAOcBg1S;-hnYxMs*0Of+3yllpXc0uDrIkXo_p$JkF0wIq zrI8vi`-16{x0<+glnL2}Y@4n=3|ZVub`UCNTtR=F`E3hP1gXI;vEsk|xTvV|&)&q5 zC|;c(5yJ7kQ;Db@s-u43?pjyoFwMoF@Vw$Ar;hd+kK|krrV{2NS)!utAivosQke6I-t<( zmu9loDr2kiU8RaeLo-it$|Wo7T%UKOjd{Z6^87%NPdT7Wd9iV-u$K6TGm6h5_@|b}9{d1a9`!sJny`4a- zQh5gZJfImb3I;>`qZTLSs{B@7#f5|Ecj%Xt4VrBh{cKv=r=A{JkA!=WC&|$?zAHHD zHimI482J}SbfMJren_+V!q=Y#&*Q?3&Pswa%a-kk8mcueyw(k6cbaYLW=UDyte@CB z40kp$MS*v57?1#>xiDgpLXZp@-%yaby-yf z*=z9Ys&KgT(xZj{Q}Fe3^K{4k{{@!UaF_D}?7s_Ee(u;1=Ku^45)vZqdCxo0)!82- X?H6$O$xn5z^ALd0H`A-rb&B~P*NvzX diff --git a/src/tribler/gui/images/delete.png b/src/tribler/gui/images/delete.png deleted file mode 100644 index b686753ebd2ac5a348badaadf21998912113222d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1283 zcmZ`&eN>Wn6#ika0hxkXnwEjnFlQ!Wp>ruvc)eiFxZ zk2&dj=R`}&S*^*ag!4PP*PCdK>()F7CZ!i zoC?5*0D!0p0K6m@?rq(O4xGM<=db~|q4lSs6<)$k%|o5~<^!{7&G`bfiIv4A#$rv7 zkBd81+@FTB7yKH>W~CIow7iOz`ml(H`tKg~$SQXeb+aA_(;ja<-7h$PiF-DD471I` zTqo*sbjgaMXSE<%T!V0ykn^v)mzU2}GbVM8-tjt8hYT614U8Vr|5KKURX!ukHdo4Z zRoj%vu+7*@njXP44SZlrehDS7F^(6T=tbq#6a!W>V_&AZr^?q1+ha8^RT&za-B~lO z+O9F%Q=Q^t{M(d{#p;lY+Um7MCtJ@M+59R7OJLmVSKDdufBPn!*gH7KF?AX#dNV>S zu!iGDZF9saYdub7E5fs}io%6z#@s0ZF&en_o&%?D-%t{x)o#y9f$p|aS*BpUqDxQv zj((ljSh+HN!KW|p4|xuG&+UTkBXw2s72ozmYT&qsZhvwd@5{*yR?oqH7s(*vptr{p zBU9^(Bdz0z`)hw(Rjo~vW_vom$Vyya5s)n`wcEls+aN84OCs-=AN8Yse$6`|bK4Ce zZF|6svH?3deI{Y5Or+#*+4rb_Us{r~JIMLSDyk)@aV1&W{>W?B2C((OwU&XK`wxG& z6w?xHD7%cCJo1s(i_RTZJkpSlA1x{Qo}1C)3=-k{-V?E3E_TuWpo`BBp&$!exq_}n zC%t=x)dCWrQWXKXsDlw~E7R2V!D)|2lXVuHg^7Mx&#Cmc)z1vdW$zE}dR zS0`rSyW?KWLgeQbzmq&*n8vvhNdrWi+WsY5!0yTj=w*?cS#*!H%rT}YdS9t8e6iie?bZ|@MrFMox5f+~SwTz(I^+IG7=fonQP_^aAPg zr{Iyu=iv>87fYLMT8gkcm*%P{JW%r7`mpA;kx+H>u)Sw^x}9)Aue0leW;zJxCT+QL z`RE!Fj~X-g|WoF`Cdj9Y-roomurF|Mu=i z_pYpl6kh9z-8{Yi*>+z{e4%gKlZ;F=lwdPM&VN|S-SuXj`_i1Irli&iLlXavd4Pt= zbkJDlYjswr%VvcRWh-j%&#g81V=F=tkI557JkxXK%$aJPeW%mlz~7fH9auGa<#Nu` z`ftW|`o6z{_5D3&A|i_!C_Uyh%0d5qwJdgrY>!Zu9wm~dqX1~sFnS1eeF%-75*8K} z77?{RGMGw>qEeTCT>RUA1lhTJ#2NekUm#XQ=b!@bd56z(Gh_;(G#w}uign_g%si1$ ZlD;liy7&CpMjw;};$oB7w>Akh{{pcVDB1u3 diff --git a/src/tribler/gui/images/down_arrow_input.png b/src/tribler/gui/images/down_arrow_input.png deleted file mode 100644 index 4bdfe7716d6ec9842cb83b3310e93ca367fcf4cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 564 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I0wfs{c7_5;mUKs7M+SzC{oH>NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lweBoc6VW5Sk<^j5`ILB=A}S9sFfcNCx;Tbp+6zPIR^hpKIe%*w~Vg)h9~2-$KV zbE3o9nbAszYWWN139fiqsj9H&E#oiF#_Wb8P`(JQ9QWN}E zeYrpx7(l8et`Q|Ei6yC4$wjF^iowXh&|KHRNY}_X#L&db*u=`vNZY{B%D`a9wF)K_ z4Y~O#nQ4`{HJt1|un(v~5@bVgep*R+Vo@qXd3m{BW?pu2a$-TMUVc&f>~}U&Kt&9m Lu6{1-oD!M<8UN3{ diff --git a/src/tribler/gui/images/downloads.png b/src/tribler/gui/images/downloads.png deleted file mode 100644 index 05fb4cb32a91813cc029a95e757c91ff7d8cb10e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1819 zcmV+$2juvPP)EX>4Tx04R~2kg-a{KoExioT74^m0}?lDQpjHOl5N_1VJIj6O2~5-5eff*O1L| z_y)d-_b^uof=}QRC|LVXw6Jg+cNykmnEC(N1v+7=j9cCVjBV;T9b{M8b-4P5E^m() zxpLJYNlxKCpRWiczw|}R-}mQP(S=jY3x-uy-2lM>Ihr+P2j!H2ONkbC1@%U}GehFPh)R<699PVMLDSnXu{T}-`ZzqGTMU~fiTUugt#$(|9 z30Tj7jc1^Hx3J*03EwpE30000PbVXQnLvL+uWo~o;Lvm$d zbY)~9cWHEJAV*0}P*;Ht7XSbSSxH1eRCwC$oXu|=MHt4PaS}H{iIP%O2^9!P6(lrj zld2&lajJaL76dJ*twJy5f&>Tt0CK7X+~99Ohznd10!8Hn0)YxbG*S{#6(|Tw8mG>O z%ipuPl!T34q6$b!cL7R5Jkkt)02QR~e261%Tts6l%otdYyl* zhy`)^k?imrfYoNd&v=n$x+VezN4Qza2KlW6fKK_lIskc)R*8mL>y2zn@Bm&Y=={5u zL!P?t9UhW5Q~~f5({9>Y@8}A9N^5EF4y_J=V_b6C>so*lf$bb|_a3&j&R({a4}iT~ z_t0yaBWw=_!0Ys`8SA0)0PtDIjjy;o)Eav^4Zf{Fty@fG9w;Z&#^;E7ZuVX<9`Goa z6?|YPG_pJ8Z{9qBy+f z00>uCA%xh9`7y2F{BgW2W`o@byO>gVFN?e(yIgK&cdSnNWzndwkW%dOYLCK=5k8F> zL=;-mWg81bqr&s!7!h+Ca)M%xeM)6aZcS=LlY;q+IPcwj#iVHVE(b$a`=7?R_#Oei zmEG9@(M)z@R`;E=f=VDJ<~63pn+b?{#B|p46?OiXRLw8*+8q1v9a<{x{Da~b_2y-L z7}@OLGgz&t`FGJ*j`@DX1w&k5rL@4I5KKY#D}_eoE43A3hd|aVMRSC~Xw@1C(KgH& zQw1}&{>$QDjQKNiA9r#luwN7J1@W+7kMi^LcuZ4tn8s269_AweF^37n12;d82@ywF z#bHkgOz&FfFv2bN1hyDTrrCqA%W9C5Tnq$2#_sliy#Ch+MdU49Lelr=Ch>g3JgP-J zrj*bumRLQ8>JkTd&o}e$aRAjPPAFyd9fDp?pgP5#UDZ5lx>*G)e-Q+|Tq(=^751XK zB`=k6SYF|2RJ&494mp-nl~#@?Q5%v+dE0By!#_il?(k(TIOFll^3SL(r4Pu{9wtIA z^hX}A%0xB=Pq@zYoj`4hXI)g7C3qIKD-M>}6mz<(zw<6kSS<~ut9dp=S!u6au4w>2 zVPAniXE7{(EE91HN?VZ=YfdNmcXt=RJ8H_8iQv2=Ec@a7oN}?seC?bM5PCj#ifK$D zcmG?#q?pzfm^cEEcXa*p5^)W&JqU7$iEEWumGS`a7HnPJit9KBYa4QY`$gZtPPntLKnV!@2FPXMmss+j@? zfB|3t7yt%<0bl?a0M#p5-vEdWGX?4#K%~9_i2N#|`t$(d44__M*mBO%Xt5tnbpfCu zl6M6oFCoIO@EV@Ol^QqyLwth;$Jqb?Y}IxFx>8Dr6pV~^^dAGO``1-}CS(8r002ov JPDHLkV1kRiCCvZ; diff --git a/src/tribler/gui/images/edit_orange.png b/src/tribler/gui/images/edit_orange.png deleted file mode 100644 index 585879c30da0821cac46b0f493a8f9a13315b755..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2088 zcmah~2~<<(623`TLqL>81R7)Nf)BD|3NZx4u!(`NDDlCgBq5OqA%O%4pnxFHpa?t` zYk{(eRO~B@Lbdt=4T{L(0c5dSDn2!?Efjl$NIjmO^Zs+@pZVv`_sz^d_uQMs z^zkr+TfzYVF!b`I`-7UP5j`Dn&sjfH3o3}j-@_HC>#?2y56^fkFFu0-`~$T003C=W zpsfi3`jCKPL!0IG}vnnIqGjgkqYq9qiW6M7|r0@@lk4vks~kw!Y9Sqvs>uPBy> zB4hDbJemeap-|M=a6ZMK?!F2KPfq9vsZ>nC;Sv%Oun8orDE2Up;OOXx!xM2tA_k1W zND`x^Y#An6^65L0_dIl-gc~amO9i56l!lkh5yeTJ&}dDfwY6HOP`s8XTC&;|*dI>D z7URIX$E`7gKq}ZHWp6BxEfvKEibPSg|2Hiv?qkGXC9IG!MBxH{BAqSe(KOS*5bzkn zuE6(meFy&_<|Fh+-Xev%~Cm-C=y8*0t!3ogdAn^S(X>bmS^BjD? z9(&RE1j-=4{OlWH=3%X?eyva_OzoR&ioeM$qc%0wcnYV({4Q znA;5v`g~J+Z_}f|E9UC@#s~gbcByf$4qQKEcv`DG%Zi1tGnqMFl-Ah%;MoCjIVY90 zHD!5Ws!{uBLo(u5OWtRumyP<2ebedgcAb_{otMVr0_;Q)riO0X z59IpVT!}O-794HI+&g9d!jSzh>)jsWPmr{2E|z<<{d>QLM?1Z8J2~a|vV}EYK`nYp z6V2^R`D*Hp{;cAN9D1y&*Wy38!>_qb|DDwqZnR`>!p|8TFR?N{N!YjXG69`cer}?z z2xTAlgJ5$dvSi|F`bD1u=G1kYrz~4PnH@Vnmu3HuWs2nKkv0*61k;Y&WS_87tm-dpICWK#19 z(v7H~z=P^0k6~2TT|lVh4(jp3OW$1kBe6xymi{F!EBlXi#niS0l}N<1a zo@qv+T8tHpMmgDYd)pHIz=hOxuyYlQS3MY0!*!X(ch62Us)b*u3-4bR?T}CmBgbj4 zS!L>ls58M8h#0={#);l&Y{$I*^XnG)B~CV?qp14CxnL@}ujRx=T{ZF%kXo`XU+^4H zyGUNDK3eWl7D>$svdSx-+;Ri5{3yhE#IJG#{jO8lP8E`(a!ZOI4TZ}yl_cl|=YgO9 z_tQ+iyBz|V9ASt~ee6!rG3>^k|o-?{*XImQ}U{=Ioc5Oh8@Whb`Hb(A1SR&3Pkh_gSE zn|BtfYvh^jTzQ|lXKM4IuVd?fyEOC*$<2<6yt5g;F#lHeRo4U!T7No1yUaW+_~~-) z%Nk#{{JttiRf);#(mJD@6;Cd@w9S#NJCZhqI^E-6D}avuI^*_j?GAGCeMd@}uDro? z{9cB$V63ixAZKV>1?iQ+sio^iVO1W$Gl5@R{<80$qLiSz*cPVy>`?vm_Z>FdpCgT4 zRDKcWt}n;U>kfXCV$xmH|GIUk?(n%I&z#G@EUmG9sa2Zx!FOe5tJ=D%>i0n}mmiBY OKQk{kA9|f@So)v*xG8%8 diff --git a/src/tribler/gui/images/edit_white.png b/src/tribler/gui/images/edit_white.png deleted file mode 100644 index dd9721e6077b18d9133edeaec5efb8b1426e01e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2621 zcmai03sh3+7Cs;Ef}+k! zuUJkr1EeHy_#9pWCke!1ot#0tV0sLbt+v<$0L~eQ#bcfESXU6c2Jh;IU+oG2h(n0a zIXWw0Uuvy{f6hm#)cB&S6Mq$H4IYODf*$gXAeT;(|7IT4Dy4eCNOoc&0BHQcq(<^1 zgV*C@I4mc+x=<$}D@h#*APDi0%3|>8ppdnL&BF^l&@%`;q^r#sG&lp{CwibGgDIdl zhsyw6om`xp(L`Mk1PR>OID8n{ca9Fdd7u;c{3JXEBM=Cj1go7m+;|Mu-Q6AIjKkn? zju66;x0B7M3mw_KWwSv(#UV3zFbUf1PBPyEjaDc6e9hI#O8T6L&70d5 zv_FiHo`ivVkNFmfA!L3j?{6%%=esmMGwwSywP%(#(~Q{~2~bDz!JJrT+)gr`&mgLg z%Mt7Bh;^lYI(P!+E6kT2&al0?3_72~rE)ksi2rZ0W@F7~d@C5^TcxNcXC8ePGmowA z3ZB9gGInexGg%Ba4>}kkc8xOu^G(k;nUUXR&hyM;s#}T=lb1VWzJzMi;ZFZ1ntk5JAb)lAg`c)8hPa(6QKOfD?0J0ZV z2U zy>Sk$GB}ogtgO|bX}Sqe=qLj2py8SEW5jDqjxo69vI`*xlG+x$9^G+KguH9KYh5a= ztBB|ygEIZl~&;*x}oX*jS8Y&yIqM3Gt;VOoceHQ6%Z_L1sclJl#Y9SrY0XX(Sk+5_I2 zNW%Nz*z3I1xc7P+#2+(KE8GGVt5dR=ngwkY$J|=(ZP$5-GC(>VG_)6DCTbfOw~JJV zj`SImcPGnPOV(Q4pq|v(wDDKe&_?a9(u0K(+X$}?P8~RrFuAX>S92?7ZL3bQk;BO> zn1|!NYYk;x%g(SnC*L-Yj%7?-IolP{HRv-))ob+r*!l16h3gCcd?68(co4SBo6~4P zN3l{lie|%#5Ge)9%0zAw>4rg7N8KBSuw!^ZO2aLQvhAfbvO`oLuD-{twV-KK*7c!k z@tbM@d?N6jm4vOpNsT>|11xCP<=IkH4U7_%k{#i9dZ|LiZ1DdN zj1n1MMbf?`DKnMU7V1ljx|UP9Zf4#^nGw_dAME%*K-mFtLo-buHC%YFZ})u*1387H;TNKt>euFe;n-#sWTaau~1 zT1`BGq(fAr>=e2mtVRtN;4XTIas#%Y1I?>Ex#=v zf4+Vhot+?Rxr6UK+6zkBwN?x@6cP!;MD20VaB7WV=E`o}*2dnke*`P`6I$NvRA|TQ zB`m1bBfq~kzV-lpT`E74v4}L~a5GV>MJ(~z&FtMv3^kRyl%BXRlShOgB;dtT1#lMsf8u7yMlsu)0|bgS{S~50POJ=iz`=@1fz_z z8g|84*`OO*s~K}Mdq6NSRJcEU!@%?}4q?h)tdCCIGXEDwuv{)*MO7Nyt)>VaOKatf zlRr)u#qZ?tgW6uX@wMB{o*C5Uy$h#)1!BPbhdqTo$A$%IP{odY{I)4pKslRFhd_u_QNZT_13))BX Af&c&j diff --git a/src/tribler/gui/images/ellipsis.png b/src/tribler/gui/images/ellipsis.png deleted file mode 100644 index fa325670f2eb921f95a76790825690dbff81e0fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9740 zcmcJVcRW>p{P^G32ni)2iXxKinO#|BXUmRAHgWBHTgvXH?2*dKmh5pWaz*&qd)^e{ z+S@(9b9^7)KYxFI`Gd#%zR&ycI`8p%zMdz-Ku?q5ILC1S0K@HDYK8z%z`s%e+GFsK zjex#A0Qh=utKBdT7+D&pdt+^qEHlMGRjRK3*tFSJ{TK>&mWqYt)Tt8!0(nc=jpjlr zfv1teO(T(&@879;|0R2SIH_HaDdHIsl!?6K^or1}B_4j|`lU@w9VzDO&0B(P=w&6p z<@LhN;+Et2TK&WOh3LuPuHjskciLQR!;0l-;!DD9+-F`kb&w#EEyQa;_-CV-p{%|3@yyME4L=Aey)v`F+?ZI7Cw^Ojjvgt@n$fXs*UV5y|r#OONu&r-0r`&)P zUa{FR<~6o%b&Q=;x7nu%Q@3#qcw>{g8!`WoglNn+-x=XXbv|aUxL#wCm z1S#;#?0Vok2oH0Ffwn_3p^8(efKxllW1e%0XQ~C4%Ar{NXS0i9p}zFn$A%~Hqgy!*y~r_{zBet(F0jHZ+N689vX z0+m#{o#5nQq;PXwWsYZRDLc9K$|yI$(ha|pTBjX*+JC^qhVs1nYmB41*o6WAUdr2k zq--JdTNIW_#_J$AbpQx;6mSY;c757xv7rhne(W8PJ%(*Cs}zl0SX|VYfe-1%q|S5EO$xi&*L7^Fp%T-P zISOFCUTOF39BM(q{gl_OwV`uvwUh?BS8BQnRZQG-MJYOU&Sfs{J22v320PrV={Rm{ zHXI{Fv8;72cQADe9PpHxGv4Ws5eDQd-f1E-ODK&|(}--Jh!l^IQ!53{t0Q9@H0;(E zoSl72|Ef&?ixCAj=0RP?1oqEeNo4S0J^~l~`X}+krsBK|ZOR#oHWwfkG-0?lp*LYD z&E$q!dD841{InO?ZKS0d^ZY6J{aBQuF;&XnS&ji{w8v{t@M5Zr+9+*7{YT=`P<44P z%I?vDuHoZ@M0u_eo+;l=#0phK7OvawA_jN)J?N@VDxTxef3)=L+8+R{R$ek409{rOQ6}g122x7u5dAr{l|VAFZP$5I*Uh z#x4(E$ht6>H!>gs$Zy0N7m+T`QO}-K8}<7KiDGrr`N8;S_nSAy-aWLe1JzgTL#q5! zz@sikI%@CaPwq<;Q<2YBL(6)p-Ua*X2)xubad%|{iTu%|SY+_FfRo+QpDMr5W20LY zn+Prx-Abaj1YW9qLhkp2`3G7%m+tzu+iQc9+xbY-vOfD{Od4`ar<*rAOunQ~(Ed$E z1`51ccKRJP{@Z#0e1YsQJ%I?eywQQTm>*nB+X_mcc5$0dy)5o1Ohs^*8jvuF=mgzvb@U7S2p`K%GDdce#qExFeoVJ31J8>sP53Z%Wr?w9nCS4E=RkWyfCQs zd`rk=)|x$jMsb50Z$dYF?edyqE;ataj!Vzg)}p?Z?w^>^kK*+Ncq+?F$WVQfyK{!g z9VI60a$<7!5nw-W$^W+X{HK5i^jL-XA6}C1DtORV9a=bd%TIA-4TJ6(vR;$|>`GUv zC^g7=uW1@VHJ{5r`^wS+RyCAu*vik6rR&i{t?npg838WLQXuKQ`+`x;HHK*gV|9J=Q^ zlQfZVO*uDZ8FUzV(ga^Ca}+TicPSx@YMo20!RhLt!5lj1-hT%qe#wR4{C23CD83u+ z!ieW}H}@%_&Lau@EBujIeSp>tGs}rx+RKpx^gcdoS350mzxsCXC1MV5&2SY+<9WmECpOt0O+s~gZO#banSQ^^LDdD8dz_T0IL{mRqScxi@nP!+N<|9nH> zV|dfew-Ac^4YePO-EYDD)^P>=?GPtu))jxGePLbM`s(;#V__g7ibF zuypMqD;)9sHME>29jecx%x^>4nd)u24 z2vvUJMavo+Y%a|^XjUIE3I7V|F!a@*S5i6&5{6Pa1oH#s&{XYEeR$mbqOK*7NE`C9 zKU6M1gmW0iY&3&feCt|3?-{JnW)PNJ+RAG4hP3TJuU)8#Od2AJAKX~2Q2}kHF;@Lv zkAIDRS?*>$11aMyX}rdYg&We(p)QQqQVM=fu~qX=ZEk@+^>l=5(f>4`w=SgvF~d1h zXlMMBwE}`oeBO>NTg@YEYp$}F%hg_H?2Vy|J`m6@o?9c_5Edan)t0Q=6&BilJ7Gfs z#Kh{juBsM3!Sbpx|@=K^d}@ zM06p2oV$(!7?}#hEm|7BiqjeQs0x#@-4?ID;Pa5^n_Xq!xq1jGe}N#8NGpq!(f4Q< zUu(zEUIwg;MT6A#%7rVxb8)#TPN3qyS)NR@f%#oSVu_9@7+JUO1{})&b6s3O?H8kxe7xfvFAY5tM*M$q4Md#`guycqZzt@v-#;W{@?`@vZ$;z z`1^VW9tsU;!1tzmDbS4QP3Y;x(+4K z_)h|7mIxn#n%O&|R~m%FQP@&z;#kkP!m+S1QS$v0(m?Uf@{_k20yA`5vON79%u3ranwD=q}Y%)f-lM%2r#;Rw{Y0`1P$W1C9{wNIya4@uTg zObmR;`~%&3?HXi#$5cWeK^e~4HPvIUG^B6GE<)(T&zQ)1zO|o(fx{B(0edQ#Q~uAt zYX5lSoYiDv-A)bjmm8^!Za{7y2pqUG=S4u0;=`{bW;uzqVFH@ROno^fTGVMd!s_g< zKNNiaRG>QMF#so4&m3y=kYYow`YbZPIe48zw)~?paVF3kaz+7;awp;|LLhGcnE6iB z&(K5auQzbcocsVzc%sj7?FL32$Ji%x?z#|5d-iWpfTVZ#wMjGeM!dfKw^v04m^5=V zw_=4n%~5t=GtS8g`t)8BIc)tIla2PC`5Ar|1ewN#yx%CU=OzU#$A&1bNquL;=iQa| zv2q!t4t-khwL(RY0vfju6%V=z*1YBi^dp{AL-*D%0(p-rf^;NwC!dpb>0uD)me!96 zx&5E0#AfG}z{l(I0{-}4yiGA{+inUU}9v@*h!PzSwZoxC?P6D%U(7?e=ANLRQx!tMJlUz7yXZh7p+zt)$#XF zEjd%^8eJIKm*;A-2dKEw&_a66`j*ImTjy=n+={Dz)|xm#N|#X8FnL258{%i8koq|BG;9ldd}(aHH@C?tm)sH=+f>rtDdviN4BPS2*d;Tpw)|U!J znH%a_<7H%hh&wN%99EXeWe;Z9p}Ki`{ObT!m*sV`Y>cGmsZxCx56Y|tT?fG0`Y^j{ z+{6E3zbl93_{~E}FXVK@jDNd92W`**>XY zfGzmcN)F+Bn}c`K5x&qv%UUAoANqEgMJXoY6#?CUNFxrDqY+H6!6E$CSSH(MX>3mf9S=dlY{aKX&< zbL?Nz8)>h}c!w;6t6qgk$16~{;Is?srrj63U!Qt|!xvzPoie~Z5bSeKA@-Vh^wcO_O zd3~2_rw(N5a@0XSaQ6ghZPE=T?S}cTzI-ED1B7T4;naxH_z(Vb8YB2H`|6|FtK9tA zEMbbyTO8jh)8RJW#RX{GWVM?WSX6msAUPjH+*P@%*D?7B(Rk>xx7IRjL?XB{dU5{k z>}3-KVKVhN+LzK;!T9#_$DScWYNk+Bsb7yd*Ajv-(Or<|ou(oSs%XCGKQ+je?K@*b zl}$?)z)_Lgt^>|g-Ma+jJkLTO7TkYPOyNI@ht?u#vrV!w>tf!-fDjna=IBjN=K|w? zYB%qUV@pBNd9p4-aE^zbuR?oJ=ETgiby(j3^ftQ=t`d; z>_OJoxZOR1M*X7(cnT$ztP5NQUy7`OVv<;6moLIArZBG*9P!H5v(@pjBNEXPc)57Q zE0U*wpe+#+sh7&S;B*8-Vew*(3fFXj(9t*e3%F5_f>A5`GrRz>;?=R^Hel?I6%{R> zqMD)G9qU0!#eTz;g%dSQ`(QdS1^JUqg(@kQFIl1}O9i$sy=m5lLCogRS@Fb!1SEXL zf?WSY9kW=`BQilVN6rKs1&R&^m`0?Ja_Sux!74u(o}Q22Mo2`SCriM?7>UUkOf)Z$ z$hjuI_WiOW|FBB>cT*V5m177qUI|92Z5cJB*Me>?0C??$vVC4Z^`TX~rCv^QN*yfu0seI~&3%CCaBS zIA~9e8hc%yAU9R1Tn6l@Nv)vYJA%@R6>AvX9`=kSghgmBzipjAViC#sA1eSM@&JEF zkDGytEP=7|Tmic!XOiQ^!u%K&dtscp8s}*vAqlI&DAe4 zBTr^ZIr&4B?Di#kf~UJ^*gFa!N#<5pSp-~kBgT@<1x3wzW8Ol=5wnj}G5=8P_L}^x z?fsu>%U}YFnB8FP<-%UpT-Fmx+|FOzSc9(5>~usHx{Xj8gesQVE%g5$&0m85?b~g2 zmLKLmAUo87Ut8ey?)tI3+Fn*5%NMI1y3%E&3j@nGuK9hwoNcJ6-!4WWP>0+iMKgsn zjTuX}x<)dc)wUu-Y4C=qW-9&*W&Y}Wymqd#+ni@VOe;{TcIWq$xvo)NPr%i_hPxUc zm1~2qe1Y(azObDBUaCssb}K93GMWpQT=%hcwA^eYtYPdS^ZVRTB8)6L&1ReeSqefF ztQKUnl-HO(vY+Sm^HfP~=;4k1puDxoqAW9VRlW9bCayG&gSv$Z2<^s==bzcZQY^I5e3={5ja;4!Mv1px3%6|3s0Y#O2Fq5 z2WO&_awd7b@+)ph6UGB8pQ`Op$K^OTrxA)*)4~j_1NAht=M^Y^L*&={cA3<_J$1~0 zL}>Z+%%>Xpa$lOLrsX29B@jE=3ZIze!kluhfX~!-8HOwV!&VApoBcsr%f4~`Gr=e4 z3ee4COE|oD{kdt0!cKEXvSeJcTZ@XrkLbf$XO*lC2@sWu9p63r`#JxeE!5)&1U@D%;bj+Wv*n&DF;K( z|3Yf#;qs?aom8DB{+o{_(Fj-c!hCO3O)qjx`J;nD+~H>(RS<5!Nr=99X6j&ne2{)B zHqz}syXlfM!zCNavDJZuCg0F{lBA9b2+xO~@XSTFnS=b)e|^?l3+_=g8aAe)KT{w4 zr=O{OD81>rXB&JI3LYPOKUre4@<$Q_?q*G#_wtX@^08;)`4l&HXbq)Dojrt#F*44~ z2M3!Rv}XI1-GZB38~Qp=n9~dMQxAk(Q&8Qep3!3krAFIZlYW1+?gmwv zP+ZTg=kdvYZ#(rvSyWaD{4M{j?Ww86O~0|lou|NFYZ4W)C5ySHi`Hv-wo+x0+>o4B7qQdUWm-eLyA^x6QjgG>0k0d3pQMx;ZAAJ zhP)hu=Esl8wUkoen~P4#Nv*{b#%>$zZ8{%#6Wz17^BjTQ6{Ug=uK$AdTn0ZMTfWk8 z`6AcJz#MxD>|7!Hu!zqin2ko2JPn>>w27(N=!S1mk(N8aE-!;?SZ*yAwiD~TW^efF z>fVL@`h7^c(=<4pU3Dj}Y7=G0Y?0M;Js`|6iZGCk&YIRcgeKlYP{#5ZrT=qafp5^Q z+peB*epsg5l30ZM&a0_Tmh-VvfySWE-}f=jRpdd%0!Rs9SyPyY3vDO3<8QcXwel1Gn-lXDZuYEOUUI#>%ND5 z$=KOlV@KZa#{qpMOrV4@Mi%JF+ALuihd}6u;)H2e-lq8n^P&PxvHx5y0(@T3ePSZ7 z@j+Y%M$=!M0kEH0RPW@RhHXrvnH4X%{zB=Z&rQeJ9^0vO%2rKN30(w`O}BN_n+R6i zfzRhDuCv)}Vs4a)`YZn#2Qai0@t9p%=j$Z#h)TtPB%KKx{=wx?_g_zOPjxTDgzk;Q zn2pWvYR$XHoB&0iHHggj$IZh|S&YN}M0X+dpX{5h#+a}`;*0Dm2%nFO*Ctig&F({? z4m&66Y~%d_T>UFUSO9a*2s2;#Ipq@7vm8FlemTIFN0t=Ya=@XyJIhc&A4yPAYhs+>)H~?Kcy%F*Lhxz6bM+Cc*k7U%8F6g*r2;&s!I*ZYjBQ#s*s5{xSWuHT>g4eY#(nA}Ze;O>=8t^`6c zwVV@53~&3=2zO?A1;e&gwS6ra2kWE;B;g~0dD9nyJo4UoM7b#_&uEbne&#HH%0pkM z<^tH3t*r3Kd}XNzPrc`aDHgtmdbs^H$8;m*F*lCDyvH|dLq;lGMhgy`q-13L@109M zo1GkIAtQ^B$rYTfw1Eaxw7>?-9lq@3be7yCq)>MXF4LJDSrvX6OZkq9V|MlndOm2r z#I9dBX_NhDY@jRRK!1%LKG8i8YH56BrRXPKmVejwpE`bB+f$p^QMOZf1TH;+)kKe_ zeF?wD<|u4>eCw<}?oaX@Q0z%=E4dbDGOOJ{yOT&1m+ME&%cf~r2TlKhL1?Zp1zFPD zYJuJJVJ)Rtmqi5MdaK1}r%-FZKS>Dw@@;#6%S~Md#)}DKT`AiJqw^$+eJ=XK3r_`wP`+oC^fPxMll$-t%7!LJb| zf|CUZ#T-mFugc>Q3zNwIwvE)kj%P(#=rh(g!%e!Dml{(P%@Nb$^ zR6Zf5r99)z%Eeq1{(%L*PpDx;y%8T%2+HdbTsj`iqzBP2i(Vfm`uizXs9zuW{zL{X z529lo;(yLknJ89J<8$FmkNwEK1R4gqp7|zmUo}cbhEfh7Cm)AVkJqaWTz&07-xs6Vs}qw~w0Zm0ffO{a|_+Tx5>C zS3KfA&SU5Ct-A3C(yx`DrRDxniro&=NyAZ&&6g#hXLLj-uv~!HG`hszppJuXzL0E=V zc(B}gprP6DC)nTcv0F(jjSX4hQq1_j88QZo^VKtquzH7Bm!u3S|6GJsx;W#tK000f zqFA~%DepbD<&RVmF26~8LFh}%PNAs;J5D`G<7WcvVc?NUPc!EC6Ww+1_nrOSWZf2+ zp!!P+p%`}ySe<5Mz2~$AR&ezq6{%h<%`_VZ)*(8EN&j4KCR__~T%t3ObVb+R`C0cR7?J-r&?IKSpc4zYm9ytM| zp-;=)CS-2UzvfVR^$HWVrNVx`A#_YlJk(+dsyS#H>J~5No^^BMpe((VvZE7ZlXDeX z%Vgl1e5z{;68a=?>l{M)sk{o&cB>@zUDc|H4& zT)CF+c$cEP;x~^ir(I3I)tO;5GYckkY`LFOS;jU($n`FTSe`mQpHieYMPZM0-EHuF zPYEag`Tuzv!Y$IpM6TKto^+&sqi{M^)f3$gEi$clE{2e&z%>C19`KTUfuU zD)cq5rpWdsjk?T(cPp7@K?;lM|3OvZhx-{l7`9^|JSU7Ca+6-mo2+Oa#5UdkjcnjJ z2^aXac`VfiQcA`K7ocJ7JxnfnoN2phtDL<3u$SZ?vYM;{gbgz}yNQhG4;evjlTI*< zXp}^D7!J(We1?{(K*c~UZc<>WFY3IkSP@7%kzp|6?LFnF-!SCKhoFfQsizKYn=ERx z&??|56AHgaGZ3v~ws5)=&C@004*vGkp{fLTo-g}U+d7^#qQ({nKAd;|z3Bh7slI_E ziR;&40<|8!gzdMOT^mP-UKIAuXKUgMxLB?d^UrYy#qL+9LH322#O6fJ=jmhEriwsf zJ8t;A;Nf0fNd88~1zI4~EQqVpbb?YCCbLa(k#JI7AG* zavUcsP_n5lyWg0eJS`{cbIbAkcS`Upm3g>)gG=?`3&E4umqTd(g%;_ertPBW&C0*f z34ni9baMF8S_)wrr?1`_Z8c*VoQUA&=4(Wi-_S65(W){ii?Y@lnD)iix-Yb z5GqCuQ4lGj5=xw?X=>eldoSOY=Y8{i?$3Ku^!e!DL>*7Lrc%*P3b?1z&_$Z8HkE=d zJS~cjD;(m9D=JR2jM1s6=)o_kXeehLzYLcZ1#$lNiE0&(ATrecvK%sc8;2u?7_ z1vywC+!~jZ9|_>D94un9L#uM5j4!|)Ihey3XA{34TvaZ_$+|BGlNh(?Cn!aO?07`h zgd7ZE+@OLaK@+lLnyl|sOI*jeEm6q~ei<&xhR+-skT}T}o?$tN@fN=U+3<}c84?mN z@x0a_oMRVbRyOS8N%MqlJd1KMijk5H9|-qRE)p1fvf(-59?L~9#=30iCCq!y$i)=K zE7?%P0YO`gP$d^F{K9x3JDw3VN26Rcv4ZiF^RlC!O@a=YrjLib-~hidR@z0e!w?6nZKu Q_5c6?07*qoM6N<$f^TriCIA2c diff --git a/src/tribler/gui/images/gear.png b/src/tribler/gui/images/gear.png deleted file mode 100644 index 2e27cd5821b3d42d575b3762f98358a47ce246c6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2499 zcmZ`*S6GvY68#erYEWq*aHX$AKp>%afdpxSgx-5VYCWTs007*kCRl4Gl79y~gqZ`> z|I%jy#NWWe003I@xlX*GOfK$YVr>Bc@zP9P3IH52TjXT`h(ZCtswV(wl>mSsvEmg@ zhdBUyn;T;RpgvbhiOD#KCQcE|O;f)EWO%VWmf2*BG_^2dqd{R@96&1ilOX`G7n))X zY-9gja0?2yvlDhZc=7pxfb;brt}{8s={z`oRVCX28jGa8VMPwKv`R8fI5*QfNzq%9 zMJO!eO5a~|dpD%k-5{k;Xy~)T zY3-XJbUNDUO_V1}*00wR>(5}Q4OHtS*{Chd0*b{PEgSs#a|ev@A$c^VyM*Eenx8!(m>80i80TY*V}(-`hf=;)-BUV1fnub&##QzAV zTy1C;9C-+N%u${#Fx%}V1G;sFS>2jM|HJ3$L2+%Uy8HY-a2$pTR-<#0EoJmuD*((# zxnMOoLK4|UJ^v6f&`z0KN7@IW90=7e_GAyMRQ#dv_v@NRigh)Ltkp0+tl;3vQ?tnu zy)0tgAirQK%{$L8&A5S1N|HAmAG|y=%5Bg7&}JmNZE@`=Od>x6TY;3&M;~z1J}aME zx#q;DlV1+07M+Vbg*wU>3`*}SS5wsvj*Io@Kiz_sC=~Sao(`4Vaw)}kzNi(MSchip zUIsI$&3{ns{SNb}!#CC;gY+9M97*hy-+34JLZ%%ZrGRHzY5Y#Km<{tB!r^|>dgKl*I?=q?neY1EatT)j`@PL{- zHTrm#kti_}d^3vvTr;bKrM=H`U+NXbYR!avl|PynR5rs7!1xbhS;ry^{9DVZKt?X4 z%_pbmm;IP?7bGk{bYEV@cqf%_0E2d%^hirqg95K>Vp+5+JBr<0V!1ghe4U_4EQjkd zHxw^?tbN&HUx&p?58p=D-K2U#?$JoFcls6CJcg@!smG~0Uvt{~Hsj7mQL?1hd(22F zclN+NFH6#)4Bf$2qs?CdC+_pN2n)9GCyUU$yC9OA_`GmY$Y{U_Dev=pNs^WPzuZsO9E~hmo-`Sdu5s~Ut#%}gzzZ-%kM&Rny z{`n7noH$88H#OpXjd}TB9BGld%DN!m{9X<-vglVApsKwBNcer1ZK36PKpkDoE28C{ zh771*yYx0OU%M8v++e%{>$l?t!+J`ZOC5b)C4l0;ebxUUTHN=?_ojO>4jNfy;@9G1 z$nZPyA8y7`zrL)j&?`vB&*o#A;KZ7mzDxrjf&N;L7b~B@erc62&oy>GT~UnZp!o@e zArK)*eSZ>%Wv;X~Xi=O-*w*Ng)CSECNO}1yBqh)%7=m^f@4H%^f1Sy7ctho|1^apm z4ZmBYv3x3_b;9j+FP0^rB?eG*F17GDe2XFQRpRK94eAKEy5cv_;4bJ`w)?PQD1@+1-W-|J;t-`b3jH}YZP>*#P1Z( zjchd@Hf>yyM4l~3*T`Q&T10R!kklSM-3!?DMqIY>5ifbaT8m=ipU5|Iohv_;&BvU3 z=GgsHW_5BK=H`9`CYRQ&&(HRas03OtT88HV0`5qxd!ugBd3lkAD+Is4gZgDdI;`rX z+y@oV-aDV}Io&XPYWyBi$_eE8UJ%@gu2B`^YJikH*tk5L{-w)4bTQUOZxKwn1ItcT zlKOD@(IT?0Z>Ar)#OpC@@OSnG+GO#sf9wxp_G_eJh9XNe+(f6-vGb3|8Ky} yK&P1oq2C*92!4? diff --git a/src/tribler/gui/images/magnet.png b/src/tribler/gui/images/magnet.png deleted file mode 100644 index 2438716479af695043748d1b4104e51a3b336790..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1869 zcmV-T2eSByP)0*4e-W0un1X^>05fmr&Yitl4C|#^4dAfT z>8#Gl$&rbblqFWZArluytU@&-!ii%X7X>M*NNs^Lx?b~DnMmrC5wQp3?fPO_4RQ9=%HH`psK2h!r`#2jL~SM z+S=N<0QAtUXcV<7ZP~JgL{XHL5d?wi>gotJstG_Z-KqdA&1yzin&Sb`L$@kGT3VW_ zGlPSJaRKO|ThZvH9XllSI}$w}`6xLY)CZYW2M%Zg__exPN<)OzboXPEuRL@pK7il1 zZIkk_$wFOSvNERZZ0f*fd-~WhO#pi7Ry2BL>sF}%5eT{6@c!S7@Y68$px1!OXHg1$k z5rGgcRkB8$PP&6gK7ZxV{BQKmeqX|GS-HJwksHl*hgppuJ zhpdb_FOU9!krx{pGy&+PThVC4x^>bLw6S1&yS$P7e7b>lSaJ5OCIG#3D;hnsW{va| z4jVNi*Ki$JfBw8C0KIf88a=&wwY1D+QZ>>T3Xy+ulATC-l~=Baq%!K4FV9Y}THUeg z-f-bUd;njsSRs{LtrTc&l{bP`_Zy6)4h@mVYJLCB#>Rtc0ADU!_C-Qs;#=hj2^3HO zuoM>3)yYYE{qp7b01zpuJUyL4O-&@h$=Qa03t3lYCVBe%X(AY8AHrX&x_R?^Ii0m- zWxMTxz^4J|EyD&4%3LK3$d0L`gsu(@5V9H{03vm0p~La69j#6r9i3f)*-YjI3&=HR z4*4M0V=}Q4I*M8I_U+rv5xJv_7e9k#Pv+FrOJuRoJhVs$oEL|OXAewD3V9*Z;_)0t zR(He&fM~sJPfh){#OI@6Pmipe0i_TwD4=V|LIE7|p*{BDwJD>KeI3F=T$B_FrDpp5 zBmtNpn>RjAq26BE%(9>~*=*z;8F>X6{Vpy5M2%h8yI0!n3Bi^YS!4L@HyyDF++A(~ z)DGaxi3u{IL;`?GL@S&^N%SNoQ8Hw*P}fH0Jb-3(3bN*f3+a;A%LKBJ$-%e)*f>&J zda5)hXG0ne9Jw-@R)m&lL>ZZIhwn{Hq)xAwf^gIf=lyt=1n09;Qz;#rT|`}n1K2Jy zALv;y262epal21%zjJ4!l7FfIpA{FErPypYma=Yy8kd4WMR~R-7>JOZOd@*^GLQgA zjmU^NHb!A&M>doE{T9^zb-<7Md`qhD-Mjgp0N6NKSXjR(H}{RqzCH>fdZh48!oQjg z(^+9Flb6_g6uv^49wFyz+TG61uik5ItyDD=3qXz2S%BqMxzm}HfWFP5sOpT? z0Z=10tsjTiRhNr>3-`n>m#a+|05sPySuth#KawIeAtgq=Fi_#n3c6Rm-T*>lQ=R$lBjxfP*#jG6R^31D4R~~uh#DF zy&v@Ue#9TErdbWZ5Va!oB${WKm@}c4~aOKrdy+0 zS+R0~;{w{M5iPe38NA!xKHMG*Zm%61yGZx{C&2nYYj>d;AesOSd@|=zpp?Yt9QJpX zAf&x&vu(`~go?)t3oQ;fX2ZS+qy4c~JKZWHFnGoer|A}p*6KsOd+_Yf*49A3C^mfK z^BwONMRr(WW6=cWIGhO^0VH1e+(Ru5wU3j%5YR}C+&-aVpt{g*)C=eAI&k2}etj`l|Ev>d`@Kx6!M z*(|652;5QTbUKGf;aRZovJ&_-<{>L3CQ~8OWt(d>I?VUq%WA7-D+e?45)-(rVa6IgB2kQ9MIQYicXX0R zeS|yj2G4mUKs7M+SzC{oH>NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lweBoc6VW5SkaJjr7Mr7 zC~0-2$ZFkkIdmpv+XvCQ{HFEV>qQtCu7&(AzkNyYCgU%OmmN#otrX5OylgOGUc!7S z>gSzuD<%epXRG%=FI=L$h`FLgEWM$`VHV>h#>}<*&Kt`zGBA9;`ft~|5|=WD{tLHw zCh&O}J6I{Kox1V;OAZEx8SnXj?3M`KGGp6+pqo@nTq8 - - - - - image/svg+xml - - - - - - - - - - - - - diff --git a/src/tribler/gui/images/monochrome_tribler.png b/src/tribler/gui/images/monochrome_tribler.png deleted file mode 100644 index e277725cf0f1aeb8afd32fc10fd18256b405eef8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1484 zcmV;-1vC1IP)<C>lFt5x2-dBdGM zcPJDJsH*C!rfF!J#?a7^tE&1j$cAB{>pF&EI0nR;Tj>Y5EX!z`MzL6Aa&oe*Z+d!~ z>FH_Co;}N}SFb|p;-{8n#fy9|z$Hm?y?XibuuC=42fOJfg!*M zs8HkCY<9xyc`tZXRgol#$B!RVtJOGs_^_MQuV26B_U+qTy?T}P^>t32I@NabCr_Sm z@!~~vU3c@KgFgQPCE)jvYBm&EMhk_)HNOfZ%Q8ZU&gCCPQ7{aHMx%jgnzmC>GMNl# z&YWTI-o3nd@q(8xU$&J{AKt$M`Y)pn*S#yKvv2Ww&9-e!({zAu(|Wy5u~>A+dDXUi z-*tCYg`8>=wETMwiKG1hk8OYx0U?Ah&}OqqE|+6zX^B)S6?@|K^>qq`0)}DqWk9Tj z%(iWJuj1@i8jXfq_C`lXy8;g|GBU#G=xFj0FbpG-8k(l{G78VwK`%a%eJ%~dQG9ku& zmrkcGP17tzQ7j>Z)!JCKscqYVdhXmgE1%B?_WRwhrE9=f5l+rXaVX??-!EBDo;(?P zeW_Gpb#*nce|&sASqeoxpI*3dA+WE}Xi%$tJU6aXD!yYbUAmMkg+fAX2_Zu1bK}Mh zckHcOx4NZKEDH5yz}(zir~wBK9B{`TJ9aE^+~VTmr!gQ5{L0EoSK}2$NhXPdJD&r(u7?_s&*uYa z_u#<;%eHN6VPPR`4M@&_VzC$s{^re_QNZGAG6sD3@F5WV+qZ8A1wOeXo|>9Ubay%M zmAF%$J$pv6SoD2JAH*~6j;+Mya@nQM!Gi|}Nu7aGDDYA1=+UEGxpD;|1l4La3CG&R zd{LeWdHpn#fM?@s1^BX`2-v_+K!Z;szX5-T9PGwQ;uYYBPXPW8;74GipZ5a*P2ks7 z{}_<=a5D-05BLR`2bO@pqK(D47Eam$vny~bq#0000NS%G|oWRD45bDP46hOx7_4S6Fo+k-*%fF5lweBoc6VW5Sk<^eldBsGVv(^DkVKne`aSX}0clPE+-a`f=Es5QN zOv)QXRM#ArF%eNYq7_M0x{M5gKnc>|H zD~9_v3^p5H^GZBtd~i$L;4Jfjeewx<^)J73o_U&o`aVnXJWh_Ml?@5=SUH|sHn_}V z^mzKYA;pf{<9RWIlO6MdCyyC|?6?*@O=d{4V_NX+Hsg~T?S^^z%p9L|89evNDV(~_ zl=9Q6!62S_!n5m)kN!wKSaD9DnQhON$|=5u`~UqE`E$_qMq|L{6Mvjl)UN26{Fmh9 z)a^cNuA=1hn(^7}AV$Ta2|o|E_;k#({(M+-q5%61a}AFcnFISgT@{`%e(OD@(#X6a zKTMs&rlB@LP^EzJ+rq2*PRBRim09@tL6Mh1Pu_8s%O7?vsH*wT=(~ceaqZDJe}KWM zTH+c}l9E`GYL#4+3Zxi}3=GY54UBb-3_}c!t&EJUj109646O_dK3ecbplHa=PsvQH z#I2zrb?XzL21$?&!TD(=<%vb94CUqJdYO6I#mR{Use1WE>9gP2NC6cwc)I$ztaD0e F0s!-r)Cm9p diff --git a/src/tribler/gui/images/play.png b/src/tribler/gui/images/play.png deleted file mode 100644 index 7d48e4dc585eab17f4768d1663209bfaddfd6f49..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1565 zcmZ`(dpOi-6h3od8rO|%(oJro+weczGIbT3Uc12q89qDVT>@h=m=>T8RlJFJ4E(G8;6TrCy zU@@;;x@A4uz;35`c>tWsHnv3(k>?$fh)(L84~)CHBpEFdk|@5Ogb_SJeWms@!Nc?D zz&8{RH%9W{+v6c|i~#+c2O?ry?W?giF}M=t+LjFPHVM*XOs9l z+yAc4WrWyU$8Rd4>YdWwP33rUSIbMKOS3ne`gy_OvL5`_v)Y}VnX!9cbhXX6yj-E2 zX`HDTQ%aP5Q>J#ev)7qhbYb%gY`x3qs=b65E3Z#630+Rg$3tC=v1Ws9;cs1Xp3RLE zP~u%JAu&vWqVnB=>&P7SFJUjuA5qql``Rn-(o#E3d)VcbS{4KBJ2+ z3B_U|e+OIdWZnJbJStX1+PCJ$vyTaNt)*4az|qRwaqNOI5kx6jf|4~{iWY1TX^i#M zn_y&Piilk>Wq<>gZp zs48(BxRcSdX$-*9bYn(q00j_9ZVL70gy9t{y-Dox&^6p(Es&-D!Jus|!h>DE?`it* zL63l+sjfk^rNwwi(CRVahUtLMsOE)n`Eu_SKD1jr@qJ)Aw4KQtPD_gQzcp2BdKt$7 zDAY)}h&G%rCwtbr$Z;SuR=hNjU}V|Lss^F}$>%muYcE)H}zHp-Xq^aW9@`3!&lQBa6+T-2(Qh<2Z;bNxA<8o&FrT$ zQwQ2#fDl{3);2|bOT3CQP$H+b<#zjo483yN%}>rv$b5i@BLuVT!2D^z;HCa>fSEkj z*?EJ#A$|wjph_4$Ij(h&06dG39|ts`zFB-fT$G&b~362vti-Z{!*IY zhqVA%`p?zSwJfCp3q5q9K;AfgI;{aK)QM530%ZtuuSUKTs3Ke8iW>^mV}U8=QaHqE zSt3IU=i+RH#*E3f!I~|2dDpUl*=`&{RiBxZe1(j_tA_jBL^g>_b?WeOC+ve}yD2ha z8E3{8F@QZGH<~V6%6xDQUgv2Diqfv;Ji%<0Y4x(~x_IyT4uCh5ChEEPL= zn813X0X1isWKvpbs8UD2`tdoGEG02@$J_?{Trf8|kwNmTSL#fgPjidrZ1)tQE)8lO ze^n5j+?Q7#I$yG%*zabvepZTcx{k9XzU~wh(@hZ%(YcOrMkPM3)-rWgYBJ)asVEBh zl>o!Nr}q^uPp_phkDfOfQ_x5w$gWf~DXtqIM_R?*rX^cl7INoZ&vYHtkS2<+ZLBQx zz=qd{E}k7Fjp%D8c}t3LO_^i)UKNsS76-|Xd6#!ichF?4m~pYgn|hv3Q+9gAbO#{r zx~rGdQw1tv*d_j{7F@oOibNQ==HrExk=UFB*OlHwVx0Use}A6C$!bF?^C;=h4vkqi zlM%0&@!|`0*6q4Yh3S;(Zz_np@%gz`+|`a>g0N||pXr1234e-gL9|)QH5N|KY%mD- zo|I{JMQQP9J?q6%uK*<>34Qav<=S;zyk-?{rugCa>oJM+Ii*~A?w@Iqp^?qDpp*}j zuA&y9Nu5T1y%;mD_*&DB*)@>kX-?Ycfc}K0NuI$;k*p-Pa|EA_Ca^`w4pxZ072?Ps zlb!9z&g8Wgh^;e%tZ*@^|BrwhAIXVI`F}xtRG9%PSTaB1m-wh80gKNDfk0r**%_M{ Z!Q!#4 -image/svg+xml diff --git a/src/tribler/gui/images/stop.png b/src/tribler/gui/images/stop.png deleted file mode 100644 index 9621e967b3d6b07089d6537fe70dbdfdaf75fa45..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 656 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H0wgodS2_SGmUKs7M+SzC{oH>NS%G|oWRDy)vIg-2>Wx(>pOP8zc-fP(BLp1!W^4_KH58AX&Erj#=&5)w z8&yjKB6t`@dDu1v^e74>X>K*N$d#CL=)1y>&nCg=i_Y4l%|8EgMzv+y{PmX8UmM>4 ze8u#{`#slg=2ich`8?<<^4Fz`$26 zag8WRNi0dVN-jzTQVd20hGx13Cb|a3A%^Bw28LEf2HFNjRt5(8yZ-M)(U6;;l9^VC zTSMG)cTj4P1lbUrpH@mmtT}V`<;yxP!WTttDnm{r-UW| DBEH(= diff --git a/src/tribler/gui/images/toggle-checked-disabled.svg b/src/tribler/gui/images/toggle-checked-disabled.svg deleted file mode 100644 index 872ef8f098..0000000000 --- a/src/tribler/gui/images/toggle-checked-disabled.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/src/tribler/gui/images/toggle-checked.svg b/src/tribler/gui/images/toggle-checked.svg deleted file mode 100644 index 706154bcac..0000000000 --- a/src/tribler/gui/images/toggle-checked.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/src/tribler/gui/images/toggle-unchecked-disabled.svg b/src/tribler/gui/images/toggle-unchecked-disabled.svg deleted file mode 100644 index 9afd88d3a4..0000000000 --- a/src/tribler/gui/images/toggle-unchecked-disabled.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/src/tribler/gui/images/toggle-unchecked.svg b/src/tribler/gui/images/toggle-unchecked.svg deleted file mode 100644 index bce1563755..0000000000 --- a/src/tribler/gui/images/toggle-unchecked.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/src/tribler/gui/images/toggle-undefined.svg b/src/tribler/gui/images/toggle-undefined.svg deleted file mode 100644 index 7484fb66c1..0000000000 --- a/src/tribler/gui/images/toggle-undefined.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - diff --git a/src/tribler/gui/images/trash.svg b/src/tribler/gui/images/trash.svg deleted file mode 100644 index b612031895..0000000000 --- a/src/tribler/gui/images/trash.svg +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - trash - - - - diff --git a/src/tribler/gui/images/tribler.png b/src/tribler/gui/images/tribler.png deleted file mode 100644 index 5f3c257022ceb941fe1f3584a6fa5e8d59b0b683..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14157 zcmeIZWmKHY(kMIw4DK3Sf(0Ag-61#xg1fsr4DRm1C4mrJf|J1^5S-u=+}(m+vd=#6 zcfPgG{ds@fyLPWtQ#JimcRk%*U8}mD>L?W@88l=fWB>qwCMPSY_JUde03!U$uNKVj zh!=!tY^tH~f{|VT5UO8f{DOe4YBJ)0s!@`?7h2m|Wa|l0w<`)iT zS8oS5V=rb0*Vq5*DSoA(cMjmit3M`|0(}^Pd96e|4)*G z>)*qA86f!26EGVKEBJrlp|1)jE$O_$b+AlLnWbv<6_%o}2Ezuw1uVMzf3j_#}Ze6@A(K|UwF%2tF zrH*Z^{yRK_ozA-t)^DiP={UL2K1jzB6`R}|tI$W;Ph#V(Ut}nc)2qTr|2F<@99xv8 zA?MdCNdu{tMN~`GA@4hnWTsbwAfXv?alU(#t7Lh+w7f7#}O5&@GEP8BQyCz@)J*0BC`}F*X;X9>MFetvx;w%5vTR*#I;CMz?<_A zcDhgSjZ%U^xt;MiXc|aQZuxT>$v1h70uGDRhBx+Hkg^%@0x^S5f~Oksyh1 zDOzp#)5z5BZ6+;QXP8UP9&ALRouLAO#p(`ivx9g2F%IL*j@aG<9!ZYN^e&gO36fS3 zuBZu&PT^=`w(;74BnBkJ0cse#_XRrAx%Fy}S$_8sQdP~ZC^|d|1pcW$r76)JU;4_QC0$V{`+7 zq-F)Xq~TruppXa_pVKpB*0&y-RhGOC$15Tn3bPhJ@}ZH6m|K%j#%yapZ-p;2DnSoH zEk=#RPt>v}6kTG_%dqG3Y;gl8ZRq%6H|+>l=qnwsX2gt?ngi-ZVI#oeI9p-poJ_|) zYAhk?56ZbjxM6+N%T?3iAji8Lk+}LIO3)!@yDqGSz6MGmFYqh>cs!yj-Nw`7+R7FV zuMl0I+M-<{wQ&@ER((Cn47K$dt3$&nt0OJ4F!jZKo7B^jQ>f%T7ku;0GP6{veTSn5 z^=LUZA7e~rO|!nsi3f`3sYbZ`Sx9x1-UCb0+bSqGpS}36!4x&YI>YFBI%@An-ID9h z1Y=@-$H?`y?>CP+b@_p-yLXQwya=Pnz~C)SJ}*#>8S;v**F!0<_p@LAAo+=uLI~51 z$9F(*o1F7u*(SoZeD{UXkI>C}4mClZ#cyHQH3l@ONaRZh zOjK~KVV(`+&Ibr5yRp~IzbQt3qXdC(-hf1gzvYd*;uT~2dSJK|*Mm$~Nj^(ZQSr?n z=Qg3Cx(BbswUx)W!F)jF9Gnk;Ox^Fl!C`O!q!5F@GaAwiU4AP3 zg zIt(ZHs~M4sss}0L0lFbk&lG-svS)C8bSeE?TIj`f$)hI$A8kPniHBz)udj$Qj#BTY z8>0LnnumB@Q${&}QcGVL-QEW5)Y zK+~ngzt7gPG`;@K8RnrU@~U0CeNLQi_Ir8uwNYwvRr7Gew>v%@3w&N@ERC~6^~tSP zX!=6OMcd?xbCO$NyDqlxg+`HBKCjyp|29JxA(W_Q7Jem6bNLiajyu(mbaL@V_GwP+ zL~2Jdc`dcX@ON_wkLl5xFQ)mORWY!nufU;X>qRo4$`-;WNAc;v(M$Jp9jFK5@)^iLn4Dn66n0j#gAT=Bj4S&c4Qr(pHspcxvbd=M!*-bCUiLB@@xH2jFNj_b}0xo3DHQ z(Ik1JM?MT!ANCd+qgNl4VF@+ox1CDByWM$L~#f@?o7v0`a6q2z3kUz>*MKDrty-GA|pe!#DdtK)eyrKsuysa{Sn;F04Jtu zM@>a^LBU$1Fb~k<*l!(QB&a3ySma2U zRmPbr(EVcMQ)fAHoVTEzCuxkLLWnRr?KKG^V*+}kAY~GC^(XvDhryG$L5UL$s!nZU zPlvQHLeJ{0pLTIKcRU9l7oD!$o8#9JJ}G?uJaYauRaB0Bv5h?3*7_mX_X4|Wc~r@d z1QYj7tqjGmCt!y{715g82weka&KZLv(F9kanF6~T0XLi z9WJNqQ@ciRj-(60aOv6RU0G zDp#HE-uP}0Ph4F)uj0IB5jd|^1~AAXePG3jcr3gwaJ?uuT->L2X@6{L77n_A^AkFR zD&=1kcpYJ_&-h|yPz3HG28X=61U~hjQq%^?eF^Ix0d3wWI_<(utfOT*<|heiZ)?=? z7z%si3E7o1En3~^x5acHO65mq;8TKMOq-iGQFthyQffMzT}U;wi|tT(M+dDdN?URk z>3bgEhsKr(~s+r?r zVmtW2pv+sMoc;cBp-7We#MS1?MYOpUf#ozFPUdmspMl$CW0|3CC z{jF+n?~yvRY$M;j!+pB$a6RGFVNVj;6)QHjNWu@3LNYdzf}>RX7L6E>)$n<#5kVQWx4wk%+V$_CP_05v2hi#?*b*Jyxp+D*UM6m0% zZ>3|P?UDPnDu7ccs3I3*T4VlWnc8Q7Ub%K^TGjb$7UJClyNQozEBmz6!8lzjjkh zmWi#0B+X$P7CyQ-F33Zv3TV|t*uUh3muH<_g{fEsX?j z10V*w0F!DG=m9el{k9#_*2>oH`LV(sSi9Ma6OCSkr_%?}Qr#dvOEG0d@soRet6b`0 zxB||A1ys>j6y^T#RV{p+4#s-z0Iyp@>!^@G60x)MlyU919vCp)4zfsm3{>esL_uX2 z5_V2}D5`I(KrC&^4|#-`aFXjZwcPtKtN?C$)Kr#RYrQf&RwVPUy6P0ju!ry#1#dC? z35^JxP!VCbj)|WEC+*0~nROnqV;J#b2TBYRBIMF+byG*3Mp|ypT@{3hrd;i?K8FIk zLBoaFXuBcB<8;|7C>f&5PK=jlrh_8$4A9tjuT|ipt+k{m*IrSCwO`_G_)a_> zcDRsk0Sy*u;EPbL(1q2GlOsW_&oCN}x*H}GnnPgy(8FR+iM0k{8fQRgv(?mDZTLrO z0b&0UV^pA|pL-Ux7;vtOFSct<8j;J4NMZKYa*XKSwx#=9o)6@VwkX9Yr%Egg_`gGm1G7}Q*UHSdlIUKQ znzIiu{+761AeS~g{1N$y1A1|pYYFpZX95F5Y*UWOV?fQbdbHU*GNM^n&hUh}A**^s zYN0&o*ZD_gz_*LQ%4d`#)|@xm=hBlehVb7e67)jYbz$miBZd$E)FoVSU^MZbF2 zv77|H9q-kP_7iVQ{9AH@jhYvo^;`-_bb+3_4t_WETNoBmc2Vc5m{PlBrV;@Jl95i{Yw$rdx4~ns86OBc@q5y?AERf^Sau`d@?se5N1Dml zL~8*-%n;aMe5qH)s9mKKCENTTd%7F7==Dts6{tCj*f#?d$O~T2Ci)GE>e*u(8+Cs#>fx$dBs;^GhnE%CtP=X@cTpG zA@OtPwuXCoitZ&(4ghzIKdrTJj0uN$4V>6Aaq*VikKp%m=zom!s^#bO?5V4y4eJdh zqty38oY-#(p^w=)_qv=CFdn_*7-=B@ycNj}?a8g~QnCy2x1|eKSC-g_KIn?vEV=Iu z*|qiJLqr-_IfDF_M;%Yw>&UeqQdd#0OfXW)Ia$7*RcG-HhDOu!t86Q#pz^qrY;t`M=35^m{d4Gs!#cvnwXhMPs+sPP z>tvWF`I`6l9lirm8Rh&mKLXVZ^j--Cc_euE-fysiSn6xPYcf4m6#MrpG)Xw7lRYEg zyfz{Ahfy$GeP2W+TO+Yq@^*sWn&NBQRL$~?#Ky|b;So)&qvoq6^Xr@tYwMpeBnEkM zQaZYK1)SFN*=?Ph%9yWNyDM%oAY^!Y;T5E4YA=&M|FSwSQtOm-x2-Wl!Y(~(@=?hb zJ7s`o#1!?BIx&1YyHkJrMRjx%hslu5=&A*>GreLry|u&gW2Q`uAIXdC*$zW1PYOvM>U4U&B&MGK8^!5c@fw$~&JE4VxyNC~WPc^9yyO!J zsU$GokC{@7O7FFiwS&mOOaP5yG}L#&f$g&K?7REnrpUzozpy1q?F-v(ALxal|BE?CZZiheI_l_qEV7-tpW;jEp}^y3S}BE~vi=~U3m>jB*S z>yvh6;As_k*QUz{(d7E;*Egv!AH0v{dfP;%g#(;-xEz%Zcg5GvVkOCjw23V9!a=J# zhRES@9a;vGmIoe3V-bDF#o0ks?a&hJlV{8$Viu|Uwu~OP&3A6-zovftS_V$i3{Pai zGTZ$c%on5}FDb`3gKg4d+^bQk1eD!hBkbC>Xi3;uPLXEbo$rkQ-r7JPHcp$NpWHj2 zr^2b4&~=okdFcJ#hyL{7Az9ri1X$s-y8nb`<=Bjtsl;Sp;SMfa+Fz-?4D$Q1csn`Y zGH~x$Jo|NHBThs@7?FQ%UiOTbeq8K3+q*jnu=DWFGo6y?MLd`erastu!RQ2K89#3)XyfW(JVSr^i(X< zVKe$#MWA97cVm1aDIOhbRV=1xeK>|_#;77Lq!Jxln;OK{C}!&q`8Ve)*;&4iqq+I* z8J={Mj2KH<_s{-2ORl3G=uGGHF%jA>3N1|ljiWD?Zmx?u2Z?ihw>>$ZBxOa_W*R>(EqIOm4{+o@S8Cgc-t^- zueKA8o|ZaQ*(l$9UuFYvx#8|#m^$&)23iAcOi-%~B}7$Z_U2%SsUdnMKy0kIlFMbZ zCUimLU&l*mo=$U|ayU4jLG?&(`W^j+V4x>_D;|9dp2n1 zSw>Yf+PW7EwE3n=E*cNvI|EJ_#P4Bbrz;S9_rK3~EZPXkFyqqOWQSQIF8j11!z>fyNfO$Mz7F{!v>&JlBNCTq^u zYr1c(%Ph&HnoS8VT?lRN1RF39mO(e2{^MHPYYBcx`c;7SN0WeH#_cesTI@vLpMB!$ zg4s3Ynn0h{NIpyFI@cXYy=C!5d=Sk8oZegN=AR^H*EQHGoE_ges-zG_J-Nw_>c+h9 zFr%c1qQdhs4WaZOqUdr+ByMMBv1%wKur+b(;eJv^#L~pPshd@xEo7s?d230aLoR|_ zIdXf~(Nw_i61}bE)Et2>r*bYI{$8fnj#46WzBiMPt#R2@>qsHSY8wF546q|~ERK?0 z*W|d|^rOk!Qf4nC8St50=>IJ)o|C9)G~u?(RI39B0uMMqw)_;?lU&n_-^(n;E0viu zh^|>xKEz~wsenrjjgN2O&K^v{6rBB;-lEFB=Fmb$%gFkKV@x)TdrWvPa6kDMz zzk8E|?Lyhz54jY|6yQ+mah(3AH_f(2*?fd>h)OCzbnRI6v!|Q86Gf-Vz`Y~NTam#0 zuOHP8+ZaLV*?!DJ)G8QE=}x<{zY)+m4?k{$d-5M(pKdYG9{Xg_zfVDN31tnO&{EdJ z(?pZSLGR^QEZ=+I-|*-U%p~E6wuCR-CZPEepbG{qjXF%xIeuvD<+n>#Mzwo@H0dt> z>iXoLuIy)`K^ADeUe{sQvF^pz4&%?>Ua?Om zcR6aH8E|~*hRa*{Ql(EvXj)H86Cc4IO$UJjJbUx^j#K5WBgCFkxUQF55y#URRV*du z4$9S-ai@Ynu5iWU{H8wc)q^uuy0lk-Lv*8Nr5aOKJH5eu- z2#%A0Y(ug8NV^erf~%7EDNSggV)p#6X+kEU->-0mz5z3fk!lnaAU}%Sg*9R7Xork! zzJ~UNU=+F7y+Xk_32}8^L1wYtZ%p?{2`h0;d+%@bwF7N-vK|MCWu)M|+en^aZ;XG( z=U`B%W)54#Avw%F@C)cTD^Cv9XoiaI#wN~ub!IIZmG@UdO`nj+0_KJvQ_2F zM+t4q>>0yf!*}_MH*0Cd1GBwyz^8=ofi1LUf?ilmEiVT5-$%zkSq=X!ked$o5?upc z{cVbod9mV>6|HboF$bO<>)Yb2OVo53I*>r1M%je@KD99&%o2)7gZ9WsFD9?kd;+@w zA@u6HvJ=B>=_lRwhg-)VEM*~_XJ0pcKEvIb?2CZhZ-g-i))G))1|dR0GeK(1b~KR| z!0~K62*%YeVU7TXB8Bo2TpCZ8E64b^z@URORY(rN2SUb2Tr_GJjtZ(e6eY-$h`58A zE479Ak(~;voH1u|@}i3Q;_;elVdAYqY}{4EBVc)F89xFRmf?lLA#N~A*t?E~g|j~k z%mXH|$7wYYQms^80b5Aw!e;G_MX&aRLi=vUtyrM9%#bQjfh@}#ee;OKxiW^+STv`F z4OEJ-l6%KUmTF=}H1r_YphcVlSQQ>uegbe7wUWt0c7UDR^S;*|$v9ik8CT%qYuJaI zL+khezh4@`$|qi~W{Gd&Gb5W_j!3Few<^gh1Z2zE>eN$CSz^V`vD%qL?KLp{Gc&XZ zgL%IHFwsxD>cq*Zcv`A{Du?RQ`LFC))H&`MIBqzbXK<%0_8)43@k{9OP5 z_WW-(L4AoZyce?45W~!C(^Kk@$JD5alEbaALjz)o1ycq0(nc0x%9)!MM&iGrDyrmO z4>H0;X}pM_h@|flXJYE5g13W9qoAj3(MuLsy^K}dTxk=4LpWYJJ{&pZX{)an zk$mPg*Sih0f1{z#ZZ&qYz=CC)IAp*4Q$>q$tn;rs$&Ui@Pf=KE6@D4cMhd2nk&Jg4Yxyvw#LgpXxnkJ}F#kDr2Jbi>bdyCeKhBN6T2LHzq8~WKSMckY(egTHnTqt-eezX90 zRreN>7*YJKj{-a!m~ZzfH$~Nl^()83g=;JMqt3(W+taBE-qR_d<{czg(}&uHYZ!62 zm=OyAg&`4;2!|oDaTHq>!*py zTA(eF`&B<>FmUY=pv1OXvYO6mvrqCs4Q=jB-(ckKE2|W|$St#skSPDjgie{%$W=(~ z_1OW{PqF#@K0%h53*kJ$a8|(WWOxEq%C^Ao$VOiVWN3+uY@j`8hEW>_+P~=eg>e7_ zmFR+1oUn*hQmr?pcA-GZ30hMf#uv6Z{4z#YkpRBri8IvH zHZU)RC4rIqQZ*rSt?EwjNu(b`M^2#36kib>$nTxD<0guyIb1a{AjBtUBm3F@Y#Gp0 z13-2GWcA?Rzw(duO2$;P;THEk>$d(N!tntYZ#IMkdNhoC@4T(*pu>P2ifaL6pIgGe zJ}I2^_xDh{vNJ!kPK`6_%7RM317_wdg`QaA4e1^xsZI!;!jwL&Q7icfs0cfY+Rozh z>x89duAmz^(_&Zx61={KDam`yW}SHe@G)s0$QI;Iq1c-M+@w3+6gQL2AmCSTec&hmAmcp(*}`7xYs8^6JmN$j!cd3f%kK zMxg)sP$Oi8D~6lb;q`Lb6IW$3DSd_5r4;$@63(9K5Erhpfvq4dZ0%iiaBu-8@oONS zYAtI)TyPmZHGhB7#}*s}@s)P(i%!SR8xM~$_3mc> zJ2M%s?W43J$NT2eS5681F26rm@@bK*cQ4(!$SjOExr@3G8#)^m*S4tU`7oMPt_Mx# zz}gmw)Eg=`UlSs*kl+VyZ1Wd%LKX$De-ne#q7bOabtxB=m-Y0Z* z=1<>UvhGIsd61VT4g}`{=`~DcftlBljE=N0iR6>w#}56UR?&98O2Pm>EywAoW2-(& z4DCUxANN7-Gz+6-1yFui4J)!G968i{hvx0!QdOMprpsH(Cv!3?AX1LSH1tz{Glomf zhiz=6aexCsd_Fsi>t4WTVsbJm{ouZ=dGqJ+MvGkH7NmmV6nL_pz9HYU#IUg2oDuBo z#9nis5-UGVfp_8WHarx(6q|UKBd^^Qh8E9n`qfLMtuk`4G0?E3Rg}FMCyM79w!$=Y}Z)N$uHIiI+>SNhQn1tx&Ilz5Dkj#-{YHV>I zS+2Aq*XwNeD02L5_2G#!{3nmk%Le$jmxb{{{}(T7OV8u%#mkERduw3wVwswW!n9Rz zJ-v1&2Y)^nAt&7=C>q=?omSa1dq*7b!}1;)>O4 zM?EwsL=_35GE~lk#PqzPhlg;6Ko90qUP%UVZXE0wJp~p71>V>J>$O(`0aL6neSpk@ z%>*#2NFkAtNg(8F2bWQ&l}OCb%}*|xoaS3WGr(+o$_fl$3sDINe@tU_J)1n$1F3=P(5h_;Hnq9< z;G2qhR4%b5L$t^M4kAeviaxp`|D^T)NeKQwp4R(M?({#C{IAUOzY^X5BPaa-wgmNm xH2EJ*{zsGlxzF;~<(EGfUH)~;<$rgd -image/svg+xml diff --git a/src/tribler/gui/images/update.svg b/src/tribler/gui/images/update.svg deleted file mode 100644 index bb5b4564f9..0000000000 --- a/src/tribler/gui/images/update.svg +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - image/svg+xml - - - - - - - - diff --git a/src/tribler/gui/network/__init__.py b/src/tribler/gui/network/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/tribler/gui/network/request.py b/src/tribler/gui/network/request.py deleted file mode 100644 index 405a6bc3d5..0000000000 --- a/src/tribler/gui/network/request.py +++ /dev/null @@ -1,196 +0,0 @@ -from __future__ import annotations - -import json -import logging -from json import JSONDecodeError -from time import time -from typing import Callable, Dict, List, Optional, TYPE_CHECKING, Union -from urllib.parse import urlencode - -from PyQt5.QtCore import QObject, pyqtSignal -from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest - -from tribler.gui.utilities import connect - -REQUEST_ID = '_request_id' - -if TYPE_CHECKING: - from tribler.gui.network.request_manager import RequestManager - -DATA_TYPE = Optional[Union[bytes, str, Dict, List]] - - -def make_reply_errors_map() -> Dict[int, str]: - errors_map = {} - for attr_name in dir(QNetworkReply): - if attr_name[0].isupper() and attr_name.endswith('Error'): # SomeError, but not the `setError` method - error_code = getattr(QNetworkReply, attr_name) - if isinstance(error_code, int): # an additional safety check, just for case - errors_map[error_code] = attr_name - return errors_map - - -reply_errors = make_reply_errors_map() - - -class Request(QObject): - GET = 'GET' - POST = 'POST' - PUT = 'PUT' - PATCH = 'PATCH' - DELETE = 'DELETE' - - # This signal is called if we receive some real reply from the request - # and if the user defined a callback to call on the received data. - # We implement the callback as a signal call and not as a direct callback - # because we want the request object be deleted independent of what happens - # during the callback call. - on_finished_signal = pyqtSignal(object) - - def __init__( - self, - endpoint: str, - on_success: Callable = lambda _: None, - url_params: Optional[Dict] = None, - data: DATA_TYPE = None, - method: str = GET, - capture_errors: bool = True, - priority=QNetworkRequest.NormalPriority, - raw_response: bool = False, - ): - super().__init__() - self.logger = logging.getLogger(self.__class__.__name__) - - self.endpoint = 'api/' + endpoint - self.url_params = url_params - - self.priority = priority - self.method = method - self.capture_errors = capture_errors - self.raw_response = raw_response - self.data = data - if isinstance(data, (Dict, List)): - raw_data = json.dumps(data).encode('utf8') - elif isinstance(data, str): - raw_data = data.encode('utf8') - else: - raw_data = data - self.raw_data: Optional[bytes] = raw_data - - connect(self.on_finished_signal, on_success) - - self.reply: Optional[QNetworkReply] = None # to hold the associated QNetworkReply object - self.manager: Optional[RequestManager] = None - self.url: str = '' - - self.time = time() - self.status_code = 0 - self.status_text = "unknown" - self.cancellable = True - self.id = 0 - self.caller = '' - - def set_manager(self, manager: RequestManager): - self.manager = manager - self._set_url(manager.get_base_url()) - - def _set_url(self, base_url: str): - self.url = base_url + self.endpoint - if self.url_params: - # Encode True and False as "1" and "0" and not as "True" and "False" - url_params = {key: int(value) if isinstance(value, bool) else value - for key, value in self.url_params.items()} - self.url += '?' + urlencode(url_params, doseq=True) - - def on_finished(self): - if not self.reply or not self.manager: - return - - self.logger.info(f'Finished: {self}') - try: - # If HTTP status code is available on the reply, we process that first. - # This is because self.reply.error() is not always QNetworkReply.NoError even if there is HTTP response. - # One example case is for HTTP Status Code 413 (HTTPRequestEntityTooLarge) for which QNetworkReply - # error code is QNetworkReply.UnknownContentError - if status_code := self.reply.attribute(QNetworkRequest.HttpStatusCodeAttribute): - self._handle_http_response(status_code) - return - - # Process any other NetworkReply Error response. - error_code = self.reply.error() - self._handle_network_reply_errors(error_code) - - except Exception as e: # pylint: disable=broad-except - self.logger.exception(e) - self.cancel() - finally: - self._delete() - - def _handle_network_reply_errors(self, error_code): - error_name = reply_errors.get(error_code, '') - self.status_code = -error_code # QNetworkReply errors are set negative to distinguish from HTTP response codes. - self.status_text = f'{self.status_code}: {error_code}' - self.logger.warning(f'Request {self} finished with error: {self.status_code} ({error_name})') - - def _handle_http_response(self, status_code): - self.logger.debug(f'Update {self}: {status_code}') - self.status_code = status_code - self.status_text = str(status_code) - - data = bytes(self.reply.readAll()) - if self.raw_response: - self.logger.debug('Create a raw response') - header = self.reply.header(QNetworkRequest.ContentTypeHeader) - self.on_finished_signal.emit((data, header)) - return - - if not data: - self.logger.error(f'No data received in the reply for {self}') - return - - self.logger.debug('Create a json response') - try: - result = json.loads(data) - except JSONDecodeError as e: - text = self.manager.show_error(self, {"error": {"message": f"{data}\n{str(e)}"}}) - raise Warning(text) - if isinstance(result, dict): - result[REQUEST_ID] = self.id - is_error = 'error' in result - if is_error and self.capture_errors: - text = self.manager.show_error(self, result) - raise Warning(text) - - self.on_finished_signal.emit(result) - - def cancel(self): - """ - Cancel the request by aborting the reply handle - """ - try: - self.logger.warning(f'Request was canceled: {self}') - if self.reply: - self.reply.abort() - finally: - self._delete() - - def _delete(self): - """ - Call Qt deletion procedure for the object and its member objects - and remove the object from the request_manager's list of requests in flight - """ - self.logger.debug(f'Delete for {self}') - - if self.manager: - self.manager.remove(self) - self.manager = None - - if self.reply: - self.reply.deleteLater() - self.reply = None - - def __str__(self): - result = f'{self.method} {self.url}' - if self.caller: - result += f'\nCaller: {self.caller}' - return result diff --git a/src/tribler/gui/network/request_manager.py b/src/tribler/gui/network/request_manager.py deleted file mode 100644 index 27645deecc..0000000000 --- a/src/tribler/gui/network/request_manager.py +++ /dev/null @@ -1,225 +0,0 @@ -from __future__ import annotations - -import json -import logging -import traceback -from collections import deque -from time import time -from typing import Callable, Dict, Optional, Set - -from PyQt5.QtCore import QBuffer, QIODevice, QUrl -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest - -from tribler.gui.defs import BUTTON_TYPE_NORMAL, DEFAULT_API_HOST, DEFAULT_API_PROTOCOL -from tribler.gui.dialogs.confirmationdialog import ConfirmationDialog -from tribler.gui.network.request import DATA_TYPE, Request -from tribler.gui.utilities import connect - -SHUTDOWN_ENDPOINT = "shutdown" - - -class RequestManager(QNetworkAccessManager): - """ - This class is responsible for all the requests made to the Tribler REST API. - All requests are asynchronous so the caller object should keep track of response (QNetworkReply) object. A finished - pyqt signal is fired when the response data is ready. - """ - - window = None - - def __init__(self, limit: int = 50, timeout_interval: int = 15): - QNetworkAccessManager.__init__(self) - self.logger = logging.getLogger(self.__class__.__name__) - - self.active_requests: Set[Request] = set() - self.performed_requests: deque[Request] = deque(maxlen=200) - - self.protocol = DEFAULT_API_PROTOCOL - self.host = DEFAULT_API_HOST - self.port: Optional[int] = None - self.key = '' - self.limit = limit - self.timeout_interval = timeout_interval - self.last_request_id = 0 - - def set_api_key(self, key: str): - self.key = key - - def set_api_port(self, api_port: int): - self.port = api_port - - def get(self, - endpoint: str, - on_success: Callable = lambda _: None, - url_params: Optional[Dict] = None, - data: DATA_TYPE = None, - capture_errors: bool = True, - priority: int = QNetworkRequest.NormalPriority, - raw_response: bool = False) -> Optional[Request]: - - request = Request(endpoint=endpoint, on_success=on_success, url_params=url_params, data=data, - capture_errors=capture_errors, priority=priority, raw_response=raw_response, - method=Request.GET) - return self.add(request) - - def post(self, - endpoint: str, - on_success: Callable = lambda _: None, - url_params: Optional[Dict] = None, - data: DATA_TYPE = None, - capture_errors: bool = True, - priority: int = QNetworkRequest.NormalPriority, - raw_response: bool = False) -> Optional[Request]: - - request = Request(endpoint=endpoint, on_success=on_success, url_params=url_params, data=data, - capture_errors=capture_errors, priority=priority, raw_response=raw_response, - method=Request.POST) - self.add(request) - return request - - def put(self, - endpoint: str, - on_success: Callable = lambda _: None, - url_params: Optional[Dict] = None, - data: DATA_TYPE = None, - capture_errors: bool = True, - priority: int = QNetworkRequest.NormalPriority, - raw_response: bool = False) -> Optional[Request]: - - request = Request(endpoint=endpoint, on_success=on_success, url_params=url_params, data=data, - capture_errors=capture_errors, priority=priority, raw_response=raw_response, - method=Request.PUT) - return self.add(request) - - def patch(self, - endpoint: str, - on_success: Callable = lambda _: None, - url_params: Optional[Dict] = None, - data: DATA_TYPE = None, - capture_errors: bool = True, - priority: int = QNetworkRequest.NormalPriority, - raw_response: bool = False) -> Optional[Request]: - - request = Request(endpoint=endpoint, on_success=on_success, url_params=url_params, data=data, - capture_errors=capture_errors, priority=priority, raw_response=raw_response, - method=Request.PATCH) - return self.add(request) - - def delete(self, - endpoint: str, - on_success: Callable = lambda _: None, - url_params: Optional[Dict] = None, - data: DATA_TYPE = None, - capture_errors: bool = True, - priority: int = QNetworkRequest.NormalPriority, - raw_response: bool = False) -> Optional[Request]: - - request = Request(endpoint=endpoint, on_success=on_success, url_params=url_params, data=data, - capture_errors=capture_errors, priority=priority, raw_response=raw_response, - method=Request.DELETE) - return self.add(request) - - def add(self, request: Request, debug: bool = False) -> Optional[Request]: - """ Add a request to the queue. - - Args: - request: The request to add. - debug: Whether to print debug information. - - Returns: The request if it was added, None otherwise. - """ - if self._is_in_shutting_down(request): - # Do not send requests when Tribler is shutting down - return None - if debug: - request.caller = traceback.extract_stack()[-3] - - # Set last request id - self.last_request_id += 1 - request.id = self.last_request_id - - if len(self.active_requests) > self.limit: - self._drop_timed_out_requests() - - self.active_requests.add(request) - self.performed_requests.append(request) - request.set_manager(self) - self.logger.info(f'Request: {request}') - - qt_request = QNetworkRequest(QUrl(request.url)) - qt_request.setPriority(request.priority) - qt_request.setHeader(QNetworkRequest.ContentTypeHeader, 'application/x-www-form-urlencoded') - qt_request.setRawHeader(b'X-Api-Key', self.key.encode('ascii')) - - buf = QBuffer() - if request.raw_data is not None: - buf.setData(request.raw_data) - buf.open(QIODevice.ReadOnly) - - # A workaround for Qt5 bug. See https://github.com/Tribler/tribler/issues/7018 - self.setNetworkAccessible(QNetworkAccessManager.Accessible) - - request.reply = self.sendCustomRequest(qt_request, request.method.encode("utf8"), buf) - buf.setParent(request.reply) - - connect(request.reply.finished, request.on_finished) - return request - - def remove(self, request: Request): - self.active_requests.discard(request) - - def show_error(self, request: Request, data: Dict) -> str: - text = self.get_message_from_error(data) - if self._is_in_shutting_down(request): - return '' - - text = f'An error occurred during the request "{request}":\n\n{text}' - error_dialog = ConfirmationDialog(self.window, "Request error", text, [('CLOSE', BUTTON_TYPE_NORMAL)]) - - def on_close(_): - error_dialog.close_dialog() - - connect(error_dialog.button_clicked, on_close) - error_dialog.show() - return text - - def get_base_url(self) -> str: - if not self.port: - raise RuntimeError("API port is not set") - return f'{self.protocol}://{self.host}:{self.port}/' - - @staticmethod - def get_message_from_error(d: Dict) -> str: - error = d.get('error', {}) - if isinstance(error, str): - return error - - if message := error.get('message'): - return message - - return json.dumps(d) - - def clear(self): - for request in list(self.active_requests): - if request.cancellable: - request.cancel() - - def _is_in_shutting_down(self, request: Request) -> bool: - """ Check if the Tribler is in shutting down state.""" - if request.endpoint == SHUTDOWN_ENDPOINT: - return False - - if not self.window or not self.window.core_manager: - return False - - return self.window.core_manager.shutting_down - - def _drop_timed_out_requests(self): - for req in list(self.active_requests): - is_time_to_cancel = time() - req.time > self.timeout_interval - if is_time_to_cancel: - req.cancel() - - -# Request manager singleton. -request_manager = RequestManager() diff --git a/src/tribler/gui/qt_resources/buttonsdialog.ui b/src/tribler/gui/qt_resources/buttonsdialog.ui deleted file mode 100644 index 3bfd104bca..0000000000 --- a/src/tribler/gui/qt_resources/buttonsdialog.ui +++ /dev/null @@ -1,255 +0,0 @@ - - - Form - - - - 0 - 0 - 538 - 201 - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - ArrowCursor - - - Form - - - false - - - background-color: #333333; -border-radius: 2px; - - - - 0 - - - QLayout::SetMinimumSize - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 0 - - - QLayout::SetMinimumSize - - - 12 - - - 12 - - - 12 - - - 12 - - - - - - 0 - 0 - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - font-size: 14px; -color: white; - - - Title of the dialog - - - Qt::AlignCenter - - - - - - - - 0 - 0 - - - - color: #B5B5B5; - - - Here comes the main text of the dialog. - - - Qt::AlignCenter - - - true - - - Qt::LinksAccessibleByMouse|Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse - - - - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - background-color: #444; -border: none; -color: #C0C0C0; -padding: 4px; - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - color: #B5B5B5; - - - CheckBox - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - - 0 - 0 - - - - - 0 - 50 - - - - - 16777215 - 50 - - - - - - - - 12 - - - 12 - - - 12 - - - 12 - - - - - - - - - diff --git a/src/tribler/gui/qt_resources/createtorrentdialog.ui b/src/tribler/gui/qt_resources/createtorrentdialog.ui deleted file mode 100644 index 721eea0287..0000000000 --- a/src/tribler/gui/qt_resources/createtorrentdialog.ui +++ /dev/null @@ -1,611 +0,0 @@ - - - Form - - - - 0 - 0 - 538 - 523 - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - ArrowCursor - - - Form - - - false - - - QWidget { -background-color: #333333; -border-radius: 2px; -} -QLabel { - color: #ffffff; -} -QToolButton { -border: 1px solid #B5B5B5; -border-radius: 12px; -color: white; -padding-left: 4px; -padding-right: 4px; -} -QToolButton::hover { -border: 1px solid white; -color: white; -} -QLineEdit, QTextEdit { -background-color: #444; -border: none; -color: #C0C0C0; -padding: 4px; -} - - - - 0 - - - QLayout::SetMinimumSize - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - 12 - - - 12 - - - 12 - - - 12 - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - font-size: 16px; -font-weight: bold; -color: white; - - - Create a new torrent - - - - - - - - - - - 0 - 0 - - - - - QFormLayout::ExpandingFieldsGrow - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - Torrent name - - - - - - - - 0 - 0 - - - - - 0 - 60 - - - - - 16777215 - 60 - - - - - - - Torrent description - - - - - - - - 0 - 0 - - - - - 0 - 120 - - - - - 16777215 - 120 - - - - Qt::CustomContextMenu - - - background-color: #303030; -border: 1px solid #555; -color: #c0c0c0; -background-color:#444; -padding:4px; - - - - /a/b/c/d/e - - - - - /d/e/f/g/h - - - - - - - - color: #bbb; - - - Seed this torrent after creation - - - true - - - - - - - - 0 - 0 - - - - - 40 - 40 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 0 - 26 - - - - - 16777215 - 26 - - - - PointingHandCursor - - - - - - ADD DIRECTORY - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - - 0 - 26 - - - - - 16666666 - 26 - - - - PointingHandCursor - - - - - - ADD FILES - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - - Torrent files: - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - margin-right: 10px; - - - /home/tribler - - - Save to directory - - - - - - - - 0 - 0 - - - - - 0 - 26 - - - - - 16777215 - 26 - - - - PointingHandCursor - - - - - - BROWSE - - - - - - - - - - Name: - - - - - - - Description: - - - - - - - Torrent file destination: - - - - - - - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - background-color:#333; - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - color:#fff; - - - Creating torrent... - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 24 - - - - - 16777212 - 24 - - - - PointingHandCursor - - - - - - CREATE TORRENT - - - - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - CANCEL - - - - - - - - - - - - - diff --git a/src/tribler/gui/qt_resources/debugwindow.ui b/src/tribler/gui/qt_resources/debugwindow.ui deleted file mode 100644 index 4cfa5c4e11..0000000000 --- a/src/tribler/gui/qt_resources/debugwindow.ui +++ /dev/null @@ -1,1119 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 1012 - 695 - - - - MainWindow - - - - - 20 - - - - - 10 - - - - General - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - 200 - - - - Key - - - - - Value - - - - - - - - - Requests - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - 200 - - - - Path - - - - - Status code - - - - - Time - - - - - - - - - IPv8 - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 1 - - - - General - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - 200 - - - - Key - - - - - Value - - - - - - - - - Overlays - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Vertical - - - - 0 - - - - Name - - - - - Community ID - - - - - My peer - - - - - Peers - - - - - Upload(MB) - - - - - Download(MB) - - - - - Msg sent - - - - - Msg received - - - - - Diff(sec) - - - - - - - IP address - - - - - Port - - - - - Public key - - - - - - - - - - Details - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - The details are not available because the statistics measurement is not enabled. - To enable the statistics measurement, go to: - - SETTINGS -> DEBUG -> Network (IPv8) Statistics - - After enabling the checkbox and saving the settings, restart Tribler. - Then the details will be available here. - - - - Qt::PlainText - - - - - - - 0 - - - 150 - - - - Name - - - - - Upload(MB) - - - - - Download(MB) - - - - - Msg sent - - - - - Msg received - - - - - - - - - QWidget { - background-color: #000; - } - - - - Health - - - - - - - - - Tunnels - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 4 - - - - Circuits - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - - Circuit ID - - - - - Hops - - - - - Type - - - - - State - - - - - Up - - - - - Down - - - - - Uptime - - - - - Exit flags - - - - - - - - - Relays - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - - From circuit - - - - - To circuit - - - - - Rendezvous? - - - - - Up - - - - - Down - - - - - Uptime - - - - - - - - - Exit sockets - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - - From circuit - - - - - Enabled? - - - - - Up - - - - - Down - - - - - Uptime - - - - - - - - - Hidden swarms - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - - Infohash - - - - - # Seeders - - - - - # Connections - - - - - # Pending - - - - - Seeding? - - - - - Last lookup - - - - - Up - - - - - Down - - - - - - - - - Peers - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - - Ip - - - - - Port - - - - - Mid - - - - - Key compatible? - - - - - Flags - - - - - - - - - - - - - DHT - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 1 - - - - Statistics - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - 200 - - - - Key - - - - - Value - - - - - - - - - Buckets - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - - Prefix - - - - - Last changed - - - - - #Peers - - - - - - - - - - - - - Events - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - Type - - - - - Time - - - - - - - - - 0 - 0 - - - - - 0 - 160 - - - - - 16777215 - 160 - - - - true - - - - - - - - LibTorrent - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 100 - 32 - - - - - 16777215 - 16777215 - - - - - 0 - 32 - - - - - - 0 - 0 - 450 - 33 - - - - - 0 - - - QLayout::SetDefaultConstraint - - - 0 - - - 5 - - - - - - 100 - 20 - - - - - 75 - true - - - - margin:0 10px 0 4px - - - Hops - - - 0 - - - - - - - - 50 - false - - - - Zero - - - - - - - One - - - - - - - Two - - - - - - - Three - - - - - - - Export to file - - - - - - - - - - - 0 - - - - Settings - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - true - - - 200 - - - - Key - - - - - Value - - - - - - - - - Session - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 0 - - - true - - - 200 - - - - Key - - - - - Value - - - - - - - - - - - - - - - - - - diff --git a/src/tribler/gui/qt_resources/edit_metadata_dialog.ui b/src/tribler/gui/qt_resources/edit_metadata_dialog.ui deleted file mode 100644 index 9fc177b668..0000000000 --- a/src/tribler/gui/qt_resources/edit_metadata_dialog.ui +++ /dev/null @@ -1,615 +0,0 @@ - - - Form - - - - 0 - 0 - 552 - 570 - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - ArrowCursor - - - Form - - - false - - - QWidget { -background-color: #333333; -border-radius: 2px; -} -QToolButton { -border: 1px solid #B5B5B5; -border-radius: 12px; -color: white; -padding-left: 4px; -padding-right: 4px; -} -QToolButton::hover { -border: 1px solid white; -color: white; -} -QLineEdit, QTextEdit { -background-color: #555; -border: none; -color: #C0C0C0; -padding: 4px; -margin-top: 4px; -} - -QTreeWidget::item { -color: white; -height: 24px; -} - -QTreeWidget { -background-color: #444; -} - -QTreeWidget QHeaderView::section { -background-color: #666; -font-size: 12px; -border: 1px solid #333; -padding: 5px; -padding-left: 10px; -margin: 0px; -} - - - - 0 - - - QLayout::SetMinimumSize - - - 0 - - - 0 - - - 0 - - - 6 - - - - - - 0 - 0 - - - - color: white; - - - - 0 - - - QLayout::SetMinimumSize - - - 20 - - - 20 - - - 20 - - - 12 - - - - - - 0 - 0 - - - - - 0 - 20 - - - - - 16777215 - 20 - - - - font-size: 16px; font-weight: bold; - - - Edit metadata - - - Qt::AlignCenter - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 10 - - - - - - - - - 0 - 0 - - - - - 0 - 30 - - - - - 50 - true - false - - - - Content name - - - - - - - - 0 - 0 - - - - - 0 - 40 - - - - - 16777215 - 40 - - - - color: white; - - - <html><head/><body><p align="center">To help organizing the content in Tribler, you can edit this items' data.</p></body></html> - - - true - - - - - - - - 0 - 0 - - - - - 0 - 135 - - - - - 16777215 - 135 - - - - - - - QAbstractItemView::NoEditTriggers - - - 5 - - - 200 - - - - Property - - - - - Value - - - - - Content Item - - - - - Description - - - - - Year - - - - - Language - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 10 - - - - - - - - - 0 - 0 - - - - - 0 - 50 - - - - - 16777215 - 50 - - - - <html><head/><body><p align="center">You can also help by suggesting several tags below that accurately describe this item (e.g., <span style=" font-style:italic;">video</span>).</p></body></html> - - - true - - - - - - - Qt::Vertical - - - QSizePolicy::Maximum - - - - 20 - 10 - - - - - - - - - 0 - 0 - - - - - 0 - 32 - - - - - 16777215 - 16777215 - - - - background-color: #ccc; - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 10 - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 40 - - - - - 16777215 - 40 - - - - <html><head/><body><p><span style=" font-weight:600;">Need inspiration?</span> Below are some suggestions (click on a tag to add it).</p></body></html> - - - true - - - - - - - - 0 - 0 - - - - - 0 - 30 - - - - - - - - - - - - - - - 0 - 0 - - - - - 0 - 30 - - - - - true - - - - color: #bd2a28; - - - Error text here - - - - - - - - - - - 0 - 0 - - - - - 0 - 50 - - - - - 16777215 - 50 - - - - - - - - 12 - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - SAVE - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - CLOSE - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - TagsLineEdit - QLineEdit -
tribler.gui.widgets.tagslineedit.h
-
-
- - -
diff --git a/src/tribler/gui/qt_resources/feedback_dialog.ui b/src/tribler/gui/qt_resources/feedback_dialog.ui deleted file mode 100644 index 0a54e194d6..0000000000 --- a/src/tribler/gui/qt_resources/feedback_dialog.ui +++ /dev/null @@ -1,360 +0,0 @@ - - - Form - - - - 0 - 0 - 600 - 470 - - - - - 0 - 0 - - - - - 600 - 470 - - - - - 16777215 - 16777215 - - - - ArrowCursor - - - Form - - - false - - - QWidget { -background-color: #202020; -border: none; -} - -QLabel { -color: #B5B5B5; -} - -EllipseButton { -border: 1px solid #B5B5B5; -border-radius: 13px; -color: white; -} - -EllipseButton::hover { -border: 1px solid white; -color: white; -} - -QLineEdit, QPlainTextEdit { -background-color: #303030; -border: none; -color: #C0C0C0; -padding: 4px; -} - -QTreeWidget::item { -color: #C0C0C0; -} - -QTreeWidget { -background-color: #303030; -} - - - - 0 - - - QLayout::SetMinimumSize - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 10 - - - QLayout::SetMinimumSize - - - 12 - - - 10 - - - 12 - - - 10 - - - - - - 0 - 0 - - - - - 0 - 18 - - - - - 16777215 - 18 - - - - - - - Tribler experienced an error. Please help us by sending the following report. - - - - - - - true - - - - 0 - 0 - - - - - 0 - 200 - - - - - 16777215 - 16777215 - - - - - - - true - - - - - - - - 0 - 0 - - - - - 0 - 18 - - - - - 16777215 - 18 - - - - Additional information: - - - true - - - - - - - - 0 - 0 - - - - - 0 - 100 - - - - - 16777215 - 16777215 - - - - - - - - - - - - 0 - 0 - - - - - 0 - 50 - - - - - 16777215 - 50 - - - - - - - - 12 - - - 12 - - - 12 - - - 12 - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 26 - - - - - 16777215 - 26 - - - - PointingHandCursor - - - - - - SEND REPORT - - - - - - - - 0 - 26 - - - - - 16777215 - 26 - - - - PointingHandCursor - - - - - - CANCEL - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - EllipseButton - QToolButton -
tribler.gui.widgets.ellipsebutton.h
-
-
- - -
diff --git a/src/tribler/gui/qt_resources/loading_list_item.ui b/src/tribler/gui/qt_resources/loading_list_item.ui deleted file mode 100644 index a2c5bbd6e7..0000000000 --- a/src/tribler/gui/qt_resources/loading_list_item.ui +++ /dev/null @@ -1,77 +0,0 @@ - - - Form - - - - 0 - 0 - 585 - 60 - - - - - 0 - 0 - - - - - 0 - 60 - - - - - 16777215 - 60 - - - - ArrowCursor - - - Form - - - false - - - background-color: transparent; - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - color: white; -font-size: 15px; - - - Loading... - - - Qt::AlignCenter - - - - - - - - diff --git a/src/tribler/gui/qt_resources/mainwindow.ui b/src/tribler/gui/qt_resources/mainwindow.ui deleted file mode 100644 index 4146bf3514..0000000000 --- a/src/tribler/gui/qt_resources/mainwindow.ui +++ /dev/null @@ -1,4248 +0,0 @@ - - - MainWindow - - - - 0 - 0 - 1058 - 867 - - - - - 0 - 0 - - - - MainWindow - - - QWidget{ -color:white; -} - -QTreeWidget{ -color: white; -} - -QScrollBar:vertical { -border: none; -width: 10px; -} -QScrollBar:horizontal { -border: none; -height: 10px; -} -QScrollBar::sub-line:vertical, QScrollBar::add-line:vertical, QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { - border: none; - background: none; -} -QScrollBar::handle:vertical { -background: #bbb; -border-radius: 5px; -} -QScrollBar::handle:horizontal { -background: #bbb; -border-radius: 5px; -} -QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal, QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { - background: none; -} -Line { -background-color: red; -} -QStatusBar::item { -border: 0px solid black; -} -QTableView { -border: none; -font-size: 13px; -outline: 0; -} -QTableView::item::hover { -background-color: rgba(255,255,255, 50); -} -QTableView::item { -color: white; -height: 40px; -border-bottom: 1px solid #303030; -} - -QHeaderView { -background-color: transparent; -} -QHeaderView::section { -background-color: transparent; -border: none; -color: #B5B5B5; -padding: 10px; -font-size: 14px; -border-bottom: 1px solid #303030; -} -QHeaderView::section:hover { -color: white; -} -QTableCornerButton::section { -background-color: transparent; -} -QHeaderView::section:up-arrow { -color: white; -} -QHeaderView::section:down-arrow { -color: white; -} -QHeaderView { -qproperty-defaultAlignment: AlignLeft; -} -QListView { -color:#B5B5B5; -} - -QToolButton QToolTip { -padding: 4px; -} - - - - - - - QWidget { -background-color: #282828;} - -TabButtonPanel UnderlineTabButton{ -color: #B5B5B5; -border: none; -border-bottom: 3px solid transparent; -background: none; -font-size: 14px; -} - -TabButtonPanel UnderlineTabButton::hover { -color: white; -} - -TabButtonPanel UnderlineTabButton::checked { -border-bottom: 3px solid #e67300; -color: white; -} - -CircleButton { -border: 1px solid #B5B5B5; -border-radius: 16px; -} - -CircleButton::hover { -border: 1px solid white; -} - -EllipseButton { -border: 1px solid #B5B5B5; -border-radius: 12px; -color: white; -} - -EllipseButton::hover { -border: 1px solid white; -color: white; -} - -QLineEdit, QTextEdit { -background-color: #303030; -border: none; -color: #C0C0C0; -padding: 4px; -} - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - background-color: #cc6600; -color: #eee; - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - font-size: 14px; - - - Transaction in progress, Please don't close Tribler. - - - Qt::AlignCenter - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 200 - 0 - - - - - 180 - 16777215 - - - - false - - - -QWidget { - background-color: #202020; - font-size: 14px; - color: white; - text-align: left; -} - -QPushButton { - color: #B5B5B5; - border: none; - background: none; - padding-left: 20px; - border-left: 4px solid transparent; -} - -QPushButton::checked { - border-left: 4px solid #e67300; - color: white; -} - - - - - 10 - - - 0 - - - - - - 0 - 0 - - - - - 0 - 38 - - - - PointingHandCursor - - - -QToolButton#add_torrent_button { - border-style: outset; - border-width: 1px; - border-radius: 15px; - border-color: grey; - margin-left: 15px; - padding-left: 10px; - margin-right: 10px; -} -QToolButton::menu-indicator { - image: none; - width: 0px; -} - - - - Add torrent - - - - ../images/add.png../images/add.png - - - - - 15 - 15 - - - - Qt::ToolButtonTextBesideIcon - - - QToolButton::InstantPopup - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 14 - - - - - - - - - 0 - 26 - - - - PointingHandCursor - - - Qt::NoFocus - - - - - - Downloads - - - - ../images/downloads.png - ../images/downloads.png../images/downloads.png - - - - - 16 - 16 - - - - true - - - true - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 5 - - - - - - - - - 0 - 0 - - - - - 0 - 26 - - - - PointingHandCursor - - - Qt::NoFocus - - - Popular - - - - ../images/fire.png - ../images/fire.png - ../images/fire.png../images/fire.png - - - - - 16 - 16 - - - - true - - - true - - - - - - - Qt::Vertical - - - QSizePolicy::Expanding - - - - 20 - 40 - - - - - - - - - - - 2 - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 0 - 50 - - - - - 16777215 - 50 - - - - color: #eee; -background-color: transparent; -font-size: 20px; -font-weight: bold; -margin: 10px; - - - Settings - - - - - - - - 0 - 0 - - - - - 42 - 24 - - - - PointingHandCursor - - - - - - SAVE - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - false - - - - 0 - 0 - - - - - 0 - 36 - - - - - 16777215 - 36 - - - - PointingHandCursor - - - - - - GENERAL - - - true - - - true - - - - - - - - 0 - 0 - - - - - 0 - 36 - - - - - 16777215 - 36 - - - - PointingHandCursor - - - - - - CONNECTION - - - true - - - - - - - - 0 - 0 - - - - - 0 - 36 - - - - - 16777215 - 36 - - - - PointingHandCursor - - - - - - BANDWIDTH - - - true - - - - - - - - 0 - 0 - - - - - 0 - 36 - - - - PointingHandCursor - - - - - - SEEDING - - - true - - - - - - - - 0 - 0 - - - - - 0 - 36 - - - - - 16777215 - 36 - - - - PointingHandCursor - - - - - - ANONYMITY - - - true - - - - - - - - 0 - 0 - - - - - 0 - 36 - - - - - 16777215 - 36 - - - - PointingHandCursor - - - - - - DEBUG - - - true - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - QScrollArea { -border-top: 1px solid #555; -} - - - QAbstractScrollArea::AdjustToContents - - - true - - - - - 0 - 0 - 300 - 506 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 300 - 0 - - - - - 16777215 - 16777215 - - - - QStackedWidget QLabel, QStackedWidget QRadioButton, QStackedWidget { -color: #B5B5B5; -} -QComboBox { -background-color: #333333; -border-radius: 4px; -color: white; -} -QComboBox::drop-down { -border: 0px; -} - -QCheckBox{margin-left: 10px;} -QCheckBox::indicator { margin: 4px; } - - - - 0 - - - - - 0 - 0 - - - - - QFormLayout::ExpandingFieldsGrow - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - font-weight: bold; -color: white;margin-top:10px; - - - Download location - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Save files to: - - - - - - - Qt::Horizontal - - - - 15 - 20 - - - - - - - - - 0 - 0 - - - - - 250 - 28 - - - - - 250 - 28 - - - - - - - File location - - - - - - - - 50 - 30 - - - - - 50 - 30 - - - - PointingHandCursor - - - Browse download location - - - Browse - - - - ../images/browse_folder.svg../images/browse_folder.svg - - - - Qt::ToolButtonIconOnly - - - false - - - Qt::NoArrow - - - - - - - - - - Always ask download settings? - - - - - - - font-weight: bold; -color: white;margin-top:10px; - - - Default download settings - - - - - - - Download anonymously using proxies - - - - - - - Encrypted anonymous seeding using proxies - - - - - - - font-weight: bold; -color: white;margin-top:10px; - - - Watch Folder - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Torrent watch folder - - - - - - - Qt::Horizontal - - - - 15 - 20 - - - - - - - - - 0 - 0 - - - - - 250 - 28 - - - - - 250 - 28 - - - - - - - Location - - - - - - - - 50 - 30 - - - - - 50 - 30 - - - - PointingHandCursor - - - Browse watchfolder - - - Browse - - - - ../images/browse_folder.svg../images/browse_folder.svg - - - - Qt::ToolButtonIconOnly - - - false - - - Qt::NoArrow - - - - - - - - - - font-weight: bold; -color: white;margin-top:10px - - - Tray Icon - - - - - - - Minimize to system tray? - - - - - - - Use monochrome icon? - - - - - - - Commit changes automatically (requires Tribler restart) - - - - - - - - 75 - true - - - - font-weight: bold; -color: white;margin-top:10px; - - - Tags - - - - - - - Hide tags from content items - - - - - - - - 75 - true - - - - font-weight: bold; -color: white;margin-top:10px; - - - Downloads table - - - - - - - Remember header state (keeps columns width and order) - - - - - - - - - QFormLayout::ExpandingFieldsGrow - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - font-weight: bold; -color: white; - - - Torrent proxy settings - - - - - - - - - - Type - - - - - - - - 250 - 28 - - - - - 250 - 28 - - - - - None - - - - - Socks4 - - - - - Socks5 - - - - - Socks5 with authentication - - - - - HTTP - - - - - HTTP with authentication - - - - - - - - - - - Server - - - - - - - - 0 - 0 - - - - - 250 - 0 - - - - - 250 - 16777215 - - - - - - - Server - - - - - - - - - - Port - - - - - - - - 250 - 0 - - - - - 250 - 16777215 - - - - - - - Port - - - - - - - - - - Username - - - - - - - - 250 - 0 - - - - - 250 - 16777215 - - - - - - - Username - - - - - - - - - - Password - - - - - - - - 250 - 0 - - - - - 250 - 16777215 - - - - - - - QLineEdit::Password - - - Password - - - - - - - font-weight: bold; -color: white; - - - BitTorrent features - - - - - - - - - - Enabled bandwidth -Management (uTP) - - - - - - - margin-top: 2px; - - - - - - - - - - Max connections -per download - - - - - - - - 0 - 0 - - - - - 250 - 0 - - - - - 250 - 16777215 - - - - Max connections per download - - - - - - - 0 = unlimited - - - - - - - - - QFormLayout::ExpandingFieldsGrow - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - font-weight: bold; -color: white; - - - Bandwidth limits - - - - - - - - - - Upload rate limit - - - - - - - - 0 - 0 - - - - - 3 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 100 - 0 - - - - - 100 - 16777215 - - - - - - - Upload - - - - - - - - - - KB/s (0 = unlimited) - - - - - - - - - - - - - Download rate limit - - - - - - - - 0 - 0 - - - - - - - Note that these settings apply to anonymous and plain downloads. - - - - - - - - 0 - 0 - - - - - 3 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 100 - 0 - - - - - 100 - 16777215 - - - - - - - Download - - - - - - - - - - KB/s (0 = unlimited) - - - - - - - - - - - - QFormLayout::ExpandingFieldsGrow - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - font-weight: bold; -color: white; - - - Seeding options - - - - - - - - 0 - 0 - - - - - 60 - 28 - - - - - 60 - 28 - - - - - - - hh:mm - - - - - - - - 0 - 0 - - - - - 70 - 24 - - - - - 70 - 24 - - - - - 0.5 - - - - - 0.75 - - - - - 1.0 - - - - - 1.5 - - - - - 2.0 - - - - - 3.0 - - - - - 5.0 - - - - - - - - - - - No seeding - - - - - - - - - - Seeding for (hours:minutes) - - - - - - - - - - Unlimited seeding - - - - - - - - - - Seed until up/down ratio is bigger than - - - - - - - Note that these settings also apply to existing downloads. - - - - - - - - - 10 - - - 12 - - - 12 - - - 12 - - - 12 - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 20 - 10 - - - - - - - - font-weight: bold; -color: white; - - - Proxy downloading - - - - - - - color: white; - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - High speed -Minimum anonymity - - - - - - - Low speed -High anonymity - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - - - - - - 1 - - - 3 - - - 1 - - - Qt::Horizontal - - - QSlider::TicksBelow - - - 1 - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - QFormLayout::ExpandingFieldsGrow - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - font-weight: bold; -color: white; - - - Developer mode - - - - - - - Enable developer mode - - - - - - - font-weight: bold; -color: white; - - - Log Directory - - - - - - - - - - Log directory - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 250 - 28 - - - - - 250 - 28 - - - - - - - Log directory - - - - - - - - 50 - 30 - - - - - 50 - 30 - - - - Browse directory - - - Browse - - - - ../images/browse_folder.svg../images/browse_folder.svg - - - - Qt::ToolButtonIconOnly - - - false - - - Qt::NoArrow - - - - - - - - - - font-weight: bold;color: white; - - - Resource Monitoring - - - - - - - Enable resource monitoring - - - - - - - font-weight: bold;color: white; - - - Network (IPv8) Statistics - - - - - - - Enable network statistics (requires restart) - - - - - - - Note: Enabling statistics slightly slows down Tribler - - - - - - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 0 - 44 - - - - - 16777215 - 44 - - - - margin: 10px; -font-size: 20px; -font-weight: bold; -color: white; - - - Downloads - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 0 - - - - - 180 - 28 - - - - - 180 - 28 - - - - QLineEdit { -border-radius: 3px; -} -QLineEdit:focus, QLineEdit::hover { -background-color: #404040; -color: white; -} - - - Filter - - - true - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 12 - 20 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Minimum - - - - 6 - 20 - - - - - - - - false - - - - 28 - 28 - - - - - 28 - 28 - - - - PointingHandCursor - - - border-radius: 14px; -padding-left: 2px; - - - - - - - ../images/play.png../images/play.png - - - - - 14 - 14 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 6 - 20 - - - - - - - - false - - - - 28 - 28 - - - - - 28 - 28 - - - - PointingHandCursor - - - border-radius: 14px; - - - - - - - ../images/stop.png../images/stop.png - - - - - 12 - 12 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 6 - 20 - - - - - - - - false - - - - 28 - 28 - - - - - 28 - 28 - - - - PointingHandCursor - - - border-radius: 14px; - - - - - - - ../images/delete.png../images/delete.png - - - - - 12 - 12 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 12 - 20 - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - false - - - - 0 - 0 - - - - - 0 - 36 - - - - - 16777215 - 36 - - - - PointingHandCursor - - - - - - ALL - - - true - - - true - - - - - - - - 0 - 0 - - - - - 0 - 36 - - - - - 16777215 - 36 - - - - PointingHandCursor - - - - - - DOWNLOADING - - - true - - - - - - - - 0 - 0 - - - - - 0 - 36 - - - - - 16777215 - 36 - - - - PointingHandCursor - - - - - - COMPLETED - - - true - - - - - - - - 0 - 0 - - - - - 0 - 36 - - - - - 16777215 - 36 - - - - PointingHandCursor - - - - - - ACTIVE - - - true - - - - - - - - 0 - 0 - - - - - 0 - 36 - - - - - 16777215 - 36 - - - - PointingHandCursor - - - - - - INACTIVE - - - true - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 6 - - - - - QSplitter::handle { background-color: #555; } - - - Qt::Vertical - - - 5 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - color: #B5B5B5; margin-left: 6px; - - - - - - - - - - Qt::CustomContextMenu - - - QTreeWidget { - border: none; - font-size: 13px; - outline: 0; - } - QTreeWidget::item { - color: white; - height: 40px; - border-bottom: 1px solid #303030; - } - QTreeWidget::item:hover { - background-color: #303030; - } - QTreeWidget::item::selected { - background-color: #444; - } - QHeaderView { - background-color: transparent; - } - QHeaderView::section { - background-color: transparent; - border: none; - color: #B5B5B5; - padding: 10px; - font-size: 14px; - border-bottom: 1px solid #303030; - } - QHeaderView::section:hover { - color: white; - } - QTableCornerButton::section { - background-color: transparent; - } - - - false - - - QAbstractItemView::ExtendedSelection - - - QAbstractItemView::ScrollPerPixel - - - 0 - - - true - - - true - - - - NAME - - - - - SIZE - - - - - PROGRESS - - - - - STATUS - - - - - SEEDS - - - - - PEERS - - - - - SPEED (DOWN) - - - - - SPEED (UP) - - - - - RATIO - - - - - ANONYMOUS? - - - - - HOPS - - - - - ETA - - - - - ADDED ON - - - - - - - - - QLabel { -color: white; -} -QTabWidget { -border: none; -} -QTabBar::tab { - color: white; - background-color: #555; -} -QTabBar::tab:selected { - color: #555; - background-color: #777; -} - - - 1 - - - false - - - - Details - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - true - - - - - 0 - 0 - 854 - 378 - - - - - QFormLayout::AllNonFixedFieldsGrow - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - 12 - - - 12 - - - 12 - - - 12 - - - - - font-weight: bold; - - - Progress - - - - - - - - 0 - 0 - - - - - 0 - 26 - - - - - 600000 - 26 - - - - background-color: white; -margin-right: 10px; - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 6 - - - - - - - - font-weight: bold; - - - Name - - - - - - - - - - - - - - font-weight: bold; - - - Status - - - - - - - - - - - - - - font-weight: bold; - - - Filesize - - - - - - - - - - - - - - font-weight: bold; - - - Health - - - - - - - - - - - - - - font-weight: bold; - - - Infohash - - - - - - - Qt::AlignCenter - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - 00000000000000000000 - - - - - - - - 28 - 28 - - - - - 28 - 28 - - - - PointingHandCursor - - - Click to copy magnet link - - - border: none; - - - - - - - ../images/magnet.png../images/magnet.png - - - - - 25 - 25 - - - - - - - - - - font-weight: bold; - - - Destination - - - - - - - - - - - - - - font-weight: bold; - - - Ratio - - - - - - - - - - - - - - font-weight: bold; - - - Availability - - - - - - - - - - - - - - - - - - - Files - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - Trackers - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QTreeWidget { -border: none; -font-size: 13px; -} -QTreeWidget::item { -color: white; -border-bottom: 1px solid #303030; -} -QTreeWidget::item:hover { -background-color: #303030; -} -QTreeWidget::item::selected { -background-color: #444; -} -QHeaderView { -background-color: transparent; -} -QHeaderView::section { -background-color: transparent; -border: none; -color: #B5B5B5; -padding: 10px; -font-size: 14px; -border-bottom: 1px solid #303030; -} -QHeaderView::drop-down { -color: red; -} -QHeaderView::section:hover { -color: white; -} -QTableCornerButton::section { -background-color: transparent; -} - - - false - - - QAbstractItemView::NoSelection - - - 0 - - - - NAME - - - - - STATUS - - - - - PEERS - - - - - - - - - Peers - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QTreeWidget { -border: none; -font-size: 13px; -} -QTreeWidget::item { -color: white; -border-bottom: 1px solid #303030; -} -QTreeWidget::item:hover { -background-color: #303030; -} - QTreeWidget::item::selected { - background-color: #444; - } - QHeaderView { - background-color: transparent; - } - QHeaderView::section { - background-color: transparent; - border: none; - color: #B5B5B5; - padding: 10px; - font-size: 14px; - border-bottom: 1px solid #303030; - } - QHeaderView::drop-down { - color: red; - } - QHeaderView::section:hover { - color: white; - } - QTableCornerButton::section { - background-color: transparent; - } - - - false - - - QAbstractItemView::NoSelection - - - 0 - - - true - - - - PEER (IP/PORT) - - - - - COMPLETED - - - - - SPEED (DOWN) - - - - - SPEED (UP) - - - - - FLAGS - - - - - CLIENT - - - - - - - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - border: none; - - - QPainter::Antialiasing|QPainter::HighQualityAntialiasing|QPainter::TextAntialiasing - - - - - - - - 0 - 160 - - - - color: white; font-size: 18px; -padding:0 15px; - - - Loading... - - - Qt::AlignCenter - - - true - - - - - - - - - - true - - - - 0 - 0 - - - - - 180 - 32 - - - - - 180 - 32 - - - - Qt::LeftToRight - - - false - - - border-radius:16px; -background-color:#e67300; -color:#fff; -padding:0 10px; -text-transform: uppercase; -font-weight:bold; - - - Force Shutdown - - - false - - - false - - - false - - - - - - - true - - - - 0 - 0 - - - - - 180 - 32 - - - - - 180 - 32 - - - - Qt::LeftToRight - - - false - - - border-radius:16px; -background-color:#e67300; -color:#fff; -padding:0 10px; -text-transform: uppercase; -font-weight:bold; - - - Skip - - - false - - - false - - - false - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 60 - - - - - - - - - - - - - - - 0 - 0 - - - - - 0 - 50 - - - - - 16777215 - 50 - - - - QWidget { -background-color: #202020; -border-bottom: 1px solid #242424; -} - -CircleButton { -border: 2px solid white; -} - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 16 - 18 - - - - - 16 - 18 - - - - PointingHandCursor - - - border: none; - - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - color: #e67300; -font-size: 28px; -font-weight: bold; -font-family: "Arial"; - - - Tribler - - - Qt::AlignCenter - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 12 - 20 - - - - - - - - - 0 - 0 - - - - - 350 - 28 - - - - - 350 - 28 - - - - QLineEdit { -background-color: #eee; -border: none; -padding-left: 5px; -border-radius: 14px; -color: black; -} - -QLineEdit:focus { -border: 1px solid #FF924F; -} - - - Search for your favorite content - - - true - - - - - - - Qt::Horizontal - - - QSizePolicy::Expanding - - - - 40 - 20 - - - - - - - - - 0 - 26 - - - - - 16777215 - 30 - - - - PointingHandCursor - - - Qt::NoFocus - - - - - - Debug - - - - ../images/debug.png - ../images/debug.png../images/debug.png - - - - - 16 - 16 - - - - false - - - true - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 22 - 22 - - - - - 22 - 22 - - - - PointingHandCursor - - - Settings - - - border: none; -background: none; - - - - - - - ../images/gear.png../images/gear.png - - - - - 20 - 20 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 10 - - - - - - - - - - - - - - TorrentFileTreeWidget - QTreeWidget -
tribler.gui.widgets.torrentfiletreewidget.h
-
- - PreformattedTorrentFileTreeWidget - QTableWidget -
tribler.gui.widgets.torrentfiletreewidget.h
-
- - EllipseButton - QToolButton -
tribler.gui.widgets.ellipsebutton.h
-
- - UnderlineTabButton - QToolButton -
tribler.gui.widgets.underlinetabbutton.h
-
- - TabButtonPanel - QWidget -
tribler.gui.widgets.tabbuttonpanel.h
- 1 -
- - SettingsPage - QWidget -
tribler.gui.widgets.settingspage.h
- 1 -
- - DownloadsPage - QWidget -
tribler.gui.widgets.downloadspage.h
- 1 -
- - DownloadsDetailsTabWidget - QTabWidget -
tribler.gui.widgets.downloadsdetailstabwidget.h
- 1 -
- - LoadingPage - QWidget -
tribler.gui.widgets.loadingpage.h
- 1 -
- - DiscoveringPage - QWidget -
tribler.gui.widgets.discoveringpage.h
- 1 -
- - CircleButton - QToolButton -
tribler.gui.widgets.circlebutton.h
-
- - DownloadProgressBar - QWidget -
tribler.gui.widgets.downloadprogressbar.h
- 1 -
- - ChannelContentsWidget - QWidget -
tribler.gui.widgets.channelcontentswidget.h
-
- - ClickableLineEdit - QLineEdit -
tribler.gui.widgets.clickable_line_edit.h
-
- - SearchResultsWidget - QWidget -
tribler.gui.widgets.searchresultswidget.h
-
-
- - - - top_search_bar - textChanged(QString) - MainWindow - on_search_text_change() - - - 308 - 24 - - - 427 - 317 - - - - - top_menu_button - clicked() - MainWindow - on_top_menu_button_click() - - - 17 - 24 - - - 427 - 317 - - - - - settings_button - clicked() - MainWindow - on_settings_button_click() - - - 1003 - 35 - - - 427 - 327 - - - - - force_shutdown_btn - clicked() - MainWindow - clicked_force_shutdown() - - - 221 - 101 - - - 20 - 20 - - - - - skip_conversion_btn - clicked() - MainWindow - clicked_skip_conversion() - - - 221 - 101 - - - 20 - 20 - - - - - left_menu_button_downloads - clicked() - MainWindow - clicked_menu_button_downloads() - - - 101 - 295 - - - 427 - 317 - - - - - left_menu_button_popular - clicked() - MainWindow - clicked_menu_button_popular() - - - 20 - 20 - - - 20 - 20 - - - - - - on_top_search_button_click() - on_add_torrent_button_click() - on_top_menu_button_click() - clicked_menu_button_downloads() - clicked_menu_button_subscriptions() - on_search_text_change() - clicked_menu_button_discovered() - on_settings_button_click() - clicked_menu_button_search() - clicked_force_shutdown() - clicked_skip_conversion() - clicked_menu_button_popular() - -
diff --git a/src/tribler/gui/qt_resources/qtbug.ui b/src/tribler/gui/qt_resources/qtbug.ui deleted file mode 100644 index ab6e6c0c23..0000000000 --- a/src/tribler/gui/qt_resources/qtbug.ui +++ /dev/null @@ -1,16 +0,0 @@ - - - details_tab_widgetk - - - - - QtBug - QWidget -
tribler.gui.widgets.qtbug.h
- 0 -
-
- - -
diff --git a/src/tribler/gui/qt_resources/search_results.ui b/src/tribler/gui/qt_resources/search_results.ui deleted file mode 100644 index c610b6c206..0000000000 --- a/src/tribler/gui/qt_resources/search_results.ui +++ /dev/null @@ -1,172 +0,0 @@ - - - search_stacked_widget - - - - 0 - 0 - 657 - 513 - - - - 0 - - - - - - - - 0 - 0 - - - - - 16777215 - 10 - - - - 0 - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter - - - - - - - - - - - 0 - 0 - - - - - 0 - 60 - - - - - - - TextLabel - - - - - - - 0 - - - - - - 100 - 0 - - - - Show now - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - - 0 - 0 - - - - - 16777215 - 10 - - - - 0 - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter - - - - - - - - - - - - - - - EllipseButton - QToolButton -
tribler.gui.widgets.ellipsebutton.h
-
- - ChannelContentsWidget - QWidget -
tribler.gui.widgets.channelcontentswidget.h
-
- - TimeoutProgressBar - QProgressBar -
tribler.gui.widgets.timeoutprogressbar.h
- 1 -
- - SearchProgressBar - QProgressBar -
tribler.gui.widgets.search_progress_bar.h
- 1 -
-
- - -
diff --git a/src/tribler/gui/qt_resources/startdownloaddialog.ui b/src/tribler/gui/qt_resources/startdownloaddialog.ui deleted file mode 100644 index ca56477462..0000000000 --- a/src/tribler/gui/qt_resources/startdownloaddialog.ui +++ /dev/null @@ -1,554 +0,0 @@ - - - Form - - - - 0 - 0 - 538 - 562 - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - ArrowCursor - - - Form - - - false - - - QWidget { -background-color: #333333; -border-radius: 2px; -} -QToolButton { -border: 1px solid #B5B5B5; -border-radius: 12px; -color: white; -padding-left: 4px; -padding-right: 4px; -} -QToolButton::hover { -border: 1px solid white; -color: white; -} - - - - - - 0 - - - QLayout::SetMinimumSize - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 0 - - - QLayout::SetMinimumSize - - - 12 - - - 12 - - - 12 - - - 12 - - - - - - 0 - 0 - - - - - 0 - 20 - - - - - 16777215 - 20 - - - - font-size: 14px; -color: white; - - - Download torrent - - - - - - - - 0 - 0 - - - - - 0 - 28 - - - - - 16777215 - 200 - - - - color: #B5B5B5; - - - my_fancy_torrent - - - true - - - - - - - - 10 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - QComboBox { -background-color: #444; -border: none; -color: #C0C0C0; -padding: 4px; -} -QComboBox::drop-down { -width: 20px; -border: 1px solid #999; -border-radius: 2px; -} -QComboBox QAbstractItemView { -selection-background-color: #707070; -} - - - true - - - - - - - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - BROWSE - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 10 - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 10 - - - - - - - - - 0 - 0 - - - - - 0 - 240 - - - - - 16777215 - 240 - - - - - -QHeaderView { -background-color: transparent; -} -QHeaderView::section { -background-color: transparent; -border: none; -padding-left: 10px; -height: 26px; -color: #B5B5B5; -font-size: 14px; -border-bottom: 1px solid #303030; -} -QTableCornerButton::section { -background-color: transparent; -} - - - -QCheckBox{ -background: yellow; -} - - - - QAbstractItemView::NoSelection - - - true - - - 150 - - - true - - - - NAME - - - - - SIZE - - - - - - - - - 0 - 0 - - - - - 0 - 40 - - - - - 16777215 - 40 - - - - color: white; - - - Loading files... - - - Qt::AlignCenter - - - - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - - - - Download anonymous using proxies - - - - - - - true - - - - 0 - 24 - - - - - - - Seed encrypted using proxies - - - false - - - - - - - - - - - 0 - 0 - - - - - 0 - 50 - - - - - 16777215 - 50 - - - - - 12 - - - 12 - - - 12 - - - 12 - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - - - - DOWNLOAD - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - CANCEL - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - ClickableLabel - QLabel -
tribler.gui.widgets.clickablewidgets.h
-
- - TorrentFileTreeWidget - QTreeWidget -
tribler.gui.widgets.torrentfiletreewidget.h
-
-
- - -
diff --git a/src/tribler/gui/qt_resources/torrents_list.ui b/src/tribler/gui/qt_resources/torrents_list.ui deleted file mode 100644 index dd6464bb2f..0000000000 --- a/src/tribler/gui/qt_resources/torrents_list.ui +++ /dev/null @@ -1,679 +0,0 @@ - - - torrents_list - - - - 0 - 0 - 976 - 569 - - - - - 0 - 0 - - - - - 0 - 0 - - - - ArrowCursor - - - Form - - - false - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 16777215 - 50 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 16 - 18 - - - - - 16 - 18 - - - - PointingHandCursor - - - border: none; - - - BACK - - - - ../images/page_back.png../images/page_back.png - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - - 400 - 50 - - - - - 16777215 - 50 - - - - Qt::NoContextMenu - - - color: #eee; -background-color: transparent; -font-size: 20px; -font-weight: bold; - - - - - - Qt::LinksAccessibleByMouse - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 12 - 20 - - - - - - - - - 0 - 0 - - - - color: #eee; font-size: 15px; - - - Channel state - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - font-size: 14px; -color: #B5B5B5; - - - 99/100 torrents - - - - - - - - 20 - 20 - - - - - 20 - 20 - - - - - 14 - - - - This page shows the list of popular torrents collected by Tribler during the last 24 hours. - - - i - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - true - - - - 0 - 50 - - - - - 16777215 - 50 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 20 - 20 - - - - - - - - - 120 - 24 - - - - - 120 - 24 - - - - PointingHandCursor - - - NEW CHANNEL - - - - - - - - 0 - 0 - - - - - 36 - 16 - - - - - 36 - 16 - - - - - false - - - - PointingHandCursor - - - - - - QToolButton::menu-indicator{width:0px;} -QToolButton{border:none;margin-left:16px;} - - - - - - - ../images/ellipsis.png../images/ellipsis.png - - - - 32 - 32 - - - - QToolButton::InstantPopup - - - false - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 0 - - - - - - - - - - - - - - - 0 - 40 - - - - - QLayout::SetFixedSize - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - - 250 - 28 - - - - - 120 - 28 - - - - 🔍 Filter - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - false - - - #line{color: #303030; - -} - - - QFrame::Plain - - - 1 - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - QSplitter::handle { background-color: #555; } - - - Qt::Vertical - - - - QTableView { -border: none; -font-size: 13px; -outline: 0; -} - -QTableView::item { -color: white; -height: 40px; -border-bottom: 1px solid #303030; -} - - -QTableView::item::hover { - background-color: rgba(255,255,255, 50); - } - - QHeaderView { - background-color: transparent; - } - QHeaderView::section { - background-color: transparent; - border: none; - color: #B5B5B5; - padding: 10px; - font-size: 14px; - border-bottom: 1px solid #303030; - } - QHeaderView::section:hover { - color: white; - } - QTableCornerButton::section { - background-color: transparent; - } - QHeaderView::section:up-arrow { - color: white; - } - QHeaderView::section:down-arrow { - color: white; - } - - - - Qt::ScrollBarAlwaysOff - - - QAbstractItemView::ExtendedSelection - - - QAbstractItemView::SelectRows - - - false - - - true - - - false - - - false - - - false - - - - - - - - - EllipseButton - QToolButton -
tribler.gui.widgets.ellipsebutton.h
-
- - QtBug - QWidget -
tribler.gui.widgets.qtbug.h
- 1 -
- - TriblerContentTableView - QTableView -
tribler.gui.widgets.lazytableview.h
-
- - ToggleButton - QWidget -
tribler.gui.widgets.togglebutton.h
-
- - InstantTooltipButton - QToolButton -
tribler.gui.widgets.instanttooltipbutton.h
-
-
- - -
diff --git a/src/tribler/gui/single_application.py b/src/tribler/gui/single_application.py deleted file mode 100644 index e86fe0463c..0000000000 --- a/src/tribler/gui/single_application.py +++ /dev/null @@ -1,95 +0,0 @@ -# Copied and modified from http://stackoverflow.com/a/12712362/605356 - -import logging -import sys -from typing import Optional - -from PyQt5.QtCore import QTextStream, pyqtSignal -from PyQt5.QtNetwork import QLocalServer, QLocalSocket -from PyQt5.QtWidgets import QApplication - -from tribler.gui.tribler_window import TriblerWindow -from tribler.gui.utilities import connect, disconnect - - -class QtSingleApplication(QApplication): - """ - This class makes sure that we can only start one Tribler application. - When a user tries to open a second Tribler instance, the current active one will be brought to front. - """ - - message_received = pyqtSignal(str) - - def __init__(self, win_id: str, *argv): - self.logger = logging.getLogger(self.__class__.__name__) - self.logger.info(f'Start Tribler application. Win id: "{win_id}". ' - f'Sys argv: "{sys.argv}"') - - QApplication.__init__(self, list(argv)) - self.tribler_window: Optional[TriblerWindow] = None - - self._id = win_id - - # Is there another instance running? - self._outgoing_connection = QLocalSocket() - self._outgoing_connection.connectToServer(self._id) - - self.connected_to_previous_instance = self._outgoing_connection.waitForConnected() - - self._stream_to_running_app = None - self._incoming_connection = None - self._incoming_stream = None - self._server = None - - # Cleanup any past, crashed server. - error = self._outgoing_connection.error() - self.logger.info(f'No running instances (socket error: {error})') - if error == QLocalSocket.ConnectionRefusedError: - self.logger.info('Received QLocalSocket.ConnectionRefusedError; removing server.') - self.cleanup_crashed_server() - self._outgoing_connection = None - self._server = QLocalServer() - self._server.listen(self._id) - connect(self._server.newConnection, self._on_new_connection) - - def cleanup_crashed_server(self): - self.logger.info('Cleaning up crashed server...') - if self._incoming_connection: - self._incoming_connection.disconnectFromServer() - if self._outgoing_connection: - self._outgoing_connection.disconnectFromServer() - if self._server: - self._server.close() - QLocalServer.removeServer(self._id) - self.logger.info('Crashed server was removed') - - def get_id(self): - return self._id - - def send_message(self, msg): - self.logger.info(f'Send message: {msg}') - if not self._stream_to_running_app: - return False - self._stream_to_running_app << msg << '\n' # pylint: disable=pointless-statement - self._stream_to_running_app.flush() - return self._outgoing_connection.waitForBytesWritten() - - def _on_new_connection(self): - if self._incoming_connection: - disconnect(self._incoming_connection.readyRead, self._on_ready_read) - self._incoming_connection = self._server.nextPendingConnection() - if not self._incoming_connection: - return - self._incoming_stream = QTextStream(self._incoming_connection) - self._incoming_stream.setCodec('UTF-8') - connect(self._incoming_connection.readyRead, self._on_ready_read) - if self.tribler_window: - self.tribler_window.raise_window() - - def _on_ready_read(self): - while True: - msg = self._incoming_stream.readLine() - if not msg: - break - self.logger.info(f'A message received via the local socket: {msg}') - self.message_received.emit(msg) diff --git a/src/tribler/gui/start_gui.py b/src/tribler/gui/start_gui.py deleted file mode 100644 index 2f0e82db4d..0000000000 --- a/src/tribler/gui/start_gui.py +++ /dev/null @@ -1,29 +0,0 @@ -import logging -import os -import sys -from pathlib import Path -from typing import Optional - -from tribler.gui.tribler_app import TriblerApplication -from tribler.gui.tribler_window import TriblerWindow - -logger = logging.getLogger(__name__) - - -def run_gui(api_port: Optional[int], api_key: Optional[str], root_state_dir: Path): - # Workaround for macOS Big Sur and later, see https://github.com/Tribler/tribler/issues/5728 - if sys.platform == "darwin": - logger.info('Enabling a workaround for macOS Big Sur') - os.environ["QT_MAC_WANTS_LAYER"] = "1" - # Workaround for Ubuntu 21.04+, see https://github.com/Tribler/tribler/issues/6701 - elif sys.platform == "linux": - logger.info('Enabling a workaround for Ubuntu 21.04+ wayland environment') - os.environ["GDK_BACKEND"] = "x11" - - app = TriblerApplication() - logger.info('Start Tribler Window') - window = TriblerWindow(app.manager, root_state_dir, api_port, api_key=api_key) - window.setWindowTitle("Tribler") - app.tribler_window = window - app.parse_sys_args(sys.argv) - app.exec_() diff --git a/src/tribler/gui/tribler_action_menu.py b/src/tribler/gui/tribler_action_menu.py deleted file mode 100644 index ba198acc8d..0000000000 --- a/src/tribler/gui/tribler_action_menu.py +++ /dev/null @@ -1,36 +0,0 @@ -from PyQt5.QtWidgets import QMenu - -from tribler.gui.defs import CONTEXT_MENU_WIDTH - - -class TriblerActionMenu(QMenu): - """ - This menu is displayed when a user right-clicks some items in Tribler, i.e. a download widget. - Overrides QMenu to provide some custom CSS rules. - """ - - def __init__(self, parent): - QMenu.__init__(self, parent) - - self.setStyleSheet( - """ - QMenu { - background-color: #404040; - } - - QMenu::item { - color: #D0D0D0; - padding: 5px; - } - - QMenu::item:selected { - background-color: #707070; - } - - QMenu::item:disabled { - color: #999999; - } - """ - ) - - self.setMinimumWidth(CONTEXT_MENU_WIDTH) diff --git a/src/tribler/gui/tribler_app.py b/src/tribler/gui/tribler_app.py deleted file mode 100644 index bc665aadd6..0000000000 --- a/src/tribler/gui/tribler_app.py +++ /dev/null @@ -1,95 +0,0 @@ -import logging -import os -import os.path -import sys -from pathlib import Path -from typing import List - -from PyQt5.QtCore import QCoreApplication, QEvent, Qt - -from tribler.gui.app_manager import AppManager -from tribler.gui.code_executor import CodeExecutor -from tribler.gui.single_application import QtSingleApplication -from tribler.gui.utilities import connect - -# Set the QT application parameters before creating any instances of the application. -QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps) -QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling) -os.environ["QT_AUTO_SCREEN_SCALE_FACTOR"] = "1" - - -# fmt: off - -class TriblerApplication(QtSingleApplication): - """ - This class represents the main Tribler application. - """ - - def __init__(self): - QtSingleApplication.__init__(self, "triblerapp") - self._logger = logging.getLogger(self.__class__.__name__) - self.code_executor = None - connect(self.message_received, self.on_app_message) - self.manager = AppManager(self) - - def on_app_message(self, msg): - if msg.startswith('file') or msg.startswith('magnet'): - self.handle_uri(msg) - - def handle_uri(self, uri): - if self.tribler_window: - self.tribler_window.handle_uri(uri) - - def parse_sys_args(self, args): - for arg in args[1:]: - if os.path.exists(arg): - file_path = arg.decode() - uri = Path(file_path).as_uri() - self.handle_uri(uri) - elif arg.startswith('magnet'): - self.handle_uri(arg) - - if '--allow-code-injection' in sys.argv[1:]: - variables = globals().copy() - variables.update(locals()) - variables['window'] = self.tribler_window - self.code_executor = CodeExecutor(5500, shell_variables=variables) - connect(self.tribler_window.events_manager.core_connected, self.code_executor.on_core_connected) - connect(self.tribler_window.tribler_crashed, self.code_executor.on_crash) - - if '--testnet' in sys.argv[1:]: - os.environ['TESTNET'] = "YES" - if '--chant-testnet' in sys.argv[1:]: - os.environ['CHANT_TESTNET'] = "YES" - if '--tunnel-testnet' in sys.argv[1:]: - os.environ['TUNNEL_TESTNET'] = "YES" - - @staticmethod - def get_urls_from_sys_args() -> List[str]: - urls = [] - for arg in sys.argv[1:]: - if os.path.exists(arg) and arg.endswith(".torrent"): - urls.append(Path(arg).as_uri()) - elif arg.startswith('magnet'): - urls.append(arg) - return urls - - def send_torrent_file_path_to_primary_process(self): - urls_to_send = self.get_urls_from_sys_args() - if not urls_to_send: - return - - if not self.connected_to_previous_instance: - self._logger.warning("Can't send torrent url: do not have a connection to a primary process") - return - - count = len(urls_to_send) - self._logger.info(f'Sending {count} torrent file{"s" if count > 1 else ""} to a primary process') - for url in urls_to_send: - self.send_message(url) - - def event(self, event): - if event.type() == QEvent.FileOpen and event.file().endswith(".torrent"): - uri = Path(event.file()).as_uri() - self.handle_uri(uri) - return QtSingleApplication.event(self, event) diff --git a/src/tribler/gui/tribler_window.py b/src/tribler/gui/tribler_window.py deleted file mode 100644 index e088f3a789..0000000000 --- a/src/tribler/gui/tribler_window.py +++ /dev/null @@ -1,925 +0,0 @@ -import itertools -import logging -import os -import re -import signal -import time -from binascii import hexlify -from pathlib import Path -from typing import Optional - -from PyQt5 import QtCore, uic -from PyQt5.QtCore import (QCoreApplication, QDir, QObject, QRect, QSize, QStringListModel, QTimer, QUrl, Qt, pyqtSignal, - pyqtSlot, QSettings,) -from PyQt5.QtGui import QDesktopServices, QFontDatabase, QIcon, QKeyEvent, QKeySequence, QPixmap -from PyQt5.QtWidgets import (QAction, QApplication, QCompleter, QFileDialog, QLineEdit, QListWidget, QMainWindow, - QShortcut, QStyledItemDelegate, QSystemTrayIcon, QTreeWidget) - -from tribler.core.libtorrent.uris import url_to_path -from tribler.gui.app_manager import AppManager -from tribler.gui.core_manager import CoreManager -from tribler.gui.debug_window import DebugWindow -from tribler.gui.defs import (BUTTON_TYPE_CONFIRM, BUTTON_TYPE_NORMAL, CATEGORY_SELECTOR_FOR_POPULAR_ITEMS, DARWIN, - PAGE_DOWNLOADS, PAGE_LOADING, PAGE_POPULAR, PAGE_SEARCH_RESULTS, PAGE_SETTINGS, - SHUTDOWN_WAITING_PERIOD) -from tribler.gui.dialogs.confirmationdialog import ConfirmationDialog -from tribler.gui.dialogs.createtorrentdialog import CreateTorrentDialog -from tribler.gui.dialogs.startdownloaddialog import StartDownloadDialog -from tribler.gui.error_handler import ErrorHandler -from tribler.gui.event_request_manager import EventRequestManager -from tribler.gui.exceptions import TriblerGuiTestException -from tribler.gui.network.request_manager import RequestManager, request_manager -from tribler.core.database.queries import Query -from tribler.gui.tribler_action_menu import TriblerActionMenu -from tribler.gui.utilities import (connect, create_api_key, format_api_key, get_font_path, get_gui_setting, - get_image_path, get_ui_file_path, is_dir_writable, set_api_key, get_clipboard_text) -from tribler.gui.widgets.instanttooltipstyle import InstantTooltipStyle -from tribler.gui.widgets.popular.popular_torrents_model import PopularTorrentsModel -from tribler.gui.widgets.triblertablecontrollers import PopularContentTableViewController - - -fc_loading_list_item, _ = uic.loadUiType(get_ui_file_path('loading_list_item.ui')) - -CHECKBOX_STYLESHEET = """ - QCheckBox::indicator { width: 16px; height: 16px;} - QCheckBox::indicator:checked { image: url("%s"); } - QCheckBox::indicator:unchecked { image: url("%s"); } - QCheckBox::indicator:checked::disabled { image: url("%s"); } - QCheckBox::indicator:unchecked::disabled { image: url("%s"); } - QCheckBox::indicator:indeterminate { image: url("%s"); } -""" % ( - get_image_path('toggle-checked.svg', convert_slashes_to_forward=True), - get_image_path('toggle-unchecked.svg', convert_slashes_to_forward=True), - get_image_path('toggle-checked-disabled.svg', convert_slashes_to_forward=True), - get_image_path('toggle-unchecked-disabled.svg', convert_slashes_to_forward=True), - get_image_path('toggle-undefined.svg', convert_slashes_to_forward=True), -) - -tags_re = re.compile(r"#[^\s^#]{3,50}(?=[#\s]|$)") - - -def extract_tags(text: str) -> tuple[set[str], str]: - if not text: - return set(), "" - - tags = set() - positions = [0] - - for m in tags_re.finditer(text): - tag = m.group(0)[1:] - tags.add(tag.lower()) - positions.extend(itertools.chain.from_iterable(m.regs)) - positions.append(len(text)) - - remaining_text = ''.join(text[positions[i]: positions[i + 1]] for i in range(0, len(positions) - 1, 2)) - return tags, remaining_text - - -class MagnetHandler(QObject): - def __init__(self, window): - QObject.__init__(self) - self.window = window - - @pyqtSlot(QUrl) - def on_open_magnet_link(self, url): - self.window.start_download_from_uri(url) - - -class TriblerWindow(QMainWindow): - resize_event = pyqtSignal() - escape_pressed = pyqtSignal() - tribler_crashed = pyqtSignal(str) - received_search_completions = pyqtSignal(object) - - def __init__(self, app_manager: AppManager, root_state_dir: Path, api_port: int, api_key: Optional[str] = None): - QMainWindow.__init__(self) - self._logger = logging.getLogger(self.__class__.__name__) - self.app_manager = app_manager - - QCoreApplication.setOrganizationDomain("nl") - QCoreApplication.setOrganizationName("TUDelft") - QCoreApplication.setApplicationName("Tribler") - - self.setWindowIcon(QIcon(QPixmap(get_image_path('tribler.png')))) - - self.root_state_dir = root_state_dir - self.gui_settings = QSettings('nl.tudelft.tribler') - - api_key = format_api_key(api_key or get_gui_setting(self.gui_settings, "api_key", None) or create_api_key()) - set_api_key(self.gui_settings, api_key) - - request_manager.set_api_key(api_key) - request_manager.set_api_port(api_port) - - self.core_connected = False - self.ui_started = False - self.tribler_settings = None - self.debug_window = None - - self.error_handler = ErrorHandler(self) - self.events_manager = EventRequestManager(api_port, api_key, self.error_handler, root_state_dir) - self.core_manager = CoreManager(self.root_state_dir, api_port, api_key, app_manager, self.events_manager) - self.pending_requests = {} - self.pending_uri_requests = [] - self.dialog = None - self.create_dialog = None - self.chosen_dir = None - self.new_version_dialog_postponed = False - self.start_download_dialog_active = False - self.selected_torrent_files = [] - self.start_time = time.time() - self.shutdown_timer = None - self.add_torrent_url_dialog_active = False - - if "Noto Color Emoji" not in QFontDatabase().families(): - emoji_ttf_path = get_font_path("NotoColorEmoji.ttf") - if os.path.exists(emoji_ttf_path): - result = QFontDatabase.addApplicationFont(emoji_ttf_path) - if result == -1: - self.logger.warning("Failed to load font %s!", emoji_ttf_path) - - uic.loadUi(get_ui_file_path('mainwindow.ui'), self) - RequestManager.window = self - self.tribler_status_bar.hide() - - self.magnet_handler = MagnetHandler(self.window) - QDesktopServices.setUrlHandler("magnet", self.magnet_handler, "on_open_magnet_link") - - self.paste = QShortcut(QKeySequence("Ctrl+v"), self) - connect(self.paste.activated, self.on_paste_event) - - self.debug_pane_shortcut = QShortcut(QKeySequence("Ctrl+d"), self) - connect(self.debug_pane_shortcut.activated, self.clicked_debug_panel_button) - - self.import_torrent_shortcut = QShortcut(QKeySequence("Ctrl+o"), self) - connect(self.import_torrent_shortcut.activated, self.on_add_torrent_browse_file) - - self.add_torrent_url_shortcut = QShortcut(QKeySequence("Ctrl+i"), self) - connect(self.add_torrent_url_shortcut.activated, self.on_add_torrent_from_url) - - self.tribler_gui_test_exception_shortcut = QShortcut(QKeySequence("Ctrl+Alt+Shift+G"), self) - connect(self.tribler_gui_test_exception_shortcut.activated, self.on_test_tribler_gui_exception) - - self.tribler_core_test_exception_shortcut = QShortcut(QKeySequence("Ctrl+Alt+Shift+C"), self) - connect(self.tribler_core_test_exception_shortcut.activated, self.on_test_tribler_core_exception) - - connect(self.top_search_bar.returnPressed, self.on_top_search_bar_return_pressed) - - # Remove the focus rect on OS X - for widget in self.findChildren(QLineEdit) + self.findChildren(QListWidget) + self.findChildren(QTreeWidget): - widget.setAttribute(Qt.WA_MacShowFocusRect, 0) - - self.menu_buttons = [ - self.left_menu_button_downloads, - self.left_menu_button_popular, - ] - - self.search_results_page.initialize(hide_xxx=self.hide_xxx) - connect( - self.core_manager.events_manager.received_remote_query_results, self.search_results_page.update_loading_page - ) - self.settings_page.initialize_settings_page() - self.downloads_page.initialize_downloads_page() - self.loading_page.initialize_loading_page() - - self.popular_page.initialize_content_page( - hide_xxx=self.hide_xxx, - controller_class=PopularContentTableViewController, - categories=CATEGORY_SELECTOR_FOR_POPULAR_ITEMS, - ) - - self.stackedWidget.setCurrentIndex(PAGE_LOADING) - - # Create the system tray icon - self.tray_icon = None - # System tray doesn't make sense on Mac - if QSystemTrayIcon.isSystemTrayAvailable(): - self.tray_icon = QSystemTrayIcon() - if not DARWIN: - connect(self.tray_icon.activated, self.on_system_tray_icon_activated) - use_monochrome_icon = get_gui_setting(self.gui_settings, "use_monochrome_icon", False, is_bool=True) - self.update_tray_icon(use_monochrome_icon) - - # Create the tray icon menu - menu = TriblerActionMenu(self) - menu.addAction('Show Tribler window', self.raise_window) - menu.addSeparator() - self.create_add_torrent_menu(menu) - menu.addSeparator() - menu.addAction('Show downloads', self.clicked_menu_button_downloads) - menu.addSeparator() - menu.addAction('Quit Tribler', self.close_tribler) - self.tray_icon.setContextMenu(menu) - - self.debug_panel_button.setHidden(True) - self.top_menu_button.setHidden(True) - self.left_menu.setHidden(True) - self.settings_button.setHidden(True) - self.add_torrent_button.setHidden(True) - self.top_search_bar.setHidden(True) - - # Set various icons - self.top_menu_button.setIcon(QIcon(get_image_path('menu.png'))) - - self.search_completion_model = QStringListModel() - completer = QCompleter() - completer.setModel(self.search_completion_model) - completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) - self.item_delegate = QStyledItemDelegate() - completer.popup().setItemDelegate(self.item_delegate) - completer.popup().setStyleSheet( - """ - QListView { - background-color: #404040; - } - - QListView::item { - color: #D0D0D0; - padding-top: 5px; - padding-bottom: 5px; - } - - QListView::item:hover { - background-color: #707070; - } - """ - ) - self.top_search_bar.setCompleter(completer) - - connect(self.core_manager.events_manager.torrent_finished, self.on_torrent_finished) - connect(self.core_manager.events_manager.new_version_available, self.on_new_version_available) - connect(self.core_manager.events_manager.core_connected, self.on_core_connected) - connect(self.core_manager.events_manager.low_storage_signal, self.on_low_storage) - connect(self.core_manager.events_manager.tribler_shutdown_signal, self.on_tribler_shutdown_state_update) - connect(self.core_manager.events_manager.config_error_signal, self.on_config_error_signal) - - # Install signal handler for ctrl+c events - def sigint_handler(*_): - self.close_tribler() - - signal.signal(signal.SIGINT, sigint_handler) - - # Resize and move the window according to the settings - self.restore_window_geometry() - - self.show() - - self.add_torrent_menu = self.create_add_torrent_menu() - self.add_torrent_button.setMenu(self.add_torrent_menu) - # The line below adds a space between an icon and text. - # Unfortunately, I was unable to achieve this through CSS. - self.add_torrent_button.setText(" " + self.add_torrent_button.text()) - - connect(self.debug_panel_button.clicked, self.clicked_debug_panel_button) - - # Apply a custom style to our checkboxes, with custom images. - stylesheet = self.styleSheet() - stylesheet += CHECKBOX_STYLESHEET - self.setStyleSheet(stylesheet) - - self.core_manager.start( - core_args=[], - core_env={}, - ) - - def on_test_tribler_gui_exception(self, *_): - raise TriblerGuiTestException("Tribler GUI Test Exception") - - def on_test_tribler_core_exception(self, *_): - request_manager.post("/debug/core_test_exception") - - def restore_window_geometry(self): - screen_geometry: QRect = QApplication.desktop().availableGeometry() - size: QSize = self.gui_settings.value("size", self.size()) - - def restore_size(): - self._logger.info(f'Available screen geometry: {screen_geometry}') - self._logger.info(f'Restored window size: {size}') - - bounded_size = QSize( - min(size.width(), screen_geometry.width()), - min(size.height(), screen_geometry.height()) - ) - self._logger.info(f'Resize window to the bounded size: {bounded_size}') - self.resize(bounded_size) - - def restore_position(): - pos = self.gui_settings.value("pos", self.pos()) - self._logger.info(f'Restored window position: {pos}') - - window_geometry = QRect(pos, size) - union: QRect = screen_geometry | window_geometry - window_outside_the_screen = union.width() > screen_geometry.width() or \ - union.height() > screen_geometry.height() - self._logger.info(f'Is window outside the screen: {window_outside_the_screen}') - - actual_position = pos if not window_outside_the_screen else screen_geometry.topLeft() - self._logger.info(f'Move the window to the: {actual_position}') - self.move(actual_position) - - restore_size() - restore_position() - - def update_tray_icon(self, use_monochrome_icon): - if not QSystemTrayIcon.isSystemTrayAvailable() or not self.tray_icon: - return - - if use_monochrome_icon: - self.tray_icon.setIcon(QIcon(QPixmap(get_image_path('monochrome_tribler.png')))) - else: - self.tray_icon.setIcon(QIcon(QPixmap(get_image_path('tribler.png')))) - self.tray_icon.show() - - def delete_tray_icon(self): - if self.tray_icon: - try: - self.tray_icon.deleteLater() - except RuntimeError: - # The tray icon might have already been removed when unloading Qt. - # This is due to the C code actually being asynchronous. - logging.debug("Tray icon already removed, no further deletion necessary.") - self.tray_icon = None - - def on_low_storage(self, disk_usage_data): - """ - Dealing with low storage space available. First stop the downloads and the core manager and ask user to user to - make free space. - :return: - """ - - def close_tribler_gui(): - self.close_tribler() - # Since the core has already stopped at this point, it will not terminate the GUI. - # So, we quit the GUI separately here. - self.app_manager.quit_application() - - self.downloads_page.stop_refreshing_downloads() - self.core_manager.stop(quit_app_on_core_finished=False) - close_dialog = ConfirmationDialog( - self.window(), - "CRITICAL ERROR", - "You are running low on disk space (<100MB). Please make sure to have " - "sufficient free space available and restart Tribler again.", - [("Close Tribler", BUTTON_TYPE_NORMAL)], - ) - connect(close_dialog.button_clicked, lambda _: close_tribler_gui()) - close_dialog.show() - - def on_torrent_finished(self, torrent_info): - if "hidden" not in torrent_info or not torrent_info["hidden"]: - self.tray_show_message("Download finished", "Download of %s has finished." % {torrent_info['name']}) - - def on_paste_event(self): - txt = get_clipboard_text() - self._logger.info("Paste event: %s", txt) - if txt.startswith("magnet:?xt=urn:btih:"): - self.start_download_from_uri(txt) - - def show_loading_screen(self): - self.top_menu_button.setHidden(True) - self.left_menu.setHidden(True) - self.debug_panel_button.setHidden(True) - self.settings_button.setHidden(True) - self.add_torrent_button.setHidden(True) - self.top_search_bar.setHidden(True) - self.stackedWidget.setCurrentIndex(PAGE_LOADING) - - def tray_set_tooltip(self, message): - """ - Set a tooltip message for the tray icon, if possible. - - :param message: the message to display on hover - """ - if self.tray_icon: - try: - self.tray_icon.setToolTip(message) - except RuntimeError as e: - logging.error("Failed to set tray tooltip: %s", str(e)) - - def tray_show_message(self, title, message): - """ - Show a message at the tray icon, if possible. - - :param title: the title of the message - :param message: the message to display - """ - if self.tray_icon: - try: - self.tray_icon.showMessage(title, message) - except RuntimeError as e: - logging.error("Failed to set tray message: %s", str(e)) - - def on_core_connected(self, version): - if self.core_connected: - self._logger.warning("Received duplicate Tribler Core connected event") - - self._logger.info("Core connected") - self.core_connected = True - self.tribler_version = version - - request_manager.get("settings", self.on_receive_settings, capture_errors=False) - - def on_receive_settings(self, settings): - self.tribler_settings = settings['settings'] - self.start_ui() - - def start_ui(self): - if self.ui_started: - self._logger.info("UI already started") - return - - self.top_menu_button.setHidden(False) - self.left_menu.setHidden(False) - self.settings_button.setHidden(False) - self.add_torrent_button.setHidden(False) - self.top_search_bar.setHidden(False) - self.process_uri_request() - self.downloads_page.start_loading_downloads() - - self.setAcceptDrops(True) - self.setWindowTitle(f"Tribler {self.tribler_version}") - - self.popular_page.initialize_root_model( - PopularTorrentsModel(channel_info={"name": "Popular torrents"}, hide_xxx=self.hide_xxx) - ) - self.popular_page.explanation_tooltip_button.setHidden(False) - - self.clicked_menu_button_downloads() - - # Toggle debug if developer mode is enabled - self.window().debug_panel_button.setHidden(not get_gui_setting(self.gui_settings, "debug", False, is_bool=True)) - - QApplication.setStyle(InstantTooltipStyle(QApplication.style())) - - self.ui_started = True - - @property - def hide_xxx(self): - return get_gui_setting(self.gui_settings, "family_filter", True, is_bool=True) - - def on_events_started(self, json_dict): - self.setWindowTitle(f"Tribler {json_dict['version']}") - - def show_status_bar(self, message): - self.tribler_status_bar_label.setText(message) - self.tribler_status_bar.show() - - def hide_status_bar(self): - self.tribler_status_bar.hide() - - def process_uri_request(self): - """ - Process a URI request if we have one in the queue. - """ - if len(self.pending_uri_requests) == 0: - return - - uri = self.pending_uri_requests.pop() - - if uri.startswith('file') or uri.startswith('magnet'): - self.start_download_from_uri(uri) - - def update_recent_download_locations(self, destination): - # Save the download location to the GUI settings - current_settings = get_gui_setting(self.gui_settings, "recent_download_locations", "") - recent_locations = current_settings.split(",") if len(current_settings) > 0 else [] - if isinstance(destination, str): - destination = destination.encode() - encoded_destination = hexlify(destination).decode() - if encoded_destination in recent_locations: - recent_locations.remove(encoded_destination) - recent_locations.insert(0, encoded_destination) - - if len(recent_locations) > 5: - recent_locations = recent_locations[:5] - - self.gui_settings.setValue("recent_download_locations", ','.join(recent_locations)) - - def perform_start_download_request( - self, - uri, - anon_download, - safe_seeding, - destination, - selected_files, - callback=None, - ): - # Check if destination directory is writable - is_writable, error = is_dir_writable(destination) - if not is_writable: - gui_error_message = ( - "Insufficient write permissions to %s directory. Please add proper " - "write permissions on the directory and add the torrent again. %s" - ) % (destination, error) - ConfirmationDialog.show_message( - self.window(), "Download error %s" % uri, gui_error_message, "OK" - ) - return - - anon_hops = int(self.tribler_settings['libtorrent']['download_defaults']['number_hops']) if anon_download else 0 - safe_seeding = 1 if safe_seeding else 0 - request_manager.put("downloads", - on_success=callback if callback else self.on_download_added, - data={ - "uri": uri, - "anon_hops": anon_hops, - "safe_seeding": safe_seeding, - "destination": destination, - "selected_files": selected_files, - }) - - self.update_recent_download_locations(destination) - - def on_new_version_available(self, version): - self.upgrade_manager.on_new_version_available(tribler_window=self, new_version=version) - - def on_search_text_change(self, text): - # We do not want to bother the database on petty 1-character queries - if len(text) < 2: - return - request_manager.get("metadata/search/completions", self.on_received_search_completions, url_params={'q': text}) - - def on_received_search_completions(self, completions): - if completions is None: - return - - self.received_search_completions.emit(completions) - - completions_list = completions.get('completions') - if completions_list: - self.search_completion_model.setStringList(completions_list) - - def on_settings_button_click(self): - self.deselect_all_menu_buttons() - self.stackedWidget.setCurrentIndex(PAGE_SETTINGS) - self.settings_page.load_settings() - - def on_system_tray_icon_activated(self, reason): - if reason != QSystemTrayIcon.DoubleClick: - return - - if self.isMinimized(): - self.raise_window() - else: - self.setWindowState(self.windowState() | Qt.WindowMinimized) - - def raise_window(self): - self.setWindowState(self.windowState() & ~Qt.WindowMinimized | Qt.WindowActive) - self.show() - self.raise_() - self.activateWindow() - - def create_add_torrent_menu(self, menu=None): - """ - Create a menu to add new torrents. Shows when users click on the tray icon or the big plus button. - """ - menu = menu if menu is not None else TriblerActionMenu(self) - - browse_files_action = QAction("Import torrent from file", self) - browse_directory_action = QAction("Import torrent(s) from directory", self) - add_url_action = QAction("Import torrent from magnet/URL", self) - create_torrent_action = QAction("Create torrent from file(s)", self) - - connect(browse_files_action.triggered, self.on_add_torrent_browse_file) - connect(browse_directory_action.triggered, self.on_add_torrent_browse_dir) - connect(add_url_action.triggered, self.on_add_torrent_from_url) - connect(create_torrent_action.triggered, self.on_create_torrent) - - menu.addAction(browse_files_action) - menu.addAction(browse_directory_action) - menu.addAction(add_url_action) - menu.addSeparator() - menu.addAction(create_torrent_action) - - return menu - - def on_create_torrent(self, checked): - self.raise_window() # For the case when the action is triggered by tray icon - if self.create_dialog: - self.create_dialog.close_dialog() - - self.create_dialog = CreateTorrentDialog(self) - connect(self.create_dialog.create_torrent_notification, self.on_create_torrent_updates) - self.create_dialog.show() - - def on_create_torrent_updates(self, update_dict): - self.tray_show_message("Torrent updates", update_dict['msg']) - - def on_add_torrent_browse_file(self, *_): - self.raise_window() # For the case when the action is triggered by tray icon - filenames, *_ = QFileDialog.getOpenFileNames( - self, "Please select the .torrent file", QDir.homePath(), "Torrent files%s" % " (*.torrent)" - ) - if not filenames: - return - - for filename in filenames: - uri = Path(filename).resolve().as_uri() - self.pending_uri_requests.append(uri) - self.process_uri_request() - - def start_download_from_uri(self, uri): - uri = uri.decode() if isinstance(uri, bytes) else uri - - ask_download_settings = get_gui_setting(self.gui_settings, "ask_download_settings", True, is_bool=True) - if ask_download_settings: - # Clear any previous dialog if exists - if self.dialog: - self.dialog.close_dialog() - self.dialog = None - - self.dialog = StartDownloadDialog(self, uri) - connect(self.dialog.button_clicked, self.on_start_download_action) - self.dialog.show() - self.start_download_dialog_active = True - else: - self.window().perform_start_download_request( - uri, - self.window().tribler_settings["libtorrent"]['download_defaults']['anonymity_enabled'], - self.window().tribler_settings["libtorrent"]['download_defaults']['safeseeding_enabled'], - self.tribler_settings["libtorrent"]['download_defaults']['saveas'], - [], - ) - self.process_uri_request() - - def on_start_download_action(self, action): - if action == 1: - if self.dialog and self.dialog.dialog_widget: - self.window().perform_start_download_request( - self.dialog.download_uri, - self.dialog.dialog_widget.anon_download_checkbox.isChecked(), - self.dialog.dialog_widget.safe_seed_checkbox.isChecked(), - self.dialog.dialog_widget.destination_input.currentText(), - self.dialog.dialog_widget.files_list_view.get_selected_files_indexes() - ) - else: - ConfirmationDialog.show_error( - self, "Tribler UI Error", "Something went wrong. Please try again." - ) - logging.exception("Error while trying to download. Either dialog or dialog.dialog_widget is None") - - if self.dialog: - self.dialog.close_dialog() - self.dialog = None - self.start_download_dialog_active = False - - if action == 0: # We do this after removing the dialog since process_uri_request is blocking - self.process_uri_request() - - def on_add_torrent_browse_dir(self, checked): - self.raise_window() # For the case when the action is triggered by tray icon - chosen_dir = QFileDialog.getExistingDirectory( - self, - "Please select the directory containing the .torrent files", - QDir.homePath(), - QFileDialog.ShowDirsOnly, - ) - self.chosen_dir = chosen_dir - if len(chosen_dir) != 0: - self.selected_torrent_files = list(Path(chosen_dir).glob("*.torrent")) - self.dialog = ConfirmationDialog( - self, - "Add torrents from directory", - "Add %s torrent files from the following directory to your Tribler channel: \n\n%s" - % (len(self.selected_torrent_files), chosen_dir), - [("ADD", BUTTON_TYPE_NORMAL), ("CANCEL", BUTTON_TYPE_CONFIRM)], - checkbox_text="Add torrents to My Channel", - ) - connect(self.dialog.button_clicked, self.on_confirm_add_directory_dialog) - self.dialog.show() - - def on_confirm_add_directory_dialog(self, action): - if action == 0: - for torrent_file in self.selected_torrent_files: - self.perform_start_download_request( - torrent_file.as_uri(), - self.window().tribler_settings['download_defaults']['anonymity_enabled'], - self.window().tribler_settings['download_defaults']['safeseeding_enabled'], - self.tribler_settings['download_defaults']['saveas'], - [], - ) - - if self.dialog: - self.dialog.close_dialog() - self.dialog = None - - def on_add_torrent_from_url(self, checked=False): - def on_close_event(): - self.add_torrent_url_dialog_active = False - - # Make sure that the window is visible (this action might be triggered from the tray icon) - self.raise_window() - - if not self.add_torrent_url_dialog_active: - self.dialog = ConfirmationDialog( - self, - "Add torrent from URL/magnet link", - "Please enter the URL/magnet link in the field below:", - [("ADD", BUTTON_TYPE_NORMAL), ("CANCEL", BUTTON_TYPE_CONFIRM)], - show_input=True, - ) - self.dialog.dialog_widget.dialog_input.setPlaceholderText("URL/magnet link") - self.dialog.dialog_widget.dialog_input.setFocus() - connect(self.dialog.button_clicked, self.on_torrent_from_url_dialog_done) - connect(self.dialog.close_event, on_close_event) - self.dialog.show() - self.add_torrent_url_dialog_active = True - - def on_torrent_from_url_dialog_done(self, action): - if self.dialog and self.dialog.dialog_widget: - uri = self.dialog.dialog_widget.dialog_input.text().strip() - - # If the URI is a 40-bytes hex-encoded infohash, convert it to a valid magnet link - if len(uri) == 40: - valid_ih_hex = True - try: - int(uri, 16) - except ValueError: - valid_ih_hex = False - - if valid_ih_hex: - uri = "magnet:?xt=urn:btih:" + uri - - # Remove first dialog - self.dialog.close_dialog() - self.dialog = None - - if action == 0: - self.start_download_from_uri(uri) - - def on_download_added(self, result): - if not result: - return - if len(self.pending_uri_requests) == 0: # Otherwise, we first process the remaining requests. - self.window().left_menu_button_downloads.click() - else: - self.process_uri_request() - - def on_top_menu_button_click(self): - if self.left_menu.isHidden(): - self.left_menu.show() - else: - self.left_menu.hide() - - def deselect_all_menu_buttons(self, except_select=None): - for button in self.menu_buttons: - if button == except_select: - button.setEnabled(False) - continue - button.setEnabled(True) - button.setChecked(False) - - def on_top_search_bar_return_pressed(self): - query_text = self.top_search_bar.text() - if not query_text: - return - - query = Query(query_text, *extract_tags(query_text)) - if self.search_results_page.search(query): - self._logger.info(f'Do search for query: {query}') - self.deselect_all_menu_buttons() - self.stackedWidget.setCurrentIndex(PAGE_SEARCH_RESULTS) - - def clicked_menu_button_popular(self): - self.deselect_all_menu_buttons() - self.left_menu_button_popular.setChecked(True) - self.popular_page.go_back_to_level(0) - self.popular_page.reset_view() - self.stackedWidget.setCurrentIndex(PAGE_POPULAR) - self.popular_page.content_table.setFocus() - - params = {'popular': True, 'metadata_type': [300], 'fts_text': '', 'original_query': ''} - request_manager.put('search/remote', lambda x: None, url_params=params) - - def clicked_menu_button_downloads(self): - self.deselect_all_menu_buttons(self.left_menu_button_downloads) - self.raise_window() - self.left_menu_button_downloads.setChecked(True) - self.stackedWidget.setCurrentIndex(PAGE_DOWNLOADS) - - def clicked_debug_panel_button(self, *_): - if not self.gui_settings: - self._logger.info("Tribler settings (Core and/or GUI) is not available yet.") - return - if not self.debug_window: - self.debug_window = DebugWindow(self.tribler_settings, self.gui_settings, self.tribler_version) - self.debug_window.show() - - def resizeEvent(self, _): - # This thing here is necessary to send the resize event to dialogs, etc. - self.resize_event.emit() - - def close_tribler(self, checked=False): - if self.core_manager.shutting_down: - return - - def show_force_shutdown(): - self.window().force_shutdown_btn.show() - - self.raise_window() - self.delete_tray_icon() - self.show_loading_screen() - self.hide_status_bar() - self.loading_text_label.setText("Shutting down...") - if self.debug_window: - self.debug_window.setHidden(True) - - self.shutdown_timer = QTimer() - connect(self.shutdown_timer.timeout, show_force_shutdown) - self.shutdown_timer.start(SHUTDOWN_WAITING_PERIOD) - - self.gui_settings.setValue("pos", self.pos()) - self.gui_settings.setValue("size", self.size()) - - if self.core_manager.use_existing_core: - self._logger.info("Quitting Tribler GUI without stopping Tribler Core") - # Don't close the core that we are using - self.app_manager.quit_application() - - self.core_manager.stop() - self.downloads_page.stop_refreshing_downloads() - request_manager.clear() - - def closeEvent(self, close_event): - self.close_tribler() - close_event.ignore() - - def event(self, event): - # Minimize to tray - if ( - not DARWIN - and event.type() == QtCore.QEvent.WindowStateChange - and self.window().isMinimized() - and get_gui_setting(self.gui_settings, "minimize_to_tray", False, is_bool=True) - ): - self.window().hide() - return True - return super().event(event) - - @classmethod - def get_urls_from_dragndrop_list(cls, e): - return [url.toString() for url in e.mimeData().urls()] if e.mimeData().hasUrls() else [] - - def dragEnterEvent(self, e): - file_urls = self.get_urls_from_dragndrop_list(e) - if any(Path(url_to_path(fu)).is_valid() for fu in file_urls): - e.accept() - else: - e.ignore() - - def dropEvent(self, e): - file_urls = self.get_urls_from_dragndrop_list(e) - - for fu in file_urls: - path = Path(url_to_path(fu)) - if path.is_file(): - self.start_download_from_uri(fu) - - e.accept() - - def clicked_force_shutdown(self): - self.core_manager.kill_core_process() - self.app_manager.quit_application() - - def clicked_skip_conversion(self): - self.dialog = ConfirmationDialog( - self, - "Abort the conversion of Channels database", - "The upgrade procedure is now converting your personal channel and channels " - "collected by the previous installation of Tribler.
" - "Are you sure you want to abort the conversion process?

" - "

!!! WARNING !!!
" - "You will lose your personal channel and subscribed channels if you ABORT now!


", - [("ABORT", BUTTON_TYPE_CONFIRM), ("CONTINUE", BUTTON_TYPE_NORMAL)], - ) - connect(self.dialog.button_clicked, self.on_skip_conversion_dialog) - self.dialog.show() - - def node_info_updated(self, node_info): - self.core_manager.events_manager.node_info_updated.emit(node_info) - - def on_skip_conversion_dialog(self, action): - if action == 0: - self.upgrade_manager.stop_upgrade() - - if self.dialog: - self.dialog.close_dialog() - self.dialog = None - - def on_tribler_shutdown_state_update(self, state): - self.loading_text_label.setText(state) - - def on_config_error_signal(self, stacktrace): - self._logger.error(f"Config error: {stacktrace}") - ConfirmationDialog.show_error(self, "Tribler config error", - ("Tribler recovered from a corrupted config. " - "Please check your settings and update if necessary.")) - - def keyPressEvent(self, event: QKeyEvent) -> None: - if event.key() == Qt.Key_Escape: - self.escape_pressed.emit() - - def handle_uri(self, uri): - self.pending_uri_requests.append(uri) - if self.ui_started and not self.start_download_dialog_active: - self.process_uri_request() diff --git a/src/tribler/gui/utilities.py b/src/tribler/gui/utilities.py deleted file mode 100644 index 8bc0c41243..0000000000 --- a/src/tribler/gui/utilities.py +++ /dev/null @@ -1,590 +0,0 @@ -from __future__ import annotations - -import hashlib -import inspect -import json -import logging -import math -import os -import sys -import time -import traceback -import types -from datetime import datetime, timedelta -from pathlib import Path -from typing import Callable, Dict, List, Optional -from urllib.parse import quote_plus -from uuid import uuid4 - -from PyQt5.QtCore import ( - QCoreApplication, - QLocale, - QPoint, - QSettings, - QTranslator, - pyqtSignal, -) -from PyQt5.QtGui import QPixmap, QRegion -from PyQt5.QtNetwork import QNetworkReply -from PyQt5.QtWidgets import QApplication, QMessageBox - -import tribler.gui -from tribler.core.database.layers.knowledge import ResourceType -from tribler.gui.defs import CORRUPTED_DB_WAS_FIXED_MESSAGE, HEALTH_DEAD, HEALTH_GOOD, HEALTH_MOOT, HEALTH_UNCHECKED - -INVALID_URLS = {"[DHT]", "[PeX]"} - -# fmt: off - -logger = logging.getLogger(__name__) - -NUM_VOTES_BARS = 8 -I18N_DIR = "i18n" -LANGUAGES_FILE = "languages.json" - - -class TranslatedString(str): - """ This class is used to wrap translated strings to be able to log untranslated strings in case of errors. - Thanks to this class no `KeyError` exceptions are raised when a translation is missing. - """ - - def __new__(cls, translation, original_string): # pylint: disable=unused-argument - return super().__new__(cls, translation) - - def __init__(self, translation: str, original_string: str): # pylint: disable=unused-argument - super().__init__() - self.original_string = original_string - - def __mod__(self, other): - try: - return str.__mod__(self, other) - except KeyError as e: - msg = f'No value provided for {e} in translation "{self}", original string: "{self.original_string}"' - logger.warning(f'{type(e).__name__}: {msg}') - return self.original_string % other - except TypeError as e: - msg = f'Wrong number of parameters in translation "{self}", original string: "{self.original_string}"' - logger.warning(f'{type(e).__name__}: {msg}') - return self.original_string % other - - -def tr(key): - translated_string = QCoreApplication.translate('@default', key) - return TranslatedString(translated_string, original_string=key) - - -VOTES_RATING_DESCRIPTIONS = ( - tr("Zero popularity"), - tr("Very low popularity"), - tr("3rd tier popularity"), - tr("2nd tier popularity"), - tr("1st tier popularity"), -) - - -def data_item2uri(data_item): - return f"magnet:?xt=urn:btih:{data_item['infohash']}&dn={data_item['name']}" - - -def index2uri(index): - return data_item2uri(index.model().data_items[index.row()]) - - -def format_size(num, suffix='B', precision=1) -> str: - for unit in ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z']: - if abs(num) < 1024.0: - return f"{num:3.{precision}f} {unit}{suffix}" - num /= 1024.0 - return f"{num:.1f} Yi{suffix}" - - -def format_speed(num): - return f"{format_size(num)}/s" - - -def seconds_to_hhmm_string(seconds): - hours = int(seconds) // 3600 - seconds -= hours * 3600 - return "%d:%02d" % (hours, seconds // 60) - - -def string_to_seconds(time_str): - parts = time_str.split(":") - if len(parts) != 2: - raise ValueError("Invalid time string") - - hours = float(parts[0]) - minutes = float(parts[1]) - return hours * 3600 + minutes * 60 - - -def pretty_date(time=False): - """ - Get a datetime object or a int() Epoch timestamp and return a - pretty string like 'an hour ago', 'Yesterday', '3 months ago', - 'just now', etc - """ - now = datetime.now() - if isinstance(time, int): - try: - diff = now - datetime.fromtimestamp(time) - except ValueError: # The time passed is out of range - return an empty string - return '' - elif isinstance(time, datetime): - diff = now - time - elif not time: - diff = timedelta(0) - second_diff = diff.seconds - day_diff = diff.days - - if day_diff < 0: - return '' - - if day_diff == 0: - if second_diff < 10: - return tr("just now") - if second_diff < 60: - return str(second_diff) + tr(" seconds ago") - if second_diff < 120: - return tr("a minute ago") - if second_diff < 3600: - return str(second_diff // 60) + tr(" minutes ago") - if second_diff < 7200: - return tr("an hour ago") - if second_diff < 86400: - return str(second_diff // 3600) + tr(" hours ago") - if day_diff == 1: - return tr("yesterday") - if day_diff < 7: - return str(day_diff) + tr(" days ago") - if day_diff < 31: - weeks = day_diff // 7 - word = tr("week") if weeks == 1 else tr("weeks") - return str(weeks) + " " + word + tr(" ago") - if day_diff < 365: - months = day_diff // 30 - word = tr("month") if months == 1 else tr("months") - return str(months) + " " + word + tr(" ago") - - years = day_diff // 365 - word = tr("year") if years == 1 else tr("years") - return str(years) + " " + word + tr(" ago") - - -def duration_to_string(seconds): - years = int(seconds // (60 * 60 * 24 * 365.249)) - seconds -= years * (60 * 60 * 24 * 365.249) - weeks = int(seconds // (60 * 60 * 24 * 7)) - seconds -= weeks * (60 * 60 * 24 * 7) - days = int(seconds // (60 * 60 * 24)) - seconds -= days * (60 * 60 * 24) - hours = int(seconds // (60 * 60)) - seconds -= hours * (60 * 60) - minutes = int(seconds // 60) - seconds -= minutes * 60 - seconds = int(seconds) - - data = {'years': years, 'weeks': weeks, 'days': days, 'hours': hours, 'minutes': minutes, 'seconds': seconds} - - if years >= 100: - return tr("Forever") - if years > 0: - return tr("%(years)iy %(weeks)iw") % data - if weeks > 0: - return tr("%(weeks)iw %(days)id") % data - if days > 0: - return tr("%(days)id %(hours)ih") % data - if hours > 0: - return tr("%(hours)ih %(minutes)im") % data - if minutes > 0: - return tr("%(minutes)im %(seconds)is") % data - return tr("%(seconds)is") % data - - -def get_base_path(): - """ Get absolute path to resource, works for dev and for PyInstaller """ - try: - # PyInstaller creates a temp folder and stores path in _MEIPASS - base_path = sys._MEIPASS - except Exception: - base_path = os.path.dirname(tribler.gui.__file__) - return base_path - - -TRANSLATIONS_DIR = os.path.join(get_base_path(), "i18n") - - -def get_available_translations(): - # Returns a list of tuples: (lanugage_name, language_code) for each available translation - translations_list = [str(p.stem) for p in Path(TRANSLATIONS_DIR).glob('*.qm')] - translations_list.append("en_US") - - result = {} - for lang_code in translations_list: - loc = QLocale(lang_code) - lang_name = loc.languageToString(loc.language()) - result[lang_name] = lang_code - return result - - -AVAILABLE_TRANSLATIONS = get_available_translations() - - -def get_ui_file_path(filename): - return os.path.join(get_base_path(), 'qt_resources', filename) - - -def get_i18n_file_path(filename): - return Path(get_base_path()) / I18N_DIR / filename - - -def get_languages_file_content(): - languages_path = get_i18n_file_path(LANGUAGES_FILE) - content = Path(languages_path).read_text(encoding='utf-8') - return json.loads(content) - - -def get_image_path(filename: str, convert_slashes_to_forward: bool = False) -> str: - """ - Return a path to a particular file in the image directory. - - If convert_slashes_to_forward is set to True, backward slashes are converted to forward slashes. - This can be used to ensure that images on Windows can be correctly loaded. - Also see https://stackoverflow.com/questions/26121737/qt-stylesheet-background-image-from-filepath. - """ - path = os.path.join(get_base_path(), 'images', filename) - if convert_slashes_to_forward: - path = path.replace("\\", "/") - return path - - -def get_font_path(filename: str) -> str: - """ - Return a path to a particular font in the fonts directory. - """ - return os.path.join(get_base_path(), 'fonts', filename) - - -def get_gui_setting(gui_settings, value, default, is_bool=False): - """ - Utility method to get a specific GUI setting. The is_bool flag defines whether we expect a boolean so we convert it - since on Windows, all values are saved as plain text. - """ - try: - val = gui_settings.value(value, default) - except TypeError: - val = default - if is_bool: - val = val == True or val == 'true' - return val - - -def create_api_key() -> str: - return os.urandom(16).hex() - - -def format_api_key(api_key) -> str: - if isinstance(api_key, bytes): - # In Tribler application, api_key is stored as a string. But in gui_settings api_key is stored as bytes, - # for compatibility with previous versions of Tribler. Otherwise, a user can get an error if he rolls back - # to a previous version of Tribler, that cannot read str values of api_key from GUI settings. - return api_key.decode('ascii') - - if isinstance(api_key, str): - # If we read api_keys from gui_settings as str, we need to save it as bytes, to restore - # settings compatibility with previous versions of Tribler - return api_key - - raise ValueError( - f'Got unexpected value type of api_key from gui settings ' f'(should be str or bytes): {type(api_key).__name__}' - ) - - -def set_api_key(gui_settings: QSettings, api_key: str): - api_key_bytes = api_key.encode('ascii') - gui_settings.setValue("api_key", api_key_bytes) - - -def is_dir_writable(path): - """ - Checks if the directory is writable. Creates the directory if one does not exist. - :param path: absolute path of directory - :return: True if writable, False otherwise - """ - - directory = Path(path) - random_file = directory / f'tribler_temp_delete_me_{uuid4()}' - try: - directory.mkdir(parents=True, exist_ok=True) - random_file.open('w') - random_file.unlink() - except (OSError, UnicodeEncodeError) as e: - return False, e - else: - return True, None - - -def unicode_quoter(c): - """ - Quote a single unicode character for URI form. - - :param c: the character to quote - :return: the safe URI string - """ - try: - return quote_plus(c) - except KeyError: - return c - - -def quote_plus_unicode(s): - """ - Quote a unicode string for URI form. - - :param s: the string to quote - :return: the safe URI string - """ - return ''.join([unicode_quoter(c) for c in s]) - - -def get_health(seeders, leechers, last_tracker_check): - if last_tracker_check == 0: - return HEALTH_UNCHECKED - if seeders > 0: - return HEALTH_GOOD - elif leechers > 0: - return HEALTH_MOOT - else: - return HEALTH_DEAD - - -def compose_magnetlink(infohash: str | None, name: str | None = None, trackers: list[dict] | None = None) -> str: - """ - Composes magnet link from infohash, display name and trackers. The format is: - magnet:?xt=urn:btih:&dn=[&tr=] - There could be multiple trackers so 'tr' field could be repeated. - - :param infohash: Infohash - :param name: Display name - :param trackers: Trackers - :return: Magnet link - """ - if not infohash: - return "" - magnet = f"magnet:?xt=urn:btih:{infohash}" - if name: - magnet = f"{magnet}&dn={quote_plus_unicode(name)}" - - if not trackers: - return magnet - - urls = (t.get("url") for t in trackers) - valid_urls = [u for u in urls if u and u not in INVALID_URLS] - - for url in valid_urls: - magnet = f"{magnet}&tr={url}" - return magnet - - -def copy_to_clipboard(message): - cb = QApplication.clipboard() - cb.clear(mode=cb.Clipboard) - cb.setText(message, mode=cb.Clipboard) - - -def get_clipboard_text(): - cb = QApplication.clipboard() - return cb.text(mode=cb.Clipboard) - - -def html_label(text, background="#e4e4e4", color="#222222", bold=True): - style = "background-color:" + background if background else '' - style = style + ";color:" + color if color else style - style = style + ";font-weight:bold" if bold else style - return f"" - - -def votes_count(votes=0.0): - votes = float(votes) - votes = max(0.0, min(votes, 1.0)) - # We add sqrt to flatten the votes curve a bit - votes = math.sqrt(votes) - votes = int(math.ceil(votes * NUM_VOTES_BARS)) - return votes - - -def format_votes(votes=0.0): - return f" {'┃' * votes_count(votes)} " - - -def format_votes_rich_text(votes=0.0): - votes_count_full = votes_count(votes) - votes_count_empty = votes_count(1.0) - votes_count_full - - rating_rich_text = ( - f"{'┃' * votes_count_full}" + - f"{'┃' * votes_count_empty}" - ) - return rating_rich_text - - -def get_votes_rating_description(votes=0.0): - return VOTES_RATING_DESCRIPTIONS[math.ceil(float(votes_count(votes)) / 2)] - - -def connect(signal: pyqtSignal, callback: Callable): - """ - By default calling ``signal.connect(callback)`` will dispose of context information. - - Practically, this leads to single line tracebacks when ``signal.emit()`` is invoked. - This is very hard to debug. - - This function wraps the ``connect()`` call to give you additional traceback information, if the callback does crash. - - :param signal: the signal to ``connect()`` to. - :param callback: the callback to connect (will be called after ``signal.emit(...)``. - """ - - # Step 1: At time of calling this function: get the stack frames. - # We reconstruct the stack as a ``traceback`` object. - source = None - for frame in list(inspect.stack())[1:]: - source = types.TracebackType(source, frame.frame, frame.index or 0, frame.lineno) - - # Step 2: construct a lightweight StackSummary object which does not contain - # actual frames or locals, to avoid memory leak - try: - summary = traceback.StackSummary.extract(traceback.walk_tb(source), capture_locals=False) - finally: - del source - - # Step 3: Wrap the ``callback`` and inject our creation stack if an error occurs. - # The BaseException instead of Exception is intentional: this makes sure interrupts of infinite loops show - # the source callback stack for debugging. - def trackback_wrapper(*args, **kwargs): - try: - callback(*args, **kwargs) - except BaseException as exc: - traceback_str = '\n' + ''.join(summary.format()) - raise exc from CreationTraceback(traceback_str) - - try: - setattr(callback, "tb_wrapper", trackback_wrapper) - except AttributeError: - # This is not a free function, but either an external library or a method bound to an instance. - if not hasattr(callback, "tb_wrapper") and hasattr(callback, "__self__") and hasattr(callback, "__func__"): - # methods are finicky: you can't set attributes on them. - # Instead, we inject the handlers for each method in a dictionary on the instance. - bound_obj = callback.__self__ - if not hasattr(bound_obj, "tb_mapping"): - setattr(bound_obj, "tb_mapping", {}) - bound_obj.tb_mapping[callback.__func__.__name__] = trackback_wrapper - else: - logging.warning( - "Unable to hook up connect() info to %s. Probably a 'builtin_function_or_method'.", repr(callback) - ) - - # Step 3: Connect our wrapper to the signal. - signal.connect(trackback_wrapper) - - -def disconnect(signal: pyqtSignal, callback: Callable): - """ - After using ``connect()`` to link a signal, use this function to disconnect from the given signal. - - This function will also work if the ``callback`` was connected directly with ``signal.connect()``. - - :param signal: the signal to ``disconnect()`` from. - :param callback: the callback to connect (will be called after ``signal.emit(...)``. - """ - if hasattr(callback, 'tb_wrapper'): - disconnectable = callback.tb_wrapper - elif hasattr(callback, "__self__") and hasattr(callback, "__func__"): - disconnectable = callback.__self__.tb_mapping[callback.__func__.__name__] - else: - disconnectable = callback - signal.disconnect(disconnectable) - - -class CreationTraceback(Exception): - pass - - -def dict_item_is_any_of(d, key, values): - if not d or not key or not values: - return False - return key in d and d[key] in values - - -def get_translator(language=None): - system_locale = QLocale.system() - # Remapping the language from uiLanguages is a workaround for an annoying bug in Qt, - # which makes QTranslator use the system language (e.g. the language the OS was installed in), - # instead of the user-display language the user installed later. - locale = QLocale(language) if language is not None else QLocale(system_locale.uiLanguages()[0]) - logger.info("Available Tribler translations %s", AVAILABLE_TRANSLATIONS) - logger.info("System language: %s, Tribler language: %s", system_locale.uiLanguages(), locale.uiLanguages()) - translator = QTranslator() - filename = "" - translator.load(locale, filename, directory=TRANSLATIONS_DIR) - return translator - - -def take_screenshot(window, screenshots_dir): - timestamp = int(time.time()) - pixmap = QPixmap(window.rect().size()) - window.render(pixmap, QPoint(), QRegion(window.rect())) - screenshots_dir.mkdir(exist_ok=True) - img_name = 'exception_screenshot_%d.jpg' % timestamp - pixmap.save(str(screenshots_dir / img_name)) - - -def show_message_box(text: str = '', title: str = 'Error', icon: QMessageBox.Icon = QMessageBox.Critical): - message_box = QMessageBox() - message_box.setIcon(icon) - message_box.setStandardButtons(QMessageBox.Yes) - message_box.setWindowTitle(title) - message_box.setText(text) - message_box.exec_() - - -def show_message_corrupted_database_was_fixed(db_path: Optional[str] = None): - text = tr(CORRUPTED_DB_WAS_FIXED_MESSAGE) - if db_path: - text = f'{text}:\n\n{db_path}' - - message_box = QMessageBox(icon=QMessageBox.Critical, text=text) - message_box.setWindowTitle(tr("Database corruption detected")) - message_box.exec() - - -def make_network_errors_dict() -> Dict[int, str]: - network_errors = {} - for name in dir(QNetworkReply): - if name.endswith('Error'): - value = getattr(QNetworkReply, name) - if isinstance(value, int): - network_errors[value] = name - return network_errors - - -def get_color(name): - """ - This method deterministically determines a color of a given name. This is done by taking the MD5 hash of the text. - """ - md5_hash = hashlib.md5() - md5_hash.update(name.encode('utf-8')) - md5_str_hash = md5_hash.hexdigest() - - red = int(md5_str_hash[0:10], 16) % 128 + 100 - green = int(md5_str_hash[10:20], 16) % 128 + 100 - blue = int(md5_str_hash[20:30], 16) % 128 + 100 - - return f'#{red:02x}{green:02x}{blue:02x}' - - -def get_objects_with_predicate(data_item: Dict, predicate: ResourceType) -> List[str]: - """ - Extract the objects that have a particular predicate from a particular data item. - """ - return [stmt["object"] for stmt in data_item.get("statements", ()) if stmt["predicate"] == predicate] diff --git a/src/tribler/gui/widgets/__init__.py b/src/tribler/gui/widgets/__init__.py deleted file mode 100644 index 5ab1e96d6c..0000000000 --- a/src/tribler/gui/widgets/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -This module contains the widgets used by the Qt GUI. -""" diff --git a/src/tribler/gui/widgets/channelcontentswidget.py b/src/tribler/gui/widgets/channelcontentswidget.py deleted file mode 100644 index c029dc8585..0000000000 --- a/src/tribler/gui/widgets/channelcontentswidget.py +++ /dev/null @@ -1,447 +0,0 @@ -from base64 import b64encode -from sys import platform - -from PyQt5 import uic -from PyQt5.QtCore import QDir, Qt, pyqtSignal -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QAction, QFileDialog - -from tribler.core.database.orm_bindings.torrent_metadata import NEW -from tribler.core.database.serialization import CHANNEL_TORRENT, COLLECTION_NODE -from tribler.gui.defs import ( - BUTTON_TYPE_CONFIRM, - BUTTON_TYPE_NORMAL, - CATEGORY_SELECTOR_FOR_SEARCH_ITEMS, - ContentCategories, -) -from tribler.gui.dialogs.confirmationdialog import ConfirmationDialog -from tribler.gui.network.request_manager import request_manager -from tribler.gui.tribler_action_menu import TriblerActionMenu -from tribler.gui.utilities import connect, disconnect, get_image_path, get_ui_file_path, tr -from tribler.gui.widgets.search_results_model import SearchResultsModel -from tribler.gui.widgets.tablecontentmodel import ChannelContentModel -from tribler.gui.widgets.triblertablecontrollers import ContentTableViewController - -widget_form, widget_class = uic.loadUiType(get_ui_file_path('torrents_list.ui')) - - -# pylint: disable=too-many-instance-attributes, too-many-public-methods -class ChannelContentsWidget(widget_form, widget_class): - model_query_completed = pyqtSignal() - - def __init__(self, parent=None): - widget_class.__init__(self, parent=parent) - - # ACHTUNG! This is a dumb workaround for a bug(?) in PyQT bindings in Python 3.7 - # When more than a single instance of a class is created, every next setupUi - # triggers connectSlotsByName error. There are some reports that it is specific to - # 3.7 and there is a fix in the 10.08.2019 PyQt bindings snapshot. - try: - self.setupUi(self) - except SystemError: - pass - - # ! ACHTUNG ! - # There is a bug in PyQT bindings that prevents uic.loadUiType from correctly - # detecting paths to external resources used in .ui files. Therefore, - # for each external resource (e.g. image/icon), we must reload it manually here. - self.channel_options_button.setIcon(QIcon(get_image_path('ellipsis.png'))) - - self.default_channel_model = ChannelContentModel - - self.initialized = False - self.chosen_dir = None - self.dialog = None - self.controller = None - self.channel_options_menu = None - - self.channels_stack = [] - self.categories = () - - self_ref = self - - self.hide_xxx = None - - # This context manager is used to freeze the state of controls in the models stack to - # prevent signals from triggering for inactive models. - class freeze_controls_class: - objects_to_freeze = [ - self_ref.category_selector, - self_ref.content_table.horizontalHeader(), - self_ref.channel_torrents_filter_input, - ] - - def __enter__(self): - for obj in self.objects_to_freeze: - obj.blockSignals(True) - - def __exit__(self, *args): - for obj in self.objects_to_freeze: - obj.blockSignals(False) - - self.freeze_controls = freeze_controls_class - - self.explanation_tooltip_button.setHidden(True) - - def hide_all_labels(self): - self.edit_channel_contents_top_bar.setHidden(True) - self.channel_num_torrents_label.setHidden(True) - self.channel_state_label.setHidden(True) - - @property - def model(self): - return self.channels_stack[-1] if self.channels_stack else None - - @property - def root_model(self): - return self.channels_stack[0] if self.channels_stack else None - - def initialize_content_page( - self, - hide_xxx=None, - controller_class=ContentTableViewController, - categories=CATEGORY_SELECTOR_FOR_SEARCH_ITEMS, - ): - if self.initialized: - return - - self.hide_xxx = hide_xxx - self.initialized = True - self.categories = categories - self.category_selector.addItems(self.categories) - connect(self.category_selector.currentIndexChanged, self.on_category_selector_changed) - self.channel_back_button.setIcon(QIcon(get_image_path('page_back.png'))) - self.channel_back_button.setHidden(True) - connect(self.channel_back_button.clicked, self.go_back) - connect(self.channel_name_label.linkActivated, self.on_breadcrumb_clicked) - - if platform == "linux": - # On Linux, the default font sometimes does not contain the emoji characters. - self.category_selector.setStyleSheet("font-family: Noto Color Emoji") - - self.controller = controller_class(self.content_table, filter_input=self.channel_torrents_filter_input) - - def _description_changed(self): - self.model.channel_info["dirty"] = True - self.update_labels() - - def run_brain_dead_refresh(self): - if self.model: - self.controller.brain_dead_refresh() - - def on_category_selector_changed(self, ind): - category = self.categories[ind] if ind else None - content_category = ContentCategories.get(category) - category_code = content_category.code if content_category else category - if self.model.category_filter != category_code: - self.model.category_filter = category_code - self.model.reset() - - def empty_channels_stack(self): - if self.channels_stack: - self.disconnect_current_model() - self.channels_stack = [] - - def push_channels_stack(self, model): - if self.model: - self.model.saved_header_state = self.controller.table_view.horizontalHeader().saveState() - self.model.saved_scroll_state = self.controller.table_view.verticalScrollBar().value() - self.disconnect_current_model() - self.channels_stack.append(model) - self.connect_current_model() - - with self.freeze_controls(): - self.category_selector.setCurrentIndex(0) - self.content_table.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) - self.channel_torrents_filter_input.setText("") - - def on_model_info_changed(self, changed_entries): - self.window().channels_menu_list.reload_if_necessary(changed_entries) - dirty = False - structure_changed = False - - if structure_changed: - self.window().add_to_channel_dialog.clear_channels_tree() - - self.model.channel_info["dirty"] = dirty - self.update_labels() - - def on_model_query_completed(self): - self.model_query_completed.emit() - - def initialize_root_model_from_channel_info(self, channel_info): - if channel_info.get("state") == "Personal": - self.default_channel_model = self.personal_channel_model - else: - self.default_channel_model = ChannelContentModel - model = self.default_channel_model(hide_xxx=self.hide_xxx, channel_info=channel_info) - self.initialize_root_model(model) - - def initialize_root_model(self, root_model): - self.empty_channels_stack() - self.push_channels_stack(root_model) - self.controller.set_model(self.model) - # Hide the edit controls by default, to prevent the user clicking the buttons prematurely - self.hide_all_labels() - - def reset_view(self, text_filter=None, category_filter=None): - if not self.model: - return - self.model.text_filter = text_filter or '' - self.model.category_filter = category_filter - - with self.freeze_controls(): - self._set_filter_controls_from_model() - self.controller.table_view.horizontalHeader().setSortIndicator(-1, Qt.DescendingOrder) - self.model.sort_by = ( - self.model.columns[self.model.default_sort_column].dict_key if self.model.default_sort_column >= 0 else None - ) - self.model.sort_desc = True - self.model.reset() - - def disconnect_current_model(self): - disconnect(self.window().core_manager.events_manager.node_info_updated, self.model.update_node_info) - disconnect(self.model.query_complete, self.on_model_query_completed) - - self.controller.unset_model() # Disconnect the selectionChanged signal - - def connect_current_model(self): - connect(self.model.query_complete, self.on_model_query_completed) - connect(self.window().core_manager.events_manager.node_info_updated, self.model.update_node_info) - - @property - def current_level(self): - return len(self.channels_stack) - 1 - - def go_back(self, checked=False): # pylint: disable=W0613 - self.go_back_to_level(self.current_level - 1) - - def on_breadcrumb_clicked(self, tgt_level): - if int(tgt_level) != self.current_level: - self.go_back_to_level(tgt_level) - elif isinstance(self.model, SearchResultsModel) and self.current_level == 0: - # In case of remote search, when only the search results are on the stack, - # we must keep the txt_filter and category_filter (which contains the search term) - # before resetting the view - self.reset_view(text_filter=self.model.text_filter, category_filter=self.model.category_filter) - else: - # Reset the view if the user clicks on the last part of the breadcrumb - self.reset_view() - - def format_search_title(self): - text = self.model.format_title() - self.channel_name_label.setText(text) - - def _set_filter_controls_from_model(self): - # This should typically be called under freeze_controls context manager - content_category = ContentCategories.get(self.model.category_filter) - filter_display_name = content_category.long_name if content_category else self.model.category_filter - self.category_selector.setCurrentIndex( - self.categories.index(filter_display_name) if filter_display_name in self.categories else 0 - ) - self.channel_torrents_filter_input.setText(self.model.text_filter or '') - - def go_back_to_level(self, level): - switched_level = False - level = int(level) - disconnected_current_model = False - while 0 <= level < self.current_level: - switched_level = True - if not disconnected_current_model: - disconnected_current_model = True - self.disconnect_current_model() - self.channels_stack.pop().deleteLater() - - if switched_level: - self.channel_description_container.initialized = False - # We block signals to prevent triggering redundant model reloading - with self.freeze_controls(): - self._set_filter_controls_from_model() - # Set filter category selector to correct index corresponding to loaded model - self.controller.set_model(self.model) - - self.connect_current_model() - self.update_labels() - - - def update_navigation_breadcrumbs(self): - # Assemble the channels navigation breadcrumb by utilising RichText links feature - self.channel_name_label.setTextFormat(Qt.RichText) - # We build the breadcrumb text backwards, by performing lookahead on each step. - # While building the breadcrumb label in RichText we also assemble an undecorated variant of the same text - # to estimate if we need to elide the breadcrumb. We cannot use RichText contents directly with - # .elidedText method because QT will elide the tags as well. - breadcrumb_text = '' - breadcrumb_text_undecorated = '' - path_parts = [(m, model.channel_info["name"]) for m, model in enumerate(self.channels_stack)] - slash_separator = ' / ' - for m, channel_name in reversed(path_parts): - breadcrumb_text_undecorated = " / " + channel_name + breadcrumb_text_undecorated - breadcrumb_text_elided = self.channel_name_label.fontMetrics().elidedText( - breadcrumb_text_undecorated, 0, self.channel_name_label.width() - ) - must_elide = breadcrumb_text_undecorated != breadcrumb_text_elided - if must_elide: - channel_name = "..." - breadcrumb_text = ( - slash_separator - + f'{channel_name}' - + breadcrumb_text - ) - if must_elide: - break - # Remove the leftmost slash: - if len(breadcrumb_text) >= len(slash_separator): - breadcrumb_text = breadcrumb_text[len(slash_separator):] - - self.channel_name_label.setText(breadcrumb_text) - self.channel_name_label.setTextInteractionFlags(Qt.TextBrowserInteraction) - - self.channel_back_button.setHidden(self.current_level == 0) - - # Disabling focus on the label is necessary to remove the ugly dotted rectangle around the most recently - # clicked part of the path. - # ACHTUNG! Setting focus policy in the .ui file does not work for some reason! - # Also, something changes the focus policy during the runtime, so we have to re-set it every time here. - self.channel_name_label.setFocusPolicy(Qt.NoFocus) - - def update_labels(self): - - folder = self.model.channel_info.get("type", None) == COLLECTION_NODE - personal = self.model.channel_info.get("state", None) == CHANNEL_STATE.PERSONAL.value - root = self.current_level == 0 - legacy = self.model.channel_info.get("state", None) == CHANNEL_STATE.LEGACY.value - is_a_channel = self.model.channel_info.get("type", None) == CHANNEL_TORRENT - - self.update_navigation_breadcrumbs() - - container = self.channel_description_container - container.initialized = False - container.setHidden(True) - - self.category_selector.setHidden(root) - - self.subscription_widget.setHidden(not is_a_channel or personal or folder or legacy) - if not self.subscription_widget.isHidden(): - self.subscription_widget.update_subscribe_button(self.model.channel_info) - - if "total" in self.model.channel_info: - self.channel_num_torrents_label.setHidden(False) - if "torrents" in self.model.channel_info: - self.channel_num_torrents_label.setText(tr("%(total)i/%(torrents)i items") % self.model.channel_info) - else: - self.channel_num_torrents_label.setText(tr("%(total)i items") % self.model.channel_info) - else: - self.channel_num_torrents_label.setHidden(True) - - # ============================== - # Channel menu related methods. - # ============================== - - def create_channel_options_menu(self): - browse_files_action = QAction(tr("Add .torrent file"), self) - browse_dir_action = QAction(tr("Add torrent(s) directory"), self) - add_url_action = QAction(tr("Add URL/magnet links"), self) - - connect(browse_files_action.triggered, self.on_add_torrent_browse_file) - connect(browse_dir_action.triggered, self.on_add_torrents_browse_dir) - connect(add_url_action.triggered, self.on_add_torrent_from_url) - - channel_options_menu = TriblerActionMenu(self) - channel_options_menu.addAction(browse_files_action) - channel_options_menu.addAction(browse_dir_action) - channel_options_menu.addAction(add_url_action) - return channel_options_menu - - # Torrent addition-related methods - def on_add_torrents_browse_dir(self, checked): # pylint: disable=W0613 - chosen_dir = QFileDialog.getExistingDirectory( - self, - tr("Please select the directory containing the .torrent files"), - QDir.homePath(), - QFileDialog.ShowDirsOnly, - ) - if not chosen_dir: - return - - self.chosen_dir = chosen_dir - self.dialog = ConfirmationDialog( - self, - tr("Add torrents from directory"), - tr("Add all torrent files from the following directory to your Tribler channel: \n\n %s") % chosen_dir, - [('ADD', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], - checkbox_text=tr("Include subdirectories (recursive mode)"), - ) - connect(self.dialog.button_clicked, self.on_confirm_add_directory_dialog) - self.dialog.show() - - def on_confirm_add_directory_dialog(self, action): - if action == 0: - self.add_dir_to_channel(self.chosen_dir, recursive=self.dialog.checkbox.isChecked()) - - if self.dialog: - self.dialog.close_dialog() - self.dialog = None - self.chosen_dir = None - - def on_add_torrent_browse_file(self, checked): # pylint: disable=W0613 - filenames = QFileDialog.getOpenFileNames( - self, tr("Please select the .torrent file"), filter=(tr("Torrent files %s") % '(*.torrent)') - ) - if not filenames[0]: - return - - for filename in filenames[0]: - self.add_torrent_to_channel(filename) - - def on_add_torrent_from_url(self, checked): # pylint: disable=W0613 - self.dialog = ConfirmationDialog( - self, - tr("Add torrent from URL/magnet link"), - tr("Please enter the URL/magnet link in the field below:"), - [(tr("ADD"), BUTTON_TYPE_NORMAL), (tr("CANCEL"), BUTTON_TYPE_CONFIRM)], - show_input=True, - ) - self.dialog.dialog_widget.dialog_input.setPlaceholderText(tr("URL/magnet link")) - connect(self.dialog.button_clicked, self.on_torrent_from_url_dialog_done) - self.dialog.show() - - def on_torrent_from_url_dialog_done(self, action): - if action == 0: - self.add_torrent_url_to_channel(self.dialog.dialog_widget.dialog_input.text()) - self.dialog.close_dialog() - self.dialog = None - - def _on_torrent_to_channel_added(self, result): - if not result: - return - if result.get('added'): - # ACHTUNG: this is a dumb hack to adapt torrents PUT endpoint output to the info_changed signal. - # If thousands of torrents were added, we don't want to post them all in a single - # REST response. Instead, we always provide the total number of new torrents. - # If we add a single torrent though, the endpoint will return it as a dict. - # However, on_model_info_changed always expects a list of changed entries. - # So, we make up the list. - results_list = result['added'] - if isinstance(results_list, dict): - results_list = [results_list] - elif isinstance(results_list, int): - results_list = [{'status': NEW}] - self.model.info_changed.emit(results_list) - self.model.reset() - - def _add_torrent_request(self, data): - channel_id = self.model.channel_info["id"] - request_manager.put(f'channels/mychannel/{channel_id}/torrents', - on_success=self._on_torrent_to_channel_added, data=data) - - def add_torrent_to_channel(self, filename): - with open(filename, "rb") as torrent_file: - torrent_content = b64encode(torrent_file.read()).decode('utf-8') - self._add_torrent_request({"torrent": torrent_content}) - - def add_dir_to_channel(self, dirname, recursive=False): - self._add_torrent_request({"torrents_dir": dirname, "recursive": int(recursive)}) - - def add_torrent_url_to_channel(self, url): - self._add_torrent_request({"uri": url}) diff --git a/src/tribler/gui/widgets/circlebutton.py b/src/tribler/gui/widgets/circlebutton.py deleted file mode 100644 index 607fbb5701..0000000000 --- a/src/tribler/gui/widgets/circlebutton.py +++ /dev/null @@ -1,7 +0,0 @@ -from PyQt5.QtWidgets import QToolButton - - -class CircleButton(QToolButton): - """ - Represents a circular button in the GUI. - """ diff --git a/src/tribler/gui/widgets/clickable_line_edit.py b/src/tribler/gui/widgets/clickable_line_edit.py deleted file mode 100644 index 036e45c8eb..0000000000 --- a/src/tribler/gui/widgets/clickable_line_edit.py +++ /dev/null @@ -1,13 +0,0 @@ -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QLineEdit - - -class ClickableLineEdit(QLineEdit): - """ - Represents a clickable QLineEdit widget. - """ - clicked = pyqtSignal(bool) - - def mousePressEvent(self, event): - self.clicked.emit(False) - QLineEdit.mousePressEvent(self, event) diff --git a/src/tribler/gui/widgets/clickablewidgets.py b/src/tribler/gui/widgets/clickablewidgets.py deleted file mode 100644 index af51158bd7..0000000000 --- a/src/tribler/gui/widgets/clickablewidgets.py +++ /dev/null @@ -1,10 +0,0 @@ -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QLabel - - -class ClickableLabel(QLabel): - clicked = pyqtSignal() - - def mousePressEvent(self, event): - self.clicked.emit() - QLabel.mousePressEvent(self, event) diff --git a/src/tribler/gui/widgets/createtorrentpage.py b/src/tribler/gui/widgets/createtorrentpage.py deleted file mode 100644 index faa5ef2983..0000000000 --- a/src/tribler/gui/widgets/createtorrentpage.py +++ /dev/null @@ -1,118 +0,0 @@ -import os - -from PyQt5.QtCore import QDir -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QAction, QFileDialog, QWidget - -from tribler.gui.defs import BUTTON_TYPE_NORMAL -from tribler.gui.dialogs.confirmationdialog import ConfirmationDialog -from tribler.gui.network.request_manager import request_manager -from tribler.gui.tribler_action_menu import TriblerActionMenu -from tribler.gui.utilities import connect, get_image_path - - -class CreateTorrentPage(QWidget): - """ - The CreateTorrentPage is the page where users can create torrent files so they can be added to their channel. - """ - - def __init__(self): - QWidget.__init__(self) - self.dialog = None - self.selected_item_index = -1 - self.initialized = False - - def initialize(self): - self.window().create_torrent_name_field.setText('') - self.window().create_torrent_description_field.setText('') - self.window().create_torrent_files_list.clear() - self.window().seed_after_adding_checkbox.setChecked(True) - self.window().edit_channel_create_torrent_progress_label.hide() - - if not self.initialized: - self.window().manage_channel_create_torrent_back.setIcon(QIcon(get_image_path('page_back.png'))) - - connect(self.window().create_torrent_files_list.customContextMenuRequested, self.on_right_click_file_item) - connect( - self.window().manage_channel_create_torrent_back.clicked, self.on_create_torrent_manage_back_clicked - ) - connect(self.window().create_torrent_choose_files_button.clicked, self.on_choose_files_clicked) - connect(self.window().create_torrent_choose_dir_button.clicked, self.on_choose_dir_clicked) - connect(self.window().edit_channel_create_torrent_button.clicked, self.on_create_clicked) - - self.initialized = True - - def on_choose_files_clicked(self, checked): - filenames, _ = QFileDialog.getOpenFileNames(self.window(), "Please select the files", QDir.homePath()) - - for filename in filenames: - self.window().create_torrent_files_list.addItem(filename) - - def on_choose_dir_clicked(self, checked): - chosen_dir = QFileDialog.getExistingDirectory( - self.window(), "Please select the directory containing the files", "", QFileDialog.ShowDirsOnly - ) - - if len(chosen_dir) == 0: - return - - files = [] - for path, _, dir_files in os.walk(chosen_dir): - for filename in dir_files: - files.append(os.path.join(path, filename)) - - self.window().create_torrent_files_list.clear() - for filename in files: - self.window().create_torrent_files_list.addItem(filename) - - def on_create_clicked(self, checked): - if self.window().create_torrent_files_list.count() == 0: - self.dialog = ConfirmationDialog( - self, "Notice", "You should add at least one file to your torrent.", [('CLOSE', BUTTON_TYPE_NORMAL)] - ) - connect(self.dialog.button_clicked, self.on_dialog_ok_clicked) - self.dialog.show() - return - - self.window().edit_channel_create_torrent_button.setEnabled(False) - - files_list = [] - for ind in range(self.window().create_torrent_files_list.count()): - file_str = self.window().create_torrent_files_list.item(ind).text() - files_list.append(file_str) - - name = self.window().create_torrent_name_field.text() - description = self.window().create_torrent_description_field.toPlainText() - - is_seed = self.window().seed_after_adding_checkbox.isChecked() - request_manager.post('createtorrent', self.on_torrent_created, - data={"name": name, "description": description, "files": files_list}, - url_params={'download': 1} if is_seed else None) - # Show creating torrent text - self.window().edit_channel_create_torrent_progress_label.show() - - def on_dialog_ok_clicked(self, _): - self.dialog.close_dialog() - self.dialog = None - - def on_torrent_created(self, result): - if not result: - return - self.window().edit_channel_create_torrent_button.setEnabled(True) - - def on_remove_entry(self): - self.window().create_torrent_files_list.takeItem(self.selected_item_index) - - def on_right_click_file_item(self, pos): - item_clicked = self.window().create_torrent_files_list.itemAt(pos) - if not item_clicked: - return - - self.selected_item_index = self.window().create_torrent_files_list.row(item_clicked) - - menu = TriblerActionMenu(self) - - remove_action = QAction('Remove file', self) - connect(remove_action.triggered, self.on_remove_entry) - menu.addAction(remove_action) - menu.exec_(self.window().create_torrent_files_list.mapToGlobal(pos)) diff --git a/src/tribler/gui/widgets/downloadprogressbar.py b/src/tribler/gui/widgets/downloadprogressbar.py deleted file mode 100644 index e4799836bc..0000000000 --- a/src/tribler/gui/widgets/downloadprogressbar.py +++ /dev/null @@ -1,94 +0,0 @@ -import base64 -import math - -from PyQt5.QtCore import QRect -from PyQt5.QtGui import QColor, QPainter -from PyQt5.QtWidgets import QStyle, QStyleOption, QWidget - -from tribler.core.libtorrent.download_manager.download_state import DownloadStatus - - -class DownloadProgressBar(QWidget): - """ - The DownloadProgressBar is visible in the download details pane and displays the completed pieces (or the progress - of various actions such as file checking). - """ - - def __init__(self, parent): - QWidget.__init__(self, parent) - self.show_pieces = False - self.pieces = [] - self.fraction = 0 - self.download = None - - def update_with_download(self, download): - self.download = download - status = DownloadStatus(download["status_code"]) - - seeding_or_circuits = { - DownloadStatus.SEEDING, - DownloadStatus.CIRCUITS, - } - downloading_or_stopped = { - DownloadStatus.HASHCHECKING, - DownloadStatus.DOWNLOADING, - DownloadStatus.STOPPED, - DownloadStatus.STOPPED_ON_ERROR, - } - - if status in downloading_or_stopped: - self.set_pieces() - self.set_fraction(download.get("progress", 0.0)) - - def set_fraction(self, fraction): - self.fraction = fraction - self.repaint() - - def set_pieces(self): - if self.download.get("pieces"): - self.show_pieces = True - self.pieces = self.decode_pieces(self.download["pieces"])[: self.download["total_pieces"]] - else: - self.show_pieces = False - self.repaint() - - def decode_pieces(self, pieces): - byte_array = base64.b64decode(pieces) - # On Python 3, iterating over bytes already returns integers - if byte_array and not isinstance(byte_array[0], int): - byte_array = list(map(ord, byte_array)) - byte_string = ''.join(bin(num)[2:].zfill(8) for num in byte_array) - return [i == '1' for i in byte_string] - - def paintEvent(self, _): - opt = QStyleOption() - opt.initFrom(self) - painter = QPainter(self) - self.style().drawPrimitive(QStyle.PE_Widget, opt, painter, self) - - if self.show_pieces: - if len(self.pieces) == 0: # Nothing to paint - return - - if len(self.pieces) <= self.width(): # We have less pieces than pixels - piece_width = self.width() / float(len(self.pieces)) - for pixel in range(len(self.pieces)): - if self.pieces[pixel]: - painter.fillRect( - QRect(int(float(pixel) * piece_width), 0, math.ceil(piece_width), self.height()), - QColor(230, 115, 0), - ) - else: # We have more pieces than pixels, group pieces - pieces_per_pixel = len(self.pieces) / float(self.width()) - for pixel in range(self.width()): - start = int(pieces_per_pixel * pixel) - stop = int(start + pieces_per_pixel) - - downloaded_pieces = sum(self.pieces[start:stop]) - qt_color = QColor(230, 115, 0) - decimal_percentage = 1 - downloaded_pieces / pieces_per_pixel - fill_size = 128 + int(127 * decimal_percentage) - qt_color.setHsl(26, 255, fill_size) - painter.fillRect(QRect(pixel, 0, 10, self.height()), qt_color) - else: - painter.fillRect(QRect(0, 0, int(self.width() * self.fraction), self.height()), QColor(230, 115, 0)) diff --git a/src/tribler/gui/widgets/downloadsdetailstabwidget.py b/src/tribler/gui/widgets/downloadsdetailstabwidget.py deleted file mode 100644 index 7dd90ca293..0000000000 --- a/src/tribler/gui/widgets/downloadsdetailstabwidget.py +++ /dev/null @@ -1,218 +0,0 @@ -import math -import operator -from enum import IntEnum -from pathlib import PurePosixPath -from typing import Dict, Optional - -from PyQt5.QtCore import QTimer, Qt -from PyQt5.QtWidgets import QTabWidget, QTreeWidgetItem - -from tribler.core.libtorrent.download_manager.download_state import DownloadStatus -from tribler.gui.defs import STATUS_STRING -from tribler.gui.utilities import compose_magnetlink, connect, copy_to_clipboard, format_size, format_speed, tr -from tribler.gui.widgets.torrentfiletreewidget import PreformattedTorrentFileTreeWidget - -INCLUDED_FILES_CHANGE_DELAY = 1000 # milliseconds - -# Disabled, because drawing progress bars with setItemWidget is horribly slow on some systems. -# We must use delegate-based drawing instead -PROGRESS_BAR_DRAW_LIMIT = 0 # Don't draw progress bars for files in torrents that have more than this many files - - -class DownloadDetailsTabs(IntEnum): - DETAILS = 0 - FILES = 1 - TRACKERS = 2 - PEERS = 3 - - -def convert_to_files_tree_format(download_info): - files = download_info['files'] - out = [] - for file in sorted(files, key=operator.itemgetter("index")): - file_path_parts = PurePosixPath(file['name']).parts - file_path = [download_info['name'], *file_path_parts] - if len(files) == 1: - # Special case of a torrent consisting of a single file - # ACHTUNG! Some torrents can still put a single file into a path of directories, resulting - # in a torrent name that contain slashes. This logic supports that case. - file_path = file_path_parts - out.append( - { - 'path': file_path, - 'length': file['size'], - 'included': file['included'], - 'progress': file['progress'], - } - ) - return out - - -class DownloadsDetailsTabWidget(QTabWidget): - """ - The DownloadDetailsTab is the tab that provides details about a specific selected download. This information - includes the connected peers, tracker status and file information. - """ - - def __init__(self, parent): - QTabWidget.__init__(self, parent) - self.current_download: Optional[Dict] = None - self.selected_files_info = [] - - # This timer is used to apply files selection changes in batches, to avoid multiple requests to the Core - # in case of e.g. deselecting a whole directory of files. - # When the user changes selection of files for download, we restart the timer. - # Then we apply all the changes in a single batch when it triggers. - # The same logic is used to batch Channel changes. - self._batch_changes_timer = QTimer(self) - self._batch_changes_timer.setSingleShot(True) - - def _restart_changes_timer(self): - self._batch_changes_timer.stop() - self._batch_changes_timer.start(INCLUDED_FILES_CHANGE_DELAY) - - def initialize_details_widget(self): - dl_files_list = PreformattedTorrentFileTreeWidget(self.window().download_files_tab) - self.window().download_files_tab.layout().addWidget(dl_files_list) - setattr(self.window(), "download_files_list", dl_files_list) - self.setCurrentIndex(0) - # make name, infohash and download destination selectable to copy - self.window().download_detail_infohash_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - self.window().download_detail_name_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - self.window().download_detail_destination_label.setTextInteractionFlags(Qt.TextSelectableByMouse) - connect(self.window().download_detail_copy_magnet_button.clicked, self.on_copy_magnet_clicked) - - def update_with_download(self, download): - # If the same infohash gets re-added with different parameters (e.g. different selected files), - # that's a different download. Thus, we must differ between the old one and the new one, to prevent - # "caching" the previous parameters. The most reliable way to make difference is by time_added property - did_change = ( - self.current_download is None - or self.current_download.get('infohash') != download.get('infohash') - or self.current_download.get('time_added') != download.get('time_added') - ) - # When we switch to another download, we want to fixate the changes user did to selected files. - # Also, we have to stop the change batching time to prevent carrying the event to the new download - if did_change and self._batch_changes_timer.isActive(): - self._batch_changes_timer.stop() - - self.current_download = download - self.update_pages(new_download=did_change) - - @staticmethod - def update_tracker_row(item, tracker): - item.setText(0, tracker["url"]) - item.setText(1, tracker["status"]) - item.setText(2, str(tracker["peers"])) - - @staticmethod - def update_peer_row(item, peer): - peer_name = f"{peer['ip']}:{peer['port']}" - if peer['connection_type'] == 1: - peer_name += ' [WebSeed]' - elif peer['connection_type'] == 2: - peer_name += ' [HTTP Seed]' - elif peer['connection_type'] == 3: - peer_name += ' [uTP]' - - state = "" - if peer['optimistic']: - state += "O," - if peer['uinterested']: - state += "UI," - if peer['uchoked']: - state += "UC," - if peer['uhasqueries']: - state += "UQ," - if not peer['uflushed']: - state += "UBL," - if peer['dinterested']: - state += "DI," - if peer['dchoked']: - state += "DC," - if peer['snubbed']: - state += "S," - state += peer['direction'] - - item.setText(0, peer_name) - item.setText(1, '%d%%' % (peer['completed'] * 100.0)) - item.setText(2, format_speed(peer['downrate'])) - item.setText(3, format_speed(peer['uprate'])) - item.setText(4, state) - item.setText(5, peer['extended_version']) - - def update_pages(self, new_download=False): - if self.current_download is None: - return - - if "files" not in self.current_download: - self.current_download["files"] = [] - - self.window().download_progress_bar.show_pieces = True - self.window().download_progress_bar.update_with_download(self.current_download) - self.window().download_detail_name_label.setText(self.current_download['name']) - - if self.current_download["vod_mode"]: - self.window().download_detail_status_label.setText('Streaming') - else: - status = DownloadStatus(self.current_download["status_code"]) - status_string = STATUS_STRING[status] - if status == DownloadStatus.STOPPED_ON_ERROR: - status_string += f" (error: {self.current_download['error']})" - self.window().download_detail_status_label.setText(status_string) - - self.window().download_detail_filesize_label.setText( - tr("%(num_bytes)s in %(num_files)d files") - % { - 'num_bytes': format_size(float(self.current_download["size"])), - 'num_files': len(self.current_download["files"]), - } - ) - self.window().download_detail_health_label.setText( - tr("%d seeders, %d leechers") % (self.current_download["num_seeds"], self.current_download["num_peers"]) - ) - self.window().download_detail_infohash_label.setText(self.current_download['infohash']) - self.window().download_detail_destination_label.setText(self.current_download["destination"]) - all_time_upload = format_size(self.current_download['all_time_upload']) - all_time_download = format_size(self.current_download['all_time_download']) - all_time_ratio = self.current_download['all_time_ratio'] - all_time_ratio = '∞' if all_time_ratio == math.inf else f'{all_time_ratio:.3f}' - self.window().download_detail_ratio_label.setText( - f"{all_time_ratio}, upload: {all_time_upload}, download: {all_time_download}" - ) - availability = self.current_download.get('availability') - availability = f"{availability :.2f}" if availability else '' - - self.window().download_detail_availability_label.setText(availability) - - if new_download: - self.window().download_files_list.clear() - self.window().download_files_list.initialize(self.current_download['infohash']) - - # Populate the trackers list - self.window().download_trackers_list.clear() - for tracker in self.current_download["trackers"]: - item = QTreeWidgetItem(self.window().download_trackers_list) - DownloadsDetailsTabWidget.update_tracker_row(item, tracker) - - # Populate the peers list if the peer information is available - self.window().download_peers_list.clear() - if "peers" in self.current_download: - for peer in self.current_download["peers"]: - item = QTreeWidgetItem(self.window().download_peers_list) - DownloadsDetailsTabWidget.update_peer_row(item, peer) - - def on_copy_magnet_clicked(self, _): - """ - Copy the magnet link of the current download to the clipboard. - """ - if not self.current_download: - return - - magnet = compose_magnetlink( - infohash=self.current_download.get("infohash"), - name=self.current_download.get("name"), - trackers=self.current_download.get("trackers", []) - ) - copy_to_clipboard(magnet) - self.window().tray_show_message("Copying magnet link", magnet) diff --git a/src/tribler/gui/widgets/downloadspage.py b/src/tribler/gui/widgets/downloadspage.py deleted file mode 100644 index 95459e527c..0000000000 --- a/src/tribler/gui/widgets/downloadspage.py +++ /dev/null @@ -1,643 +0,0 @@ -import logging -import os -from pathlib import Path -from typing import List, Optional, Tuple - -from PyQt5.QtCore import QTimer, QUrl, Qt, pyqtSignal -from PyQt5.QtGui import QDesktopServices -from PyQt5.QtNetwork import QNetworkRequest -from PyQt5.QtWidgets import QAbstractItemView, QAction, QFileDialog, QWidget - -from tribler.core.libtorrent.download_manager.download_state import DownloadStatus -from tribler.gui.defs import ( - BUTTON_TYPE_CONFIRM, - BUTTON_TYPE_NORMAL, - DOWNLOADS_FILTER_ACTIVE, - DOWNLOADS_FILTER_ALL, - DOWNLOADS_FILTER_CHANNELS, - DOWNLOADS_FILTER_COMPLETED, - DOWNLOADS_FILTER_DEFINITION, - DOWNLOADS_FILTER_DOWNLOADING, - DOWNLOADS_FILTER_INACTIVE, ) -from tribler.gui.dialogs.confirmationdialog import ConfirmationDialog -from tribler.gui.network.request import REQUEST_ID -from tribler.gui.network.request_manager import request_manager -from tribler.gui.tribler_action_menu import TriblerActionMenu -from tribler.gui.utilities import connect, format_speed, tr -from tribler.gui.widgets.downloadsdetailstabwidget import DownloadDetailsTabs -from tribler.gui.widgets.downloadwidgetitem import DownloadWidgetItem, LoadingDownloadWidgetItem -from tribler.gui.widgets.loading_list_item import LoadingListItem - -REFRESH_DOWNLOADS_SOON_INTERVAL_MSEC = 10 # 0.01s -REFRESH_DOWNLOADS_UI_CHANGE_INTERVAL_MSEC = 2000 # 2s -REFRESH_DOWNLOADS_BACKGROUND_INTERVAL_MSEC = 5000 # 5s - -button_name2filter = { - "downloads_all_button": DOWNLOADS_FILTER_ALL, - "downloads_downloading_button": DOWNLOADS_FILTER_DOWNLOADING, - "downloads_completed_button": DOWNLOADS_FILTER_COMPLETED, - "downloads_active_button": DOWNLOADS_FILTER_ACTIVE, - "downloads_inactive_button": DOWNLOADS_FILTER_INACTIVE, - "downloads_channels_button": DOWNLOADS_FILTER_CHANNELS, -} - - -class DownloadsPage(QWidget): - """ - This class is responsible for managing all items on the downloads page. - The downloads page shows all downloads and specific details about a download. - """ - - received_downloads = pyqtSignal(object) - - def __init__(self): - super().__init__() - self._logger = logging.getLogger(self.__class__.__name__) - self.export_dir = None - self.filter = DOWNLOADS_FILTER_ALL - self.download_widgets = {} # key: infohash, value: QTreeWidgetItem - self.background_refresh_downloads_timer = QTimer() - self.downloads_last_update = 0 - self.selected_items: List[DownloadWidgetItem] = [] - self.dialog = None - self.loading_message_widget: Optional[LoadingDownloadWidgetItem] = None - self.loading_list_item: Optional[LoadingListItem] = None - self.total_download_speed = 0 - self.total_upload_speed = 0 - - # Used to keep track of the last processed request with a purpose of ignoring old requests - self.last_processed_request_id = 0 - - def showEvent(self, QShowEvent): - """ - When the downloads tab is clicked, we want to update the downloads list immediately. - """ - super().showEvent(QShowEvent) - self.schedule_downloads_refresh(REFRESH_DOWNLOADS_SOON_INTERVAL_MSEC) - - def hideEvent(self, QHideEvent): - super().hideEvent(QHideEvent) - self.stop_refreshing_downloads() - - def initialize_downloads_page(self): - self.window().downloads_tab.initialize() - connect(self.window().downloads_tab.clicked_tab_button, self.on_downloads_tab_button_clicked) - connect(self.window().start_download_button.clicked, self.on_start_download_clicked) - connect(self.window().stop_download_button.clicked, self.on_stop_download_clicked) - connect(self.window().remove_download_button.clicked, self.on_remove_download_clicked) - connect(self.window().downloads_list.header().sectionResized, self.on_header_change) - connect(self.window().downloads_list.header().sortIndicatorChanged, self.on_header_change) - self.window().downloads_list.header().setContextMenuPolicy(Qt.CustomContextMenu) - connect(self.window().downloads_list.header().customContextMenuRequested, self.on_header_right_click_item) - connect(self.window().downloads_list.itemSelectionChanged, self.on_selection_change) - connect(self.window().downloads_list.customContextMenuRequested, self.on_right_click_item) - - self.window().download_details_widget.initialize_details_widget() - self.window().download_details_widget.hide() - - connect(self.window().downloads_filter_input.textChanged, self.on_filter_text_changed) - - state = self.window().gui_settings.value("downloads_header_state", None) - if state is None: - self.window().downloads_list.header().resizeSection(12, 146) - self.window().downloads_list.header().setSortIndicator(12, Qt.AscendingOrder) - else: - self.window().downloads_list.header().restoreState(state) - - self.background_refresh_downloads_timer.setSingleShot(True) - connect(self.background_refresh_downloads_timer.timeout, self.on_background_refresh_downloads_timer) - - def on_header_change(self, *args, **kwargs): - gui_settings = self.window().gui_settings - if gui_settings.value("downloads_header_state", None) is not None: - gui_settings.setValue( - "downloads_header_state", - self.window().downloads_list.header().saveState() - ) - - def on_filter_text_changed(self, text): - self.window().downloads_list.clearSelection() - self.window().download_details_widget.hide() - self.update_download_visibility() - - def start_loading_downloads(self): - self.window().downloads_list.setSelectionMode(QAbstractItemView.NoSelection) - self.loading_message_widget = LoadingDownloadWidgetItem() - self.loading_list_item = LoadingListItem(self.window().downloads_list) - self.window().downloads_list.addTopLevelItem(self.loading_message_widget) - self.window().downloads_list.setItemWidget(self.loading_message_widget, 2, self.loading_list_item) - - def schedule_downloads_refresh(self, interval_msec=REFRESH_DOWNLOADS_BACKGROUND_INTERVAL_MSEC): - timer = self.background_refresh_downloads_timer - remaining = timer.remainingTime() - if timer.isActive(): - interval_msec = min(remaining, interval_msec) - timer.start(interval_msec) - - def on_background_refresh_downloads_timer(self): - self.refresh_non_selected_downloads() - self.refresh_selected_download() - self.schedule_downloads_refresh() - - def stop_refreshing_downloads(self): - self.background_refresh_downloads_timer.stop() - - def refresh_selected_download(self): - details_widget = self.window().download_details_widget - index = details_widget.currentIndex() - - details_shown = not details_widget.isHidden() - selected_download = details_widget.current_download - - if not details_shown or selected_download is None: - return - - infohash = selected_download.get('infohash') - if not infohash: - return - - request_manager.get( - endpoint="downloads", - url_params={ - 'get_pieces': 1, - 'get_peers': int(index == DownloadDetailsTabs.PEERS), - 'get_availability': 1, - 'infohash': infohash - }, - on_success=self.on_received_selected_download, - ) - - files_list = details_widget.window().download_files_list - if index != DownloadDetailsTabs.FILES or files_list.pages[0].loaded: - return - - request_manager.get( - endpoint=f"downloads/{infohash}/files", - url_params={ - 'view_start_path': Path("."), - 'view_size': files_list.page_size - }, - on_success=files_list.fill_entries, - ) - - def refresh_non_selected_downloads(self): - url_params = {} - - details_widget = self.window().download_details_widget - details_shown = not details_widget.isHidden() - selected_download = details_widget.current_download - if details_shown and selected_download: - if infohash := selected_download.get('infohash'): - url_params['excluded'] = infohash - - request_manager.get( - endpoint="downloads", - url_params=url_params, - on_success=self.on_received_non_selected_downloads, - ) - - def on_received_selected_download(self, result): - """ - Process the result of the refresh request for the selected download. - """ - - if not result: - return - downloads = [d for d in result.get("downloads", []) if 'pieces' in d] - if not downloads or len(downloads) != 1: - self._logger.warning( - f'Received unexpected number of downloads ({len(downloads)} instead of 1), ignoring the result.' - ) - return - - download = downloads[0] - if download["infohash"] not in self.download_widgets: - self._logger.warning('Received infohash not in download_widgets, ignoring the result.') - return - - self.process_refresh_result(result) - - def on_received_non_selected_downloads(self, result): - """ - Process the result of the refresh request for the non-selected downloads. - """ - if not result or "downloads" not in result: - self._logger.warning("Received unexpected result, ignoring it.") - return # This might happen when closing Tribler - - request_id = result[REQUEST_ID] - if self.last_processed_request_id >= request_id: - # This is an old request, ignore it. - # It could happen because some of requests processed a bit longer than others - msg = f'Ignoring old request {request_id} (last processed request id: {self.last_processed_request_id})' - self._logger.warning(msg) - return - self.last_processed_request_id = request_id - - self.process_refresh_result(result) - - def process_refresh_result(self, result): - """ - Process the result of the refresh request. - """ - loading_widget_index = self.window().downloads_list.indexOfTopLevelItem(self.loading_message_widget) - if loading_widget_index > -1: - self.window().downloads_list.takeTopLevelItem(loading_widget_index) - self.window().downloads_list.setSelectionMode(QAbstractItemView.ExtendedSelection) - - self.total_download_speed = 0 - self.total_upload_speed = 0 - - infohash_set = set() - items = [] - for download in result["downloads"]: - infohash = download["infohash"] - self.window().core_manager.events_manager.node_info_updated.emit( - {"infohash": infohash, "progress": download["progress"]} - ) - - if infohash in self.download_widgets: - item = self.download_widgets[infohash] - else: - item = DownloadWidgetItem() - self.download_widgets[infohash] = item - items.append(item) - - item.update_with_download(download) - - self.total_download_speed += download["speed_down"] - self.total_upload_speed += download["speed_up"] - - infohash_set.add(infohash) - - current_download = self.window().download_details_widget.current_download - if current_download and current_download["infohash"] == infohash: - self.window().download_details_widget.update_with_download(download) - - self.window().downloads_list.addTopLevelItems(items) - for item in items: - self.window().downloads_list.setItemWidget(item, 2, item.bar_container) - - self.window().tray_set_tooltip( - f"Down: {format_speed(self.total_download_speed)}, Up: {format_speed(self.total_upload_speed)}" - ) - self.update_download_visibility() - self.refresh_top_panel() - - self.received_downloads.emit(result) - - def update_download_visibility(self): - for i in range(self.window().downloads_list.topLevelItemCount()): - item = self.window().downloads_list.topLevelItem(i) - if not isinstance(item, DownloadWidgetItem): - continue - - filter_match = self.window().downloads_filter_input.text().lower() in item.download_info["name"].lower() - filtered = DOWNLOADS_FILTER_DEFINITION[self.filter] - hide = item.get_status() not in filtered or not filter_match - item.setHidden(hide) - - def on_downloads_tab_button_clicked(self, button_name): - self.filter = button_name2filter[button_name] - - self.window().downloads_list.clearSelection() - self.window().download_details_widget.hide() - self.update_download_visibility() - - @staticmethod - def start_download_enabled(download_widgets): - return any(dw.get_status() == DownloadStatus.STOPPED for dw in download_widgets) - - @staticmethod - def stop_download_enabled(download_widgets): - stopped = {DownloadStatus.STOPPED, DownloadStatus.STOPPED_ON_ERROR} - return any(dw.get_status() not in stopped for dw in download_widgets) - - @staticmethod - def force_recheck_download_enabled(download_widgets): - recheck = {DownloadStatus.METADATA, DownloadStatus.HASHCHECKING, DownloadStatus.WAITING_FOR_HASHCHECK} - return any(dw.get_status() not in recheck for dw in download_widgets) - - def on_selection_change(self): - self.selected_items = self.window().downloads_list.selectedItems() - - # refresh bottom detailed info panel - if len(self.selected_items) == 1: - self.window().download_details_widget.update_with_download(self.selected_items[0].download_info) - self.window().download_details_widget.show() - self.refresh_selected_download() - else: - self.window().download_details_widget.hide() - - self.refresh_top_panel() - - def refresh_top_panel(self): - if len(self.selected_items) == 0: - self.window().remove_download_button.setEnabled(False) - self.window().start_download_button.setEnabled(False) - self.window().stop_download_button.setEnabled(False) - return - - self.window().remove_download_button.setEnabled(True) - self.window().start_download_button.setEnabled(DownloadsPage.start_download_enabled(self.selected_items)) - self.window().stop_download_button.setEnabled(DownloadsPage.stop_download_enabled(self.selected_items)) - - def on_start_download_clicked(self, checked): - for item in self.selected_items: - request_manager.patch( - f"downloads/{item.infohash}", - on_success=self.on_download_resumed, - data={"state": "resume"} - ) - - def find_item_in_selected(self, infohash) -> Optional[DownloadWidgetItem]: - return next((it for it in self.selected_items if it.infohash == infohash), None) - - def on_download_resumed(self, json_result): - if not json_result or 'modified' not in json_result: - return - - self.schedule_downloads_refresh(REFRESH_DOWNLOADS_UI_CHANGE_INTERVAL_MSEC) - - if item := self.find_item_in_selected(json_result["infohash"]): - item.update_item() - - def on_stop_download_clicked(self, checked): - for item in self.selected_items: - request_manager.patch( - f"downloads/{item.infohash}", - on_success=self.on_download_stopped, - data={"state": "stop"} - ) - - def on_download_stopped(self, json_result): - if not json_result or "modified" not in json_result: - return - - self.schedule_downloads_refresh(REFRESH_DOWNLOADS_UI_CHANGE_INTERVAL_MSEC) - - if item := self.find_item_in_selected(json_result["infohash"]): - item.update_item() - - def on_remove_download_clicked(self, checked): - self.dialog = ConfirmationDialog( - self, - tr("Remove download"), - tr("Are you sure you want to remove this download?"), - [ - (tr("remove download"), BUTTON_TYPE_NORMAL), - (tr("remove download + data"), BUTTON_TYPE_NORMAL), - (tr("cancel"), BUTTON_TYPE_CONFIRM), - ], - ) - connect(self.dialog.button_clicked, self.on_remove_download_dialog) - self.dialog.show() - - def on_remove_download_dialog(self, action): - if action != 2: - for item in self.selected_items: - current_download = self.window().download_details_widget.current_download - if current_download and current_download.get('infohash') == item.infohash: - self.window().download_details_widget.current_download = None - - request_manager.delete( - f"downloads/{item.infohash}", - on_success=self.on_download_removed, - data={"remove_data": bool(action)} - ) - - if self.dialog: - self.dialog.close_dialog() - self.dialog = None - - def on_download_removed(self, json_result): - if not json_result or "removed" not in json_result: - return - - infohash = json_result["infohash"] - - if current_download := self.window().download_details_widget.current_download: - if current_download["infohash"] == infohash: - self.window().download_details_widget.hide() - - if item := self.download_widgets.get(infohash): - index = self.window().downloads_list.indexOfTopLevelItem(item) - self.window().downloads_list.takeTopLevelItem(index) - del self.download_widgets[infohash] - - self.window().core_manager.events_manager.node_info_updated.emit({"infohash": infohash, "progress": None}) - - def on_force_recheck_download(self, checked): - for item in self.selected_items: - request_manager.patch( - f"downloads/{item.infohash}", - on_success=self.on_forced_recheck, - data={"state": "recheck"} - ) - - def on_forced_recheck(self, result): - if not result or "modified" not in result: - return - - self.schedule_downloads_refresh(REFRESH_DOWNLOADS_UI_CHANGE_INTERVAL_MSEC) - - if item := self.find_item_in_selected(result["infohash"]): - item.update_item() - - def on_change_anonymity(self, result): - pass - - def change_anonymity(self, hops): - for item in self.selected_items: - request_manager.patch( - f"downloads/{item.infohash}", - on_success=self.on_change_anonymity, - data={"anon_hops": hops} - ) - - def on_explore_files(self, checked): - # ACHTUNG! To whomever might stumble upon here intending to debug the case - # when this does not work on Linux: know, my friend, that for some mysterious reason - # (probably related to Snap disk access rights peculiarities), this DOES NOT work - # when you run Tribler from PyCharm. However, it works perfectly fine when you - # run Tribler directly from system console, etc. So, don't spend your time on debugging this, - # like I did. - for selected_item in self.selected_items: - path = os.path.normpath(selected_item.download_info["destination"]) - QDesktopServices.openUrl(QUrl.fromLocalFile(path)) - - def on_move_files(self, checked): - if len(self.selected_items) != 1: - return - - dest_dir = QFileDialog.getExistingDirectory( - self, - tr("Please select the destination directory"), - self.selected_items[0].download_info["destination"], - QFileDialog.ShowDirsOnly, - ) - if not dest_dir: - return - - _infohash = self.selected_items[0].infohash - _name = self.selected_items[0].download_info["name"] - - request_manager.patch( - f"downloads/{_infohash}", - on_success=lambda res: self.on_files_moved(res, _name, dest_dir), - data={"state": "move_storage", "dest_dir": dest_dir} - ) - - def on_files_moved(self, response, name, dest_dir): - if "modified" in response and response["modified"]: - self.window().tray_show_message(name, f"Moved to {dest_dir}") - - def on_export_download(self, checked): - self.export_dir = QFileDialog.getExistingDirectory( - self, tr("Please select the destination directory"), "", QFileDialog.ShowDirsOnly - ) - - selected_item = self.selected_items[:1] - if len(self.export_dir) > 0 and selected_item: - # Show confirmation dialog where we specify the name of the file - torrent_name = selected_item[0].download_info['name'] - self.dialog = ConfirmationDialog( - self, - tr("Export torrent file"), - tr("Please enter the name of the torrent file:"), - [(tr("SAVE"), BUTTON_TYPE_NORMAL), (tr("CANCEL"), BUTTON_TYPE_CONFIRM)], - show_input=True, - ) - self.dialog.dialog_widget.dialog_input.setPlaceholderText(tr("Torrent file name")) - self.dialog.dialog_widget.dialog_input.setText(f"{torrent_name}.torrent") - self.dialog.dialog_widget.dialog_input.setFocus() - connect(self.dialog.button_clicked, self.on_export_download_dialog_done) - self.dialog.show() - - def on_export_download_dialog_done(self, action): - def on_success(result: Tuple): - data, _ = result - self.on_export_download_request_done(filename, data) - - item = self.selected_items[0:1] - if action == 0 and item: - item = item[0] - filename = self.dialog.dialog_widget.dialog_input.text() - request_manager.get( - f"downloads/{item.infohash}/torrent", - on_success=on_success, - priority=QNetworkRequest.LowPriority, - raw_response=True - ) - - self.dialog.close_dialog() - self.dialog = None - - def on_export_download_request_done(self, filename, data): - dest_path = os.path.join(self.export_dir, filename) - try: - torrent_file = open(dest_path, "wb") - torrent_file.write(data) - torrent_file.close() - except OSError as exc: - ConfirmationDialog.show_error( - self.window(), - tr("Error when exporting file"), - tr("An error occurred when exporting the torrent file: %s") % str(exc), - ) - else: - self.window().tray_show_message( - tr("Torrent file exported"), tr("Torrent file exported to %s") % str(dest_path) - ) - - def show_all_headers(self, _=None): - for i in range(self.window().downloads_list.header().count()): - self.window().downloads_list.header().showSection(i) - - def on_header_right_click_item(self, pos): - index = self.window().downloads_list.header().logicalIndexAt(pos) - - menu = TriblerActionMenu(self) - if index > 0: # Do not show hide action on first column - hide_action = QAction("Hide Column", self) - connect( - hide_action.triggered, - lambda _: self.window().downloads_list.header().hideSection(index) - ) - menu.addAction(hide_action) - - restore_action = QAction("Restore All", self) - connect(restore_action.triggered, self.show_all_headers) - menu.addAction(restore_action) - - menu.exec_(self.window().downloads_list.mapToGlobal(pos)) - - def on_right_click_item(self, pos): - item_clicked = self.window().downloads_list.itemAt(pos) - if not item_clicked or not self.selected_items: - return - - if item_clicked not in self.selected_items: - self.selected_items.append(item_clicked) - - menu = TriblerActionMenu(self) - - start_action = QAction(tr("Start"), self) - stop_action = QAction(tr("Stop"), self) - remove_download_action = QAction(tr("Remove download"), self) - force_recheck_action = QAction(tr("Force recheck"), self) - export_download_action = QAction(tr("Export .torrent file"), self) - explore_files_action = QAction(tr("Explore files"), self) - move_files_action = QAction(tr("Move file storage"), self) - - no_anon_action = QAction(tr("No anonymity"), self) - one_hop_anon_action = QAction(tr("One hop"), self) - two_hop_anon_action = QAction(tr("Two hops"), self) - three_hop_anon_action = QAction(tr("Three hops"), self) - - connect(start_action.triggered, self.on_start_download_clicked) - start_action.setEnabled(DownloadsPage.start_download_enabled(self.selected_items)) - connect(stop_action.triggered, self.on_stop_download_clicked) - stop_action.setEnabled(DownloadsPage.stop_download_enabled(self.selected_items)) - connect(remove_download_action.triggered, self.on_remove_download_clicked) - connect(force_recheck_action.triggered, self.on_force_recheck_download) - force_recheck_action.setEnabled(DownloadsPage.force_recheck_download_enabled(self.selected_items)) - connect(export_download_action.triggered, self.on_export_download) - connect(explore_files_action.triggered, self.on_explore_files) - connect(move_files_action.triggered, self.on_move_files) - - connect(no_anon_action.triggered, lambda _: self.change_anonymity(0)) - connect(one_hop_anon_action.triggered, lambda _: self.change_anonymity(1)) - connect(two_hop_anon_action.triggered, lambda _: self.change_anonymity(2)) - connect(three_hop_anon_action.triggered, lambda _: self.change_anonymity(3)) - - menu.addAction(start_action) - menu.addAction(stop_action) - - menu.addSeparator() - menu.addAction(remove_download_action) - menu.addSeparator() - menu.addAction(force_recheck_action) - menu.addSeparator() - - exclude_states = [ - DownloadStatus.METADATA, - DownloadStatus.CIRCUITS, - DownloadStatus.EXIT_NODES, - DownloadStatus.HASHCHECKING, - DownloadStatus.WAITING_FOR_HASHCHECK, - ] - if len(self.selected_items) == 1 and self.selected_items[0].get_status() not in exclude_states: - menu.addAction(export_download_action) - menu.addAction(explore_files_action) - if len(self.selected_items) == 1: - menu.addAction(move_files_action) - menu.addSeparator() - - menu_anon_level = menu.addMenu(tr("Change Anonymity ")) - menu_anon_level.addAction(no_anon_action) - menu_anon_level.addAction(one_hop_anon_action) - menu_anon_level.addAction(two_hop_anon_action) - menu_anon_level.addAction(three_hop_anon_action) - - menu.exec_(self.window().downloads_list.mapToGlobal(pos)) diff --git a/src/tribler/gui/widgets/downloadwidgetitem.py b/src/tribler/gui/widgets/downloadwidgetitem.py deleted file mode 100644 index b0e93bafab..0000000000 --- a/src/tribler/gui/widgets/downloadwidgetitem.py +++ /dev/null @@ -1,143 +0,0 @@ -import logging -import math -from datetime import datetime -from typing import Dict, Optional - -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QProgressBar, QTreeWidgetItem, QVBoxLayout, QWidget - -from tribler.core.libtorrent.download_manager.download_state import DownloadStatus -from tribler.gui.defs import STATUS_STRING -from tribler.gui.utilities import duration_to_string, format_size, format_speed - - -class LoadingDownloadWidgetItem(QTreeWidgetItem): - """ - This class is used for the placeholder "Loading" item for the downloads list - """ - - def __init__(self): - QTreeWidgetItem.__init__(self) - self.setFlags(Qt.NoItemFlags) - - def get_raw_download_status(self): - return "PLACEHOLDER" - - -def create_progress_bar_widget() -> (QWidget, QProgressBar): - progress_slider = QProgressBar() - - bar_container = QWidget() - bar_container.setLayout(QVBoxLayout()) - bar_container.setStyleSheet("background-color: transparent;") - - # We have to set a zero pixel border to get the background working on Mac. - progress_slider.setStyleSheet( - """ - QProgressBar { - background-color: white; - color: black; - font-size: 12px; - text-align: center; - border: 0px solid transparent; - } - - QProgressBar::chunk { - background-color: #e67300; - } - """ - ) - - progress_slider.setAutoFillBackground(True) - bar_container.layout().addWidget(progress_slider) - bar_container.layout().setContentsMargins(4, 4, 8, 4) - return bar_container, progress_slider - - -class DownloadWidgetItem(QTreeWidgetItem): - """ - This class is responsible for managing the item in the downloads list and fills the item with the relevant data. - """ - - def __init__(self): - QTreeWidgetItem.__init__(self) - self.download_info: Optional[Dict] = None - self.infohash: Optional[str] = None - self._logger = logging.getLogger('TriblerGUI') - self.bar_container, self.progress_slider = create_progress_bar_widget() - - def update_with_download(self, download: Dict): - self.download_info = download - self.infohash = download["infohash"] - self.update_item() - - def get_status(self) -> DownloadStatus: - return DownloadStatus(self.download_info["status_code"]) - - def update_item(self): - self.setText(0, self.download_info["name"]) - - if self.download_info["size"] == 0 and self.get_status() == DownloadStatus.METADATA: - self.setText(1, "unknown") - else: - self.setText(1, format_size(float(self.download_info["size"]))) - - try: - self.progress_slider.setValue(int(self.download_info["progress"] * 100)) - except RuntimeError: - self._logger.error("The underlying GUI widget has already been removed.") - - if self.download_info["vod_mode"]: - self.setText(3, "Streaming") - else: - status = DownloadStatus(self.download_info["status_code"]) - status_string = STATUS_STRING[status] - self.setText(3, status_string) - self.setText(4, f"{self.download_info['num_connected_seeds']} ({self.download_info['num_seeds']})") - self.setText(5, f"{self.download_info['num_connected_peers']} ({self.download_info['num_peers']})") - self.setText(6, format_speed(self.download_info["speed_down"])) - self.setText(7, format_speed(self.download_info["speed_up"])) - - all_time_ratio = self.download_info['all_time_ratio'] - all_time_ratio = "∞" if all_time_ratio == math.inf else f'{all_time_ratio:.3f}' - self.setText(8, all_time_ratio) - - self.setText(9, "yes" if self.download_info["anon_download"] else "no") - self.setText(10, str(self.download_info["hops"]) if self.download_info["anon_download"] else "-") - self.setText(12, datetime.fromtimestamp(int(self.download_info["time_added"])).strftime('%Y-%m-%d %H:%M')) - - eta_text = "-" - if self.get_status() == DownloadStatus.DOWNLOADING: - eta_text = duration_to_string(self.download_info["eta"]) - self.setText(11, eta_text) - - def __lt__(self, other): - # The download info might not be available yet or there could still be loading QTreeWidgetItem - if not self.download_info or not isinstance(other, DownloadWidgetItem): - return True - elif not other.download_info: - return False - - column = self.treeWidget().sortColumn() - if column == 1: - return float(self.download_info["size"]) > float(other.download_info["size"]) - elif column == 2: - return int(self.download_info["progress"] * 100) > int(other.download_info["progress"] * 100) - elif column == 4: - return self.download_info["num_seeds"] > other.download_info["num_seeds"] - elif column == 5: - return self.download_info["num_peers"] > other.download_info["num_peers"] - elif column == 6: - return float(self.download_info["speed_down"]) > float(other.download_info["speed_down"]) - elif column == 7: - return float(self.download_info["speed_up"]) > float(other.download_info["speed_up"]) - elif column == 8: - return float(self.download_info["all_time_ratio"]) > float(other.download_info["all_time_ratio"]) - elif column == 11: - # Put finished downloads with an ETA of 0 after all other downloads - return (float(self.download_info["eta"]) or float('inf')) > ( - float(other.download_info["eta"]) or float('inf') - ) - elif column == 12: - return int(self.download_info["time_added"]) > int(other.download_info["time_added"]) - return self.text(column) > other.text(column) diff --git a/src/tribler/gui/widgets/ellipsebutton.py b/src/tribler/gui/widgets/ellipsebutton.py deleted file mode 100644 index 31ed17b6b3..0000000000 --- a/src/tribler/gui/widgets/ellipsebutton.py +++ /dev/null @@ -1,7 +0,0 @@ -from PyQt5.QtWidgets import QToolButton - - -class EllipseButton(QToolButton): - """ - Represents an ellipsoid button in the GUI. - """ diff --git a/src/tribler/gui/widgets/graphs/__init__.py b/src/tribler/gui/widgets/graphs/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/tribler/gui/widgets/graphs/dataplot.py b/src/tribler/gui/widgets/graphs/dataplot.py deleted file mode 100644 index 0bdbdf6066..0000000000 --- a/src/tribler/gui/widgets/graphs/dataplot.py +++ /dev/null @@ -1,17 +0,0 @@ -import pyqtgraph as pg -from pyqtgraph import DateAxisItem - -from tribler.gui.utilities import format_size -from tribler.gui.widgets.graphs.timeseriesplot import TimeSeriesPlot - - -class DataAxisItem(pg.AxisItem): - def tickStrings(self, values, scale, spacing): - return [format_size(value, precision=3) for value in values] - - -class TimeSeriesDataPlot(TimeSeriesPlot): - - def __init__(self, parent, name, series, **kargs): - axis_items = {'bottom': DateAxisItem('bottom'), 'left': DataAxisItem('left')} - super().__init__(parent, name, series, axis_items=axis_items, **kargs) diff --git a/src/tribler/gui/widgets/graphs/timeseriesplot.py b/src/tribler/gui/widgets/graphs/timeseriesplot.py deleted file mode 100644 index a907e3d215..0000000000 --- a/src/tribler/gui/widgets/graphs/timeseriesplot.py +++ /dev/null @@ -1,54 +0,0 @@ -import operator -import time - -import numpy as np -import pyqtgraph as pg -from pyqtgraph import DateAxisItem -from pyqtgraph.graphicsItems.DateAxisItem import YEAR_SPACING - -from tribler.gui.defs import BITTORRENT_BIRTHDAY - - -class TimeSeriesPlot(pg.PlotWidget): - - def __init__(self, parent, name, series, **kargs): - axis_items = kargs.pop('axis_items', {'bottom': DateAxisItem('bottom')}) - super().__init__(parent=parent, title=name, axisItems=axis_items, **kargs) - self.getPlotItem().showGrid(x=True, y=True) - self.setBackground('#202020') - self.setAntialiasing(True) - self.setMenuEnabled(False) - - self.plot_data = {} - self.plots = [] - self.series = series - self.last_timestamp = 0 - - legend = pg.LegendItem((150, 25 * len(series)), offset=(150, 30)) - legend.setParentItem(self.graphicsItem()) - - for serie in series: - plot = self.plot(**serie) - self.plots.append(plot) - legend.addItem(plot, serie['name']) - - # Limit the date range - self.setLimits(xMin=BITTORRENT_BIRTHDAY, xMax=time.time() + YEAR_SPACING) - - def setup_labels(self): - pass - - def reset_plot(self): - self.plot_data = {} - - def add_data(self, timestamp, data): - self.plot_data[timestamp] = data - - def render_plot(self): - # Sort the plot data before rendering via plot.setData() to prevent loops and extra lines in the graph. - self.plot_data = dict(sorted(self.plot_data.items(), key=operator.itemgetter(0))) - - for i, plot in enumerate(self.plots): - plot.setData( - x=np.array(list(self.plot_data.keys())), y=np.array([data[i] for data in self.plot_data.values()]) - ) diff --git a/src/tribler/gui/widgets/instanttooltipbutton.py b/src/tribler/gui/widgets/instanttooltipbutton.py deleted file mode 100644 index 6467eae715..0000000000 --- a/src/tribler/gui/widgets/instanttooltipbutton.py +++ /dev/null @@ -1,10 +0,0 @@ -from PyQt5.QtWidgets import QToolButton - - -class InstantTooltipButton(QToolButton): - """ - This class represents a button that immediately shows a tooltip when being hovered over. - Unfortunately, there are some issues in PyQt that makes it challenging to customize the background. - For example, the background is not correctly clipped, also see https://stackoverflow.com/questions/39120586. - Also, it seems that we are unable to modify the border-radius on tooltips without using a custom mask. - """ diff --git a/src/tribler/gui/widgets/instanttooltipstyle.py b/src/tribler/gui/widgets/instanttooltipstyle.py deleted file mode 100644 index d8d825aa0c..0000000000 --- a/src/tribler/gui/widgets/instanttooltipstyle.py +++ /dev/null @@ -1,15 +0,0 @@ -from PyQt5.QtWidgets import QProxyStyle, QStyle - -from tribler.gui.widgets.instanttooltipbutton import InstantTooltipButton - - -class InstantTooltipStyle(QProxyStyle): - """ - Proxy style to make sure that there is a zero tooltip delay for particular widgets. - Specifically used to implement InstantTooltipButton. - """ - - def styleHint(self, hint, option=None, widget=None, returnData=None): - if isinstance(widget, InstantTooltipButton) and hint == QStyle.SH_ToolTip_WakeUpDelay: - return 0 - return QProxyStyle.styleHint(self, hint, option, widget, returnData) diff --git a/src/tribler/gui/widgets/ipv8health.py b/src/tribler/gui/widgets/ipv8health.py deleted file mode 100644 index 60d09809de..0000000000 --- a/src/tribler/gui/widgets/ipv8health.py +++ /dev/null @@ -1,149 +0,0 @@ -import statistics -import threading -import time - -from PyQt5.QtCore import QTimer, Qt -from PyQt5.QtGui import QPainter -from PyQt5.QtWidgets import QWidget - -from tribler.gui.utilities import connect - - -class MonitorWidget(QWidget): - """ - An "ECG" plot of the IPv8 core update frequency. - - The updates (which we technically refer to as "boops") are measured by the IPv8 core itself. - Each boop will be scaled in height based on the additional drift over the core update frequency. - Each boop will be scaled in width to make sure they don't overlap in the display of the past 10 seconds. - 10 px under each boop the timestamp will be drawn. - - The mean and median of the core drift are shown in the upper left of the plot. - - Drawing enters on the right side of the screen. - Drawing finishes at -10% of the left side of the screen to make sure the boops are not cut off. - """ - - def __init__(self): - super().__init__() - self.is_paused = False - - self.update_lock = threading.Lock() - self.draw_times = [] - self.median_drift = "?" - self.mean_drift = "?" - self.walk_interval_target = "?" - - self.timer = QTimer() - connect(self.timer.timeout, self.repaint) - self.timer.start(33) - - self.backup_size = None - - def pause(self): - self.is_paused = True - self.timer.stop() - - def resume(self): - self.is_paused = False - self.set_history([]) - self.repaint() - self.timer.start(33) - - def set_history(self, history): - # We store/draw 11.0 seconds of history for 10.0 seconds of plot. - # This is so the boops don't visually pop out of existence on the left side. - # Instead, they smoothly run off screen. - with self.update_lock: - self.draw_times = [ - (entry["timestamp"], entry["drift"]) for entry in history if entry["timestamp"] > time.time() - 11.0 - ] - if self.draw_times: - drifts = [entry[1] for entry in self.draw_times] - self.median_drift = round(statistics.median(drifts), 5) - self.mean_drift = round(statistics.mean(drifts), 5) - if len(drifts) > 1: - self.walk_interval_target = round( - self.draw_times[-1][0] - self.draw_times[-2][0] - self.draw_times[-1][1], 4 - ) - else: - self.walk_interval_target = "?" - else: - self.median_drift = "?" - self.mean_drift = "?" - self.walk_interval_target = "?" - - def paintEvent(self, e): - painter = QPainter() - painter.begin(self) - self.custom_paint(painter) - painter.end() - - def custom_paint(self, painter): - size = self.size() - current_time = time.time() - - # Reduce flicker while performing the layout algorithm. - # If we were already loaded before and are now being resized, use the last known size. - if size.width() <= 1 or size.height() <= 1: - if self.backup_size is None: - return - size = self.backup_size - else: - self.backup_size = size - - # Draw the statistics - painter.setPen(Qt.white) - painter.drawText(0, 20, f" Target:\t{self.walk_interval_target}") - painter.drawText(0, 40, f" Mean:\t+{self.mean_drift}") - painter.drawText(0, 60, f" Median:\t+{self.median_drift}") - - # Draw the baseline frequency bands (a perfect score of 0.0 drift). - midy = (size.height() - 1) // 2 - painter.setPen(Qt.darkGray) - painter.drawLine(0, midy + 40, size.width() - 1, midy + 40) - painter.drawLine(0, midy - 50, size.width() - 1, midy - 50) - - # Draw the centerline. - painter.setPen(Qt.green) - painter.drawLine(0, midy, size.width() - 1, midy) - - # Calculate the required scaling. - time_window = 10.0 - x_time_start = current_time - time_window - boop_px = 60 - boop_secs = 0.25 - boop_xscale = size.width() / boop_px / time_window * boop_secs - - # Go through all core measurements and draw boops. - with self.update_lock: - for draw_time, drift in self.draw_times: - x = int((draw_time - x_time_start) / time_window * size.width()) - self.draw_boop(painter, size, x, boop_xscale, 1 + drift * 10, str(round(draw_time, 3))) - - def draw_boop(self, painter, size, x, xscale=1.0, yscale=1.0, label=""): - midy = (size.height() - 1) // 2 - - # Erase the centerline on the area we will be drawing. - painter.setPen(Qt.black) - painter.drawLine(x, midy, x + int(60 * xscale), midy) - painter.setPen(Qt.green) - - # The boop shape. - painter.drawLine(x, midy, x + int(5 * xscale), midy - int(10 * yscale)) - painter.drawLine(x + int(5 * xscale), midy - int(10 * yscale), x + int(10 * xscale), midy) - painter.drawLine(x + int(10 * xscale), midy, x + int(15 * xscale), midy) - painter.drawLine(x + int(15 * xscale), midy, x + int(20 * xscale), midy + int(10 * yscale)) - painter.drawLine(x + int(20 * xscale), midy + int(10 * yscale), x + int(30 * xscale), midy - int(50 * yscale)) - painter.drawLine(x + int(30 * xscale), midy - int(50 * yscale), x + int(50 * xscale), midy + int(40 * yscale)) - painter.drawLine(x + int(50 * xscale), midy + int(40 * yscale), x + int(60 * xscale), midy) - - # If required, rotate the label 90 degrees and draw it 10 px under the boop. - # This 10 px offset is scale independent. - if label: - painter.save() - painter.translate(x + int(30 * xscale), midy + 10 + int(40 * yscale)) - painter.rotate(90) - painter.setPen(Qt.white) - painter.drawText(0, 0, label) - painter.restore() diff --git a/src/tribler/gui/widgets/lazytableview.py b/src/tribler/gui/widgets/lazytableview.py deleted file mode 100644 index 28b478b405..0000000000 --- a/src/tribler/gui/widgets/lazytableview.py +++ /dev/null @@ -1,252 +0,0 @@ -import json -from typing import Dict, List - -from PyQt5.QtCore import QEvent, QModelIndex, QRect, QTimer, Qt, pyqtSignal -from PyQt5.QtGui import QGuiApplication, QMouseEvent, QMovie -from PyQt5.QtWidgets import QAbstractItemView, QApplication, QHeaderView, QLabel, QTableView - -from tribler.core.database.serialization import SNIPPET -from tribler.gui.dialogs.editmetadatadialog import EditMetadataDialog -from tribler.gui.network.request_manager import request_manager -from tribler.gui.utilities import connect, data_item2uri, get_image_path, index2uri -from tribler.gui.widgets.tablecontentdelegate import TriblerContentDelegate -from tribler.gui.widgets.tablecontentmodel import Column, EXPANDING - - -class FloatingAnimationWidget(QLabel): - def __init__(self, parent): - super().__init__(parent) - self.setGeometry(0, 0, 100, 100) - self.setAttribute(Qt.WA_TranslucentBackground) - - self.qm = QMovie(get_image_path("spinner.gif")) - self.setMovie(self.qm) - - def update_position(self): - if hasattr(self.parent(), 'viewport'): - parent_rect = self.parent().viewport().rect() - else: - parent_rect = self.parent().rect() - - if not parent_rect: - return - - x = parent_rect.width() / 2 - self.width() / 2 - y = parent_rect.height() / 2 - self.height() / 2 - self.setGeometry(int(x), int(y), self.width(), self.height()) - - def resizeEvent(self, event): - super().resizeEvent(event) - self.update_position() - - -class TriblerContentTableView(QTableView): - """ - This table view is designed to support lazy loading. - When the user reached the end of the table, it will ask the model for more items, and load them dynamically. - """ - - torrent_clicked = pyqtSignal(dict) - torrent_doubleclicked = pyqtSignal(dict) - edited_metadata = pyqtSignal(dict) - - def __init__(self, parent=None): - QTableView.__init__(self, parent) - self.add_tags_dialog = None - self.setMouseTracking(True) - - self.delegate = TriblerContentDelegate(self) - self.delegate.font_metrics = self.fontMetrics() # Required to estimate the height of a row. - - self.setItemDelegate(self.delegate) - connect(self.delegate.redraw_required, self.redraw) - - # Install an event filter on the horizontal header to catch mouse movements (so we can deselect rows). - self.horizontalHeader().installEventFilter(self) - - # Stop triggering editor events on doubleclick, because we already use doubleclick to start downloads. - # Editing should be started manually, from drop-down menu instead. - self.setEditTriggers(QAbstractItemView.NoEditTriggers) - - # Mix-in connects - connect(self.clicked, self.on_table_item_clicked) - connect(self.doubleClicked, lambda item: self.on_table_item_clicked(item, doubleclick=True)) - - self.loading_animation_widget = FloatingAnimationWidget(self) - - # We add a small delay to show the loading animation to avoid flickering on fast-loaded data - self.loading_animation_delay_timer = QTimer() - self.loading_animation_delay_timer.setSingleShot(True) - self.loading_animation_delay = 100 # Milliseconds - connect(self.loading_animation_delay_timer.timeout, self.show_loading_animation) - - self.hide_loading_animation() - - self.verticalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) - self.horizontalHeader().setFixedHeight(40) - - def show_loading_animation_delayed(self): - self.loading_animation_delay_timer.start(self.loading_animation_delay) - - def show_loading_animation(self): - self.loading_animation_widget.qm.start() - self.loading_animation_widget.setHidden(False) - - def hide_loading_animation(self): - self.loading_animation_delay_timer.stop() - self.loading_animation_widget.qm.stop() - self.loading_animation_widget.setHidden(True) - - def eventFilter(self, obj, event): - if obj == self.horizontalHeader() and event.type() == QEvent.HoverEnter: - # Deselect rows when the mouse leaves through the table view header. - self.deselect_all_rows() - return False - - def wheelEvent(self, event): - super().wheelEvent(event) - - # We trigger a mouse movement event to make sure that the whole row remains selected when scrolling. - index = QModelIndex(self.indexAt(event.pos())) - self.delegate.on_mouse_moved(event.pos(), index) - - def mousePressEvent(self, event: QMouseEvent) -> None: - should_select_row = True - index = self.indexAt(event.pos()) - model = index.model() - if not model: - return - data_item = model.data_items[index.row()] - if data_item["type"] == SNIPPET: - should_select_row = False - - if index != self.delegate.no_index: - # Check if we are clicking the 'edit tags' button - if index in model.edit_tags_rects: - rect = model.edit_tags_rects[index] - if rect.contains(event.pos()) and event.button() != Qt.RightButton: - should_select_row = False - self.on_edit_tags_clicked(index) - - # Check if we are clicking the 'popular content' button - if index in model.download_popular_content_rects: - for torrent_index, rect in enumerate(model.download_popular_content_rects[index]): - if rect.contains(event.pos()) and event.button() != Qt.RightButton: - should_select_row = False - self.on_download_popular_torrent_clicked(index, torrent_index) - - if should_select_row: - super().mousePressEvent(event) - - def deselect_all_rows(self): - """ - Deselect all rows in the table view. - """ - old_selected = self.delegate.hover_index - self.delegate.hover_index = self.delegate.no_index - self.redraw(old_selected, True) - - def leaveEvent(self, event): - """ - The mouse has left the viewport. Make sure that we deselect the currently selected row and redraw. - Note that this might fail when moving the mouse very fast. - """ - super().leaveEvent(event) - self.deselect_all_rows() - QApplication.restoreOverrideCursor() - self.delegate.on_mouse_left() - - def mouseMoveEvent(self, event): - index = QModelIndex(self.indexAt(event.pos())) - QApplication.restoreOverrideCursor() - self.delegate.on_mouse_moved(event.pos(), index) - - def redraw(self, index, redraw_whole_row): - """ - Redraw the cell at a particular index. - """ - if not self.model(): - return - if redraw_whole_row: - for col_ind in range(self.model().columnCount()): - index = self.model().index(index.row(), col_ind) - self.model().dataChanged.emit(index, index, []) - else: - self.model().dataChanged.emit(index, index, []) - - # This is required to drop the sensitivity zones of the controls, - # so there are no invisible controls left over from a previous state of the view - for control in self.delegate.controls: - control.rect = QRect() - - def on_edit_tags_clicked(self, index: QModelIndex) -> None: - self.add_tags_dialog = EditMetadataDialog(self.window(), index) - self.add_tags_dialog.show() - connect(self.add_tags_dialog.save_button_clicked, self.save_edited_metadata) - - def on_download_popular_torrent_clicked(self, index: QModelIndex, torrent_index: int) -> None: - data_item = index.model().data_items[index.row()] - self.start_download_from_dataitem(data_item["torrents_in_snippet"][torrent_index]) - - def on_table_item_clicked(self, item, doubleclick=False): - # We don't want to trigger the click-based events on, say, Ctrl-click based selection - if QGuiApplication.keyboardModifiers() != Qt.NoModifier: - return - # Skip emitting click event when the user clicked on some specific columns - column_position = self.model().column_position - if item.column() in ( - column_position.get(cname, False) - for cname in (Column.ACTIONS, Column.STATUS, Column.VOTES, Column.SUBSCRIBED, Column.HEALTH) - ): - return - - data_item = self.model().data_items[item.row()] - - if not doubleclick: - self.torrent_clicked.emit(data_item) - else: - self.torrent_doubleclicked.emit(data_item) - - def on_delete_button_clicked(self, _index): - self.model().delete_rows(self.selectionModel().selectedRows()) - - def on_move_button_clicked(self, _index): - self.model().delete_rows(self.selectionModel().selectedRows()) - - def resizeEvent(self, _): - if self.model() is None: - return - viewport_width = self.width() - for col_num, col in enumerate(self.model().columns): - self.setColumnWidth( - col_num, col.width if col.width != EXPANDING else viewport_width - self.model().min_columns_width - 20 - ) - self.loading_animation_widget.update_position() - - name_column_pos = self.model().column_position.get(Column.NAME) - self.model().name_column_width = self.columnWidth(name_column_pos) - - def start_download_from_index(self, index): - self.window().start_download_from_uri(index2uri(index)) - - def start_download_from_dataitem(self, data_item): - self.window().start_download_from_uri(data_item2uri(data_item)) - - def on_metadata_edited(self, index, statements: List[Dict]): - if self.add_tags_dialog: - self.add_tags_dialog.close_dialog() - self.add_tags_dialog = None - - data_item = self.model().data_items[index.row()] - data_item["statements"] = statements - self.redraw(index, True) - - self.edited_metadata.emit(data_item) - - def save_edited_metadata(self, index: QModelIndex, statements: List[Dict]): - def on_success(_): - self.on_metadata_edited(index, statements) - - data_item = self.model().data_items[index.row()] - request_manager.patch(f"knowledge/{data_item['infohash']}", on_success=on_success, - data=json.dumps({"statements": statements})) diff --git a/src/tribler/gui/widgets/loading_list_item.py b/src/tribler/gui/widgets/loading_list_item.py deleted file mode 100644 index 1130d1d0a9..0000000000 --- a/src/tribler/gui/widgets/loading_list_item.py +++ /dev/null @@ -1,18 +0,0 @@ -from PyQt5.QtWidgets import QWidget - -from tribler.gui.tribler_window import fc_loading_list_item - - -class LoadingListItem(QWidget, fc_loading_list_item): - """ - When data is loading, we show a list widget with some text. - """ - - def __init__(self, parent, label_text=None): - QWidget.__init__(self, parent) - fc_loading_list_item.__init__(self) - - self.setupUi(self) - - if label_text is not None: - self.textlabel.setText(label_text) diff --git a/src/tribler/gui/widgets/loadingpage.py b/src/tribler/gui/widgets/loadingpage.py deleted file mode 100644 index 264b453ef8..0000000000 --- a/src/tribler/gui/widgets/loadingpage.py +++ /dev/null @@ -1,41 +0,0 @@ -from PyQt5.QtSvg import QGraphicsSvgItem, QSvgRenderer -from PyQt5.QtWidgets import QGraphicsScene, QWidget - -from tribler.gui.utilities import connect, get_image_path - - -def load_gears_animation(): - svg_container = QGraphicsScene() - svg_item = QGraphicsSvgItem() - - svg = QSvgRenderer(get_image_path("loading_animation.svg")) - svg.repaintNeeded.connect(svg_item.update) - svg_item.setSharedRenderer(svg) - - svg_container.addItem(svg_item) - return svg_container - - -LOADING_ANIMATION = load_gears_animation() - - -class LoadingPage(QWidget): - """ - This page is presented when Tribler is starting. - """ - - def __init__(self): - QWidget.__init__(self) - self.loading_label = None - self.upgrading = False - - def initialize_loading_page(self): - self.window().loading_svg_view.setScene(LOADING_ANIMATION) - connect(self.window().core_manager.events_manager.change_loading_text, self.change_loading_text) - self.window().skip_conversion_btn.hide() - - # Hide the force shutdown button initially. Should be triggered by shutdown timer from main window. - self.window().force_shutdown_btn.hide() - - def change_loading_text(self, text): - self.window().loading_text_label.setText(text) diff --git a/src/tribler/gui/widgets/popular/__init__.py b/src/tribler/gui/widgets/popular/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/tribler/gui/widgets/popular/popular_torrents_model.py b/src/tribler/gui/widgets/popular/popular_torrents_model.py deleted file mode 100644 index 055c362290..0000000000 --- a/src/tribler/gui/widgets/popular/popular_torrents_model.py +++ /dev/null @@ -1,8 +0,0 @@ -from tribler.gui.widgets.tablecontentmodel import ChannelContentModel, Column - - -class PopularTorrentsModel(ChannelContentModel): - columns_shown = (Column.CATEGORY, Column.NAME, Column.SIZE, Column.HEALTH, Column.CREATED) - - def __init__(self, *args, **kwargs): - super().__init__(*args, endpoint_url="metadata/torrents/popular", **kwargs) diff --git a/src/tribler/gui/widgets/qtbug.py b/src/tribler/gui/widgets/qtbug.py deleted file mode 100644 index 7db4272715..0000000000 --- a/src/tribler/gui/widgets/qtbug.py +++ /dev/null @@ -1,16 +0,0 @@ -from PyQt5 import uic -from PyQt5.QtWidgets import QWidget - -from tribler.gui.utilities import get_ui_file_path - - -# This file is a result of a nasty QT bug that PREVENTS US from loading some custom -# widgets WITHOUT custom subwidgets. -# Total crazyness. -# I DARE you to delete this widget and save us all from this thing haunting us forever! - - -class QtBug(QWidget): - def __init__(self, parent): - QWidget.__init__(self, parent) - uic.loadUi(get_ui_file_path('qtbug.ui'), self) diff --git a/src/tribler/gui/widgets/search_progress_bar.py b/src/tribler/gui/widgets/search_progress_bar.py deleted file mode 100644 index b6747cae67..0000000000 --- a/src/tribler/gui/widgets/search_progress_bar.py +++ /dev/null @@ -1,104 +0,0 @@ -import time - -from PyQt5.QtCore import QTimer, pyqtSignal -from PyQt5.QtWidgets import QProgressBar - -from tribler.gui.utilities import connect - -MAX_VALUE = 10000 -UPDATE_DELAY = 0.5 -REMOTE_DELAY = 0.25 - - -class SearchProgressBar(QProgressBar): - ready_to_update_results = pyqtSignal() - - def __init__(self, parent=None, timeout=20): - super().__init__(parent) - self.timeout_interval = timeout - self.timer = QTimer() - self.timer.setSingleShot(False) - self.timer.setInterval(100) # update the progress bar tick - - self.start_time = None - self.last_update_time = None - self.last_remote_result_time = None - self.has_new_remote_results = False - self.peers_total = 0 - self.peers_responded = 0 - self.new_remote_items_count = 0 - self.total_remote_items_count = 0 - - self._value = 0 - self.setValue(0) - self.setMaximum(MAX_VALUE) - - connect(self.timer.timeout, self._update) - - def start(self): - t = time.time() - self.start_time = t - self.peers_total = 0 - self.peers_responded = 0 - self.setToolTip('') - self.setValue(0) - self.timer.start() - self.show() - - def _update(self): - if self.start_time is None: - return - - t = time.time() - - time_progress = (t - self.start_time) / self.timeout_interval - response_progress = (self.peers_responded / self.peers_total) if self.peers_total else 0 - scale = 1 - ((1 - time_progress) * (1 - response_progress)) ** 2 - value = int(scale * MAX_VALUE) - self.setValue(value) - - timeout = time_progress >= 1 - most_peers_responded = self.peers_total > 0 and self.peers_responded / self.peers_total >= 0.8 - active_transfers_finished = self.last_remote_result_time and t - self.last_remote_result_time > REMOTE_DELAY - - should_stop = timeout or (most_peers_responded and active_transfers_finished) - - if self.last_update_time is not None and self.has_new_remote_results \ - and (t - self.last_update_time > UPDATE_DELAY and active_transfers_finished or should_stop): - self.last_update_time = t - self.has_new_remote_results = False - self.new_remote_items_count = 0 - self.ready_to_update_results.emit() - - if should_stop: - self.stop() - - def stop(self): - self.start_time = None - self.timer.stop() - self.hide() - - def mousePressEvent(self, _): - self.stop() - - def on_local_results(self): - self.last_update_time = time.time() - self.has_new_remote_results = False - self._update() - - def set_remote_total(self, total: int): - self.peers_total = total - self.setToolTip(f'0/{total} remote responded') - self._update() - - def on_remote_results(self, new_items_count, peers_responded): - self.last_remote_result_time = time.time() - tool_tip = f'{peers_responded}/{self.peers_total} peers responded' - if self.total_remote_items_count: - tool_tip += f', {self.total_remote_items_count} new results' - self.setToolTip(tool_tip) - self.has_new_remote_results = True - self.new_remote_items_count += new_items_count - self.total_remote_items_count += new_items_count - self.peers_responded = peers_responded - self._update() diff --git a/src/tribler/gui/widgets/search_results_model.py b/src/tribler/gui/widgets/search_results_model.py deleted file mode 100644 index 640ba0477b..0000000000 --- a/src/tribler/gui/widgets/search_results_model.py +++ /dev/null @@ -1,128 +0,0 @@ -from tribler.core.database.serialization import SNIPPET -from tribler.core.knowledge.content_bundling import calculate_diversity, group_content_by_number -from tribler.gui.widgets.tablecontentmodel import ChannelContentModel, get_item_uid - - -class SearchResultsModel(ChannelContentModel): - def __init__(self, original_query, **kwargs): - self.original_query = original_query - self.remote_results = {} - title = self.format_title() - kwargs["text_filter"] = original_query - super().__init__(channel_info={"name": title}, **kwargs) - self.remote_results_received = False - self.postponed_remote_results = [] - self.highlight_remote_results = True - self.sort_by_rank = True - self.original_search_results = [] - - def format_title(self): - q = self.original_query - q = q if len(q) < 50 else q[:50] + "..." - return f"Search results for {q}" - - def perform_initial_query(self): - return self.perform_query(first=1, last=200) - - def on_query_results(self, response, remote=False, on_top=False): - super().on_query_results(response, remote=remote, on_top=on_top) - self.add_remote_results([]) # to trigger adding postponed results - self.show_remote_results() - - @property - def all_local_entries_loaded(self): - return self.loaded - - def add_remote_results(self, results): - if not self.all_local_entries_loaded: - self.postponed_remote_results.extend(results) - return [] - - results = self.postponed_remote_results + results - self.postponed_remote_results = [] - new_items = [] - for item in results: - uid = get_item_uid(item) - if uid not in self.item_uid_map and uid not in self.remote_results: - self.remote_results_received = True - new_items.append(item) - self.remote_results[uid] = item - return new_items - - def show_remote_results(self): - if not self.all_local_entries_loaded: - return - - remote_items = list(self.remote_results.values()) - self.remote_results.clear() - self.remote_results_received = False - if remote_items: - self.add_items(remote_items, remote=True) - - def create_bundles(self, content_list: list[dict], filter_zero_seeders=True, min_bundle_size=3) -> list[dict]: - """ - Create bundles from the content list. Each bundle contains at least min_bundle_size items. - - :param content_list: list of content items - :param filter_zero_seeders: if True, items with zero seeders are filtered out - :param min_bundle_size: minimum number of items in a bundle - :returns: list of content items and bundles - """ - diversity = calculate_diversity(content_list) - self._logger.info(f"Diversity: {diversity}") - if diversity > 6: # 6 is a threshold found empirically - self._logger.info("Diversity is higher than 6. Bundling is disabled.") - return content_list - groups = group_content_by_number(content_list, min_bundle_size) - - result = [] - torrents_in_bundles = set() - for name, group in groups.items(): - if filter_zero_seeders: - group = [item for item in group if item.get("num_seeders", 0) > 0] - if len(group) < min_bundle_size: - continue - - bundle = { - "category": "", - "infohash": name, - "name": name, - "torrents": len(group), - "torrents_in_snippet": group, - "type": SNIPPET, - } - result.append(bundle) - torrents_in_bundles.update(t.get("infohash") for t in group) - - content_not_in_bundles = (item for item in content_list if item.get("infohash") not in torrents_in_bundles) - result.extend(content_not_in_bundles) - return result - - def add_items(self, new_items: list[dict], on_top=False, remote=False) -> None: - """ - Adds new items to the table model. All items are mapped to their unique ids to avoid the duplicates. - New items are prepended to the end of the model. - Note that item_uid_map tracks items twice: once by public_key+id and once by infohash. This is necessary to - support status updates from TorrentChecker based on infohash only. - - :param new_items: list(item) - :param on_top: True if new_items should be added on top of the table - :param remote: True if new_items are from a remote peer. Default: False - :return: None - """ - if not new_items: - return - unique_new_items, _ = self.extract_unique_new_items(new_items, on_top, remote) - if remote: - self.original_search_results = self.original_search_results + unique_new_items - if self.sort_by_rank: - self.original_search_results.sort(key=lambda item: item["rank"], reverse=True) - items = self.create_bundles(self.original_search_results) - else: - self.original_search_results = unique_new_items - items = self.create_bundles(unique_new_items) - - self.beginResetModel() - self.data_items = items - self.item_uid_map = self.create_uid_map(items) - self.endResetModel() \ No newline at end of file diff --git a/src/tribler/gui/widgets/searchresultswidget.py b/src/tribler/gui/widgets/searchresultswidget.py deleted file mode 100644 index 199f25921f..0000000000 --- a/src/tribler/gui/widgets/searchresultswidget.py +++ /dev/null @@ -1,151 +0,0 @@ -import logging -import time -import uuid -from dataclasses import dataclass, field - -from PyQt5 import uic - -from tribler.core.database.serialization import REGULAR_TORRENT -from tribler.gui.network.request_manager import request_manager -from tribler.core.database.queries import Query -from tribler.gui.utilities import connect, get_ui_file_path, tr -from tribler.gui.widgets.search_results_model import SearchResultsModel - -widget_form, widget_class = uic.loadUiType(get_ui_file_path('search_results.ui')) - - -def format_search_loading_label(search_request): - data = { - "total_peers": len(search_request.peers), - "num_complete_peers": len(search_request.peers_complete), - "num_remote_results": len(search_request.remote_results), - } - - return ( - tr( - "Remote responses: %(num_complete_peers)i / %(total_peers)i" - "\nNew remote results received: %(num_remote_results)i" - ) - % data - ) - - -@dataclass -class SearchRequest: - uuid: uuid - query: Query - peers: set - peers_complete: set = field(default_factory=set) - remote_results: list = field(default_factory=list) - - @property - def complete(self): - return self.peers == self.peers_complete - - -class SearchResultsWidget(widget_form, widget_class): - def __init__(self, parent=None): - widget_class.__init__(self, parent=parent) - self._logger = logging.getLogger(self.__class__.__name__) - - try: - self.setupUi(self) - except SystemError: - pass - - self.last_search_time = None - self.last_search_query = None - self.hide_xxx = None - self.search_request = None - - connect(self.results_page_content.model_query_completed, self.on_local_query_completed) - connect(self.search_progress_bar.ready_to_update_results, self.on_ready_to_update_results) - - def initialize(self, hide_xxx=False): - self.hide_xxx = hide_xxx - self.results_page_content.initialize_content_page(hide_xxx=hide_xxx) - - @property - def has_results(self): - return self.last_search_query is not None - - def check_can_show(self, query): - if ( - self.last_search_query == query - and self.last_search_time is not None - and time.time() - self.last_search_time < 1 - ): - self._logger.info("Same search query already sent within 500ms so dropping this one") - return False - return True - - def search(self, query: Query) -> bool: - if not self.check_can_show(query.original_query): - return False - - if not query.fts_text: - return False - - self.last_search_query = query.fts_text - self.last_search_time = time.time() - - model = SearchResultsModel( - endpoint_url="metadata/search/local", - hide_xxx=self.results_page_content.hide_xxx, - original_query=query.original_query, - fts_text=query.fts_text, - tags=list(query.tags), - type_filter=[REGULAR_TORRENT], - exclude_deleted=True, - ) - self.results_page_content.initialize_root_model(model) - self.setCurrentWidget(self.results_page) - self.results_page_content.format_search_title() - self.search_progress_bar.start() - - # After transitioning to the page with search results, we refresh the viewport since some rows might have been - # rendered already with an incorrect row height. - self.results_page_content.run_brain_dead_refresh() - - def register_request(response): - peers = set(response["peers"]) - self.search_request = SearchRequest(response["request_uuid"], query, peers) - self.search_progress_bar.set_remote_total(len(peers)) - - request_manager.put( - endpoint='search/remote', - on_success=register_request, - url_params={ - 'hide_xxx': self.hide_xxx, - 'tags': list(query.tags), - 'original_query': query.original_query, - 'fts_text': query.fts_text, - 'metadata_type': REGULAR_TORRENT, - 'exclude_deleted': True - } - ) - - return True - - def on_local_query_completed(self): - self.search_progress_bar.on_local_results() - - def reset(self): - if self.currentWidget() == self.results_page: - self.results_page_content.go_back_to_level(0) - - def update_loading_page(self, remote_results): - if not self.search_request or self.search_request.uuid != remote_results.get("uuid"): - return - - peer = remote_results["peer"] - results = remote_results.get("results", []) - - self.search_request.peers_complete.add(peer) - self.search_request.remote_results.append(results) - - new_items = self.results_page_content.model.add_remote_results(results) - self.search_progress_bar.on_remote_results(len(new_items), len(self.search_request.peers_complete)) - - def on_ready_to_update_results(self): - self.results_page_content.root_model.show_remote_results() diff --git a/src/tribler/gui/widgets/settingspage.py b/src/tribler/gui/widgets/settingspage.py deleted file mode 100644 index d86b4425c8..0000000000 --- a/src/tribler/gui/widgets/settingspage.py +++ /dev/null @@ -1,343 +0,0 @@ -import json -import logging - -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QFileDialog, QSizePolicy, QWidget - -from tribler.gui.defs import ( - DARWIN, - PAGE_SETTINGS_ANONYMITY, - PAGE_SETTINGS_BANDWIDTH, - PAGE_SETTINGS_CONNECTION, - PAGE_SETTINGS_DEBUG, - PAGE_SETTINGS_GENERAL, - PAGE_SETTINGS_SEEDING, -) -from tribler.gui.dialogs.confirmationdialog import ConfirmationDialog -from tribler.gui.network.request_manager import request_manager -from tribler.gui.utilities import ( - connect, - get_gui_setting, - is_dir_writable, - seconds_to_hhmm_string, - string_to_seconds, -) -from tribler.tribler_config import DEFAULT_CONFIG - - -class SettingsPage(QWidget): - """ - This class is responsible for displaying and adjusting the settings present in Tribler. - """ - - settings_edited = pyqtSignal() - - def __init__(self): - QWidget.__init__(self) - self.logger = logging.getLogger(self.__class__.__name__) - - self.settings = None - - def initialize_settings_page(self): - if DARWIN: - self.window().minimize_to_tray_checkbox.setHidden(True) - self.window().settings_tab.initialize() - connect(self.window().settings_tab.clicked_tab_button, self.clicked_tab_button) - connect(self.window().settings_save_button.clicked, self.save_settings) - - connect(self.window().download_location_chooser_button.clicked, self.on_choose_download_dir_clicked) - connect(self.window().watch_folder_chooser_button.clicked, self.on_choose_watch_dir_clicked) - - connect(self.window().developer_mode_enabled_checkbox.stateChanged, self.on_developer_mode_checkbox_changed) - connect(self.window().use_monochrome_icon_checkbox.stateChanged, self.on_use_monochrome_icon_checkbox_changed) - connect(self.window().minimize_to_tray_checkbox.stateChanged, self.on_minimize_to_tray_changed) - connect(self.window().download_settings_anon_checkbox.stateChanged, self.on_anon_download_state_changed) - - self.update_stacked_widget_height() - - def showEvent(self, *args): - super().showEvent(*args) - self.window().settings_tab.process_button_click(self.window().settings_general_button) - - def on_developer_mode_checkbox_changed(self, _): - self.window().gui_settings.setValue("debug", self.window().developer_mode_enabled_checkbox.isChecked()) - self.window().debug_panel_button.setHidden(not self.window().developer_mode_enabled_checkbox.isChecked()) - - def on_use_monochrome_icon_checkbox_changed(self, _): - use_monochrome_icon = self.window().use_monochrome_icon_checkbox.isChecked() - self.window().gui_settings.setValue("use_monochrome_icon", use_monochrome_icon) - self.window().update_tray_icon(use_monochrome_icon) - - def on_minimize_to_tray_changed(self, _): - minimize_to_tray = self.window().minimize_to_tray_checkbox.isChecked() - self.window().gui_settings.setValue("minimize_to_tray", minimize_to_tray) - - def on_anon_download_state_changed(self, _): - if self.window().download_settings_anon_checkbox.isChecked(): - self.window().download_settings_anon_seeding_checkbox.setChecked(True) - self.window().download_settings_anon_seeding_checkbox.setEnabled( - not self.window().download_settings_anon_checkbox.isChecked() - ) - - def on_choose_download_dir_clicked(self, checked): - previous_download_path = self.window().download_location_input.text() or "" - download_dir = QFileDialog.getExistingDirectory( - self.window(), "Please select the download location", previous_download_path, QFileDialog.ShowDirsOnly - ) - - if not download_dir: - return - - self.window().download_location_input.setText(download_dir) - - def on_choose_watch_dir_clicked(self, checked): - if self.window().watchfolder_enabled_checkbox.isChecked(): - previous_watch_dir = self.window().watchfolder_location_input.text() or "" - watch_dir = QFileDialog.getExistingDirectory( - self.window(), "Please select the watch folder", previous_watch_dir, QFileDialog.ShowDirsOnly - ) - - if not watch_dir: - return - - self.window().watchfolder_location_input.setText(watch_dir) - - def initialize_with_settings(self, settings): - if not settings: - return - self.settings = settings = settings["settings"] - gui_settings = self.window().gui_settings - down_default_settings = settings['libtorrent']['download_defaults'] - - self.window().settings_stacked_widget.show() - self.window().settings_tab.show() - - # General settings - self.window().use_monochrome_icon_checkbox.setChecked( - get_gui_setting(gui_settings, "use_monochrome_icon", False, is_bool=True) - ) - self.window().minimize_to_tray_checkbox.setChecked( - get_gui_setting(gui_settings, "minimize_to_tray", False, is_bool=True) - ) - self.window().download_location_input.setText(down_default_settings['saveas']) - self.window().always_ask_location_checkbox.setChecked( - get_gui_setting(gui_settings, "ask_download_settings", True, is_bool=True) - ) - self.window().download_settings_anon_checkbox.setChecked(down_default_settings['anonymity_enabled']) - self.window().download_settings_anon_seeding_checkbox.setChecked(down_default_settings['safeseeding_enabled']) - - # Tags settings - self.window().disable_tags_checkbox.setChecked( - get_gui_setting(gui_settings, "disable_tags", True, is_bool=True) - ) - - # The header state of the downloads table - if gui_settings.value("downloads_header_state", None) is not None: - self.window().downloads_header_state_checkbox.setChecked(True) - - # Connection settings - self.window().lt_proxy_type_combobox.setCurrentIndex(settings['libtorrent']['proxy_type']) - if settings['libtorrent']['proxy_server']: - proxy_server = settings['libtorrent']['proxy_server'].split(":") - self.window().lt_proxy_server_input.setText(proxy_server[0]) - self.window().lt_proxy_port_input.setText(proxy_server[1]) - if settings['libtorrent']['proxy_auth']: - proxy_auth = settings['libtorrent']['proxy_auth'].split(":") - self.window().lt_proxy_username_input.setText(proxy_auth[0]) - self.window().lt_proxy_password_input.setText(proxy_auth[1]) - self.window().lt_utp_checkbox.setChecked(settings['libtorrent']['utp']) - - max_conn_download = settings['libtorrent']['max_connections_download'] - if max_conn_download == -1: - max_conn_download = 0 - self.window().max_connections_download_input.setText(str(max_conn_download)) - - # Bandwidth settings - self.window().upload_rate_limit_input.setText(str(settings['libtorrent']['max_upload_rate'] // 1024)) - self.window().download_rate_limit_input.setText(str(settings['libtorrent']['max_download_rate'] // 1024)) - - # Seeding settings - getattr(self.window(), "seeding_" + down_default_settings['seeding_mode'] + "_radio").setChecked(True) - self.window().seeding_time_input.setText(seconds_to_hhmm_string(down_default_settings['seeding_time'])) - ind = self.window().seeding_ratio_combobox.findText(str(down_default_settings['seeding_ratio'])) - if ind != -1: - self.window().seeding_ratio_combobox.setCurrentIndex(ind) - - # Anonymity settings - self.window().number_hops_slider.setValue(int(down_default_settings['number_hops'])) - - # Debug - self.window().developer_mode_enabled_checkbox.setChecked( - get_gui_setting(gui_settings, "debug", False, is_bool=True) - ) - self.window().checkbox_enable_network_statistics.setChecked(settings['statistics']) - - self.window().settings_stacked_widget.setCurrentIndex(0) - - def load_settings(self): - self.window().settings_stacked_widget.hide() - self.window().settings_tab.hide() - request_manager.get("settings", self.initialize_with_settings) - - def clicked_tab_button(self, tab_button_name): - if tab_button_name == "settings_general_button": - self.window().settings_stacked_widget.setCurrentIndex(PAGE_SETTINGS_GENERAL) - elif tab_button_name == "settings_connection_button": - self.window().settings_stacked_widget.setCurrentIndex(PAGE_SETTINGS_CONNECTION) - elif tab_button_name == "settings_bandwidth_button": - self.window().settings_stacked_widget.setCurrentIndex(PAGE_SETTINGS_BANDWIDTH) - elif tab_button_name == "settings_seeding_button": - self.window().settings_stacked_widget.setCurrentIndex(PAGE_SETTINGS_SEEDING) - elif tab_button_name == "settings_anonymity_button": - self.window().settings_stacked_widget.setCurrentIndex(PAGE_SETTINGS_ANONYMITY) - elif tab_button_name == "settings_debug_button": - self.window().settings_stacked_widget.setCurrentIndex(PAGE_SETTINGS_DEBUG) - - self.update_stacked_widget_height() - - def update_stacked_widget_height(self): - """ - Update the height of the settings tab. This is required since the height of a QStackedWidget is by default - the height of the largest page. This messes up the scroll bar. - """ - for index in range(self.window().settings_stacked_widget.count()): - if index == self.window().settings_stacked_widget.currentIndex(): - self.window().settings_stacked_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) - else: - self.window().settings_stacked_widget.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) - - self.window().settings_stacked_widget.adjustSize() - - def save_settings(self, checked): - # Create a dictionary with all available settings - settings_data = DEFAULT_CONFIG - settings_data['libtorrent']['download_defaults']['saveas'] = self.window().download_location_input.text() - settings_data['libtorrent']['proxy_type'] = self.window().lt_proxy_type_combobox.currentIndex() - - if ( - self.window().lt_proxy_server_input.text() - and len(self.window().lt_proxy_server_input.text()) > 0 - and len(self.window().lt_proxy_port_input.text()) > 0 - ): - try: - settings_data['libtorrent']['proxy_server'] = "{}:{}".format( - self.window().lt_proxy_server_input.text(), - int(self.window().lt_proxy_port_input.text()), - ) - except ValueError: - ConfirmationDialog.show_error( - self.window(), - "Invalid proxy port number", - "You've entered an invalid format for the proxy port number. Please enter a whole number.", - ) - return - else: - settings_data['libtorrent']['proxy_server'] = ":" - - username = self.window().lt_proxy_username_input.text() - password = self.window().lt_proxy_password_input.text() - if username and password: - settings_data['libtorrent']['proxy_auth'] = f"{username}:{password}" - else: - settings_data['libtorrent']['proxy_auth'] = ":" - - settings_data['libtorrent']['utp'] = self.window().lt_utp_checkbox.isChecked() - - try: - max_conn_download = int(self.window().max_connections_download_input.text()) - except ValueError: - ConfirmationDialog.show_error( - self.window(), - "Invalid number of connections", - "You've entered an invalid format for the maximum number of connections. " - "Please enter a whole number." - ) - return - if max_conn_download == 0: - max_conn_download = -1 - settings_data['libtorrent']['max_connections_download'] = max_conn_download - - try: - if self.window().upload_rate_limit_input.text(): - user_upload_rate_limit = int(float(self.window().upload_rate_limit_input.text()) * 1024) - if user_upload_rate_limit < 2147483647: - settings_data['libtorrent']['max_upload_rate'] = user_upload_rate_limit - else: - raise ValueError - if self.window().download_rate_limit_input.text(): - user_download_rate_limit = int(float(self.window().download_rate_limit_input.text()) * 1024) - if user_download_rate_limit < 2147483647: - settings_data['libtorrent']['max_download_rate'] = user_download_rate_limit - else: - raise ValueError - except ValueError: - ConfirmationDialog.show_error( - self.window(), - "Invalid value for bandwidth limit", - "You've entered an invalid value for the maximum upload/download rate. \n" - "The rate is specified in KB/s and the value permitted is between 0 and 2097151 KB/s.\n" - "Note that the decimal values are truncated." - ) - return - - seeding_modes = ['forever', 'time', 'never', 'ratio'] - selected_mode = 'forever' - for seeding_mode in seeding_modes: - if getattr(self.window(), "seeding_" + seeding_mode + "_radio").isChecked(): - selected_mode = seeding_mode - break - settings_data['libtorrent']['download_defaults']['seeding_mode'] = selected_mode - settings_data['libtorrent']['download_defaults']['seeding_ratio'] = float(self.window().seeding_ratio_combobox.currentText()) - - try: - settings_data['libtorrent']['download_defaults']['seeding_time'] = string_to_seconds( - self.window().seeding_time_input.text() - ) - except ValueError: - ConfirmationDialog.show_error( - self.window(), - "Invalid seeding time", - "You've entered an invalid format for the seeding time (expected HH:MM)" - ) - return - - settings_data['tunnel_community']['exitnode_enabled'] = False - settings_data['libtorrent']['download_defaults']['number_hops'] = self.window().number_hops_slider.value() - settings_data['libtorrent']['download_defaults']['anonymity_enabled'] = self.window().download_settings_anon_checkbox.isChecked() - settings_data['libtorrent']['download_defaults']['safeseeding_enabled'] = self.window().download_settings_anon_seeding_checkbox.isChecked() - - # network statistics - settings_data['statistics'] = self.window().checkbox_enable_network_statistics.isChecked() - - # In case the default save dir has changed, add it to the top of the list of last download locations. - # Otherwise, the user could absentmindedly click through the download dialog and start downloading into - # the last used download dir, and not into the newly designated default download dir. - if self.settings['libtorrent']['download_defaults']['saveas'] != settings_data['libtorrent']['download_defaults']['saveas']: - self.window().update_recent_download_locations(settings_data['libtorrent']['download_defaults']['saveas']) - self.settings = settings_data - request_manager.post("settings", self.on_settings_saved, data=json.dumps(settings_data)) - - def on_settings_saved(self, data): - if not data: - return - # Now save the GUI settings - gui_settings = self.window().gui_settings - - gui_settings.setValue("disable_tags", self.window().disable_tags_checkbox.isChecked()) - gui_settings.setValue("ask_download_settings", self.window().always_ask_location_checkbox.isChecked()) - gui_settings.setValue("use_monochrome_icon", self.window().use_monochrome_icon_checkbox.isChecked()) - gui_settings.setValue("minimize_to_tray", self.window().minimize_to_tray_checkbox.isChecked()) - if self.window().downloads_header_state_checkbox.isChecked(): - gui_settings.setValue("downloads_header_state", self.window().downloads_list.header().saveState()) - else: - gui_settings.remove("downloads_header_state") - self.window().tray_show_message("Tribler settings", "Settings saved") - - def on_receive_settings(response): - settings = response['settings'] - - self.window().tribler_settings = settings - - request_manager.get("settings", on_receive_settings, capture_errors=False) - - self.settings_edited.emit() diff --git a/src/tribler/gui/widgets/tabbuttonpanel.py b/src/tribler/gui/widgets/tabbuttonpanel.py deleted file mode 100644 index 5693e22391..0000000000 --- a/src/tribler/gui/widgets/tabbuttonpanel.py +++ /dev/null @@ -1,44 +0,0 @@ -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QWidget - -from tribler.gui.utilities import connect - - -class TabButtonPanel(QWidget): - """ - This class manages the tab button panels that can often be found above pages. - """ - - clicked_tab_button = pyqtSignal(str) - - def __init__(self, parent): - QWidget.__init__(self, parent) - self.buttons = [] - - def initialize(self): - for button in self.findChildren(QWidget): - self.buttons.append(button) - connect(button.clicked_tab_button, self.on_tab_button_click) - - def on_tab_button_click(self, clicked_button): - self.process_button_click(clicked_button) - - def process_button_click(self, button): - """ This method is called when a button is clicked.""" - self.deselect_all_buttons(except_select=button) - self.clicked_tab_button.emit(button.objectName()) - - def deselect_all_buttons(self, except_select=None): - for button in self.buttons: - if button == except_select: - button.setEnabled(False) - continue - button.setEnabled(True) - button.setChecked(False) - except_select.setChecked(True) - - def get_selected_index(self): - for index, button in enumerate(self.buttons): - if button.isChecked(): - return index - return -1 diff --git a/src/tribler/gui/widgets/tablecontentdelegate.py b/src/tribler/gui/widgets/tablecontentdelegate.py deleted file mode 100644 index 5a1afbf599..0000000000 --- a/src/tribler/gui/widgets/tablecontentdelegate.py +++ /dev/null @@ -1,771 +0,0 @@ -from math import floor -from typing import Dict -from sys import platform - -from PyQt5.QtCore import QEvent, QModelIndex, QObject, QPointF, QRect, QRectF, QSize, Qt, pyqtSignal -from PyQt5.QtGui import QBrush, QColor, QCursor, QFont, QIcon, QPainter, QPainterPath, QPalette, QPen -from PyQt5.QtWidgets import QApplication, QComboBox, QStyle, QStyleOptionViewItem, QStyledItemDelegate, QToolTip - -from tribler.core.database.layers.knowledge import ResourceType -from tribler.core.database.serialization import COLLECTION_NODE, REGULAR_TORRENT, SNIPPET -from tribler.gui.defs import ( - COMMIT_STATUS_COMMITTED, - COMMIT_STATUS_NEW, - COMMIT_STATUS_TODELETE, - COMMIT_STATUS_UPDATED, - ContentCategories, - HEALTH_CHECKING, - HEALTH_DEAD, - HEALTH_ERROR, - HEALTH_GOOD, - HEALTH_MOOT, - HEALTH_UNCHECKED, - TAG_BACKGROUND_COLOR, - TAG_BORDER_COLOR, - TAG_HEIGHT, - TAG_HORIZONTAL_MARGIN, - TAG_TEXT_COLOR, - TAG_TEXT_HORIZONTAL_PADDING, - TAG_TOP_MARGIN, -) -from tribler.gui.utilities import get_color, get_gui_setting, get_health, get_image_path, \ - get_objects_with_predicate, tr, format_size, pretty_date -from tribler.gui.widgets.tablecontentmodel import Column, RemoteTableModel -from tribler.gui.widgets.tableiconbuttons import DownloadIconButton - -PROGRESS_BAR_BACKGROUND = QColor("#444444") -PROGRESS_BAR_FOREGROUND = QColor("#BBBBBB") -TRIBLER_NEUTRAL = QColor("#B5B5B5") -TRIBLER_ORANGE = QColor("#e67300") -TRIBLER_PALETTE = QPalette() -TRIBLER_PALETTE.setColor(QPalette.Highlight, TRIBLER_ORANGE) - -DEFAULT_ROW_HEIGHT = 30 -CONTENT_ROW_HEIGHT = 72 -TORRENT_IN_SNIPPET_HEIGHT = 28 -MAX_TAGS_TO_SHOW = 10 - - -def draw_text( - painter, rect, text, color=TRIBLER_NEUTRAL, font=None, - text_flags=Qt.AlignLeft | Qt.AlignVCenter | Qt.TextSingleLine -): - painter.save() - text_box = painter.boundingRect(rect, text_flags, text) - painter.setPen(QPen(color, 1, Qt.SolidLine, Qt.RoundCap)) - if font: - painter.setFont(font) - - painter.drawText(text_box, text_flags, text) - painter.restore() - - -def draw_progress_bar(painter, rect, progress=0.0): - painter.save() - - outer_margin = 2 - bar_height = 16 - p = painter - r = rect - - x = r.x() + outer_margin - y = r.y() + (r.height() - bar_height) // 2 - h = bar_height - - # Draw background rect - w_border = r.width() - 2 * outer_margin - bg_rect = QRect(x, y, w_border, h) - background_color = PROGRESS_BAR_BACKGROUND - p.setPen(background_color) - p.setBrush(background_color) - p.drawRect(bg_rect) - - w_progress = int((r.width() - 2 * outer_margin) * progress) - progress_rect = QRect(x, y, w_progress, h) - foreground_color = PROGRESS_BAR_FOREGROUND - p.setPen(foreground_color) - p.setBrush(foreground_color) - p.drawRect(progress_rect) - - # Draw border rect over the bar rect - - painter.setCompositionMode(QPainter.CompositionMode_Difference) - p.setPen(TRIBLER_PALETTE.light().color()) - font = p.font() - p.setFont(font) - p.drawText(bg_rect, Qt.AlignCenter, f"{str(floor(progress * 100))}%") - - painter.restore() - - -class CheckClickedMixin: - def check_clicked(self, event, _, __, index): - model = index.model() - data_item = model.data_items[index.row()] - if column_position := model.column_position.get(self.column_name) is None: - return False - attribute_name = model.columns[column_position].dict_key - if ( - event.type() == QEvent.MouseButtonRelease - and column_position == index.column() - and data_item.get(attribute_name, '') != '' - ): - self.clicked.emit(index) - return True - return False - - def size_hint(self, _, __): - return self.size - - def on_mouse_moved(self, pos, index): - model = index.model() - if self.last_index != index: - # Handle the case when the cursor leaves the table - if not model or (model.column_position.get(self.column_name, -1) == index.column()): - self.last_index = index - - -class TriblerButtonsDelegate(QStyledItemDelegate): - redraw_required = pyqtSignal(QModelIndex, bool) - - def __init__(self, parent=None): - QStyledItemDelegate.__init__(self, parent) - self.no_index = QModelIndex() - self.hover_index = self.no_index - self.controls = [] - self.column_drawing_actions = [] - self.font_metrics = None - - self.hovering_over_tag_edit_button: bool = False - self.hovering_over_download_popular_torrent_button: int = -1 - - # We have to control if mouse is in the buttons box to add some tolerance for vertical mouse - # misplacement around the buttons. The button box effectively overlaps upper and lower rows. - # row 0 - # --------- <- tolerance zone - # row 1 |buttons| - # --------- <- tolerance zone - # row 2 - # button_box_extended_border_ration controls the thickness of the tolerance zone - self.button_box = QRect() - self.button_box_extended_border_ratio = float(1.0) - - def get_bool_gui_setting(self, setting_name: str, default: bool = False): - """ - Get a particular boolean GUI setting. - The reason why this is a separate method is that there are some additional checks that need to be done - when accessing the GUI settings in the window. - """ - try: - return get_gui_setting(self.table_view.window().gui_settings, setting_name, False, is_bool=True) - except AttributeError: - # It could happen that the window is unloaded, e.g., when closing down Tribler. - return default - - def sizeHint(self, _, index: QModelIndex) -> QSize: - """ - Estimate the height of the row. This is mostly dependent on the tags attached to each item. - """ - data_item = index.model().data_items[index.row()] - - if data_item["type"] == SNIPPET: - torrents_in_snippet = len(data_item["torrents_in_snippet"]) - height = CONTENT_ROW_HEIGHT + TORRENT_IN_SNIPPET_HEIGHT * torrents_in_snippet - return QSize(0, height) - - tags_disabled = self.get_bool_gui_setting("disable_tags") - if data_item["type"] != REGULAR_TORRENT or tags_disabled: - return QSize(0, DEFAULT_ROW_HEIGHT) - - name_column_width = index.model().name_column_width - cur_tag_x = 6 - cur_tag_y = TAG_TOP_MARGIN - - for tag_text in get_objects_with_predicate(data_item, ResourceType.TAG)[:MAX_TAGS_TO_SHOW]: - text_width = self.font_metrics.horizontalAdvance(tag_text) - tag_box_width = text_width + 2 * TAG_TEXT_HORIZONTAL_PADDING - - # Check whether this tag is going to overflow - if cur_tag_x + tag_box_width >= name_column_width: - cur_tag_x = 6 - cur_tag_y += TAG_HEIGHT + 10 - - cur_tag_x += tag_box_width + TAG_HORIZONTAL_MARGIN - - # Account for the 'edit tags' button - if cur_tag_x + TAG_HEIGHT >= name_column_width: - cur_tag_y += TAG_HEIGHT + 10 - - return QSize(0, cur_tag_y + TAG_HEIGHT + 10) - - def paint_empty_background(self, painter, option): - super().paint(painter, option, self.no_index) - - def on_mouse_moved(self, pos, index): - # This method controls for which rows the buttons/box should be drawn - if self.hover_index != index: - self.hover_index = index - if not self.button_box.contains(pos): - # Hide the tooltip when cell hover changes - QToolTip.hideText() - - # Check if we hover over the 'edit tags' button - new_hovering_state = False - if ( - self.hover_index != self.no_index - and self.hover_index.column() == index.model().column_position[Column.NAME] - ): - if index in index.model().edit_tags_rects: - rect = index.model().edit_tags_rects[index] - if rect.contains(pos): - QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) - new_hovering_state = True - - if new_hovering_state != self.hovering_over_tag_edit_button: - self.redraw_required.emit(index, False) - self.hovering_over_tag_edit_button = new_hovering_state - - # Check if we are hovering over the download popular torrents button - new_hovering_state = -1 - if ( - self.hover_index != self.no_index - and self.hover_index.column() == index.model().column_position[Column.NAME] - ): - if index in index.model().download_popular_content_rects: - for ind, rect in enumerate(index.model().download_popular_content_rects[index]): - if rect.contains(pos): - QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) - new_hovering_state = ind - - if new_hovering_state != self.hovering_over_download_popular_torrent_button: - self.redraw_required.emit(index, False) - self.hovering_over_download_popular_torrent_button = new_hovering_state - - for controls in self.controls: - controls.on_mouse_moved(pos, index) - - def on_mouse_left(self) -> None: - self.hovering_over_tag_edit_button = False - self.hovering_over_download_popular_torrent_button = -1 - - @staticmethod - def split_rect_into_squares(r, buttons): - x_border = 2 - side_size = min(r.width() // len(buttons), r.height() - x_border) - y_border = (r.height() - side_size) // 2 - x_start = r.left() + (r.width() - len(buttons) * side_size) // 2 # Center the squares horizontally - for n, button in enumerate(buttons): - x = x_start + n * side_size - y = r.top() + y_border - h = side_size - w = side_size - yield QRect(x, y, w, h), button - - def paint(self, painter, option, index): - model: RemoteTableModel = index.model() - data_item = model.data_items[index.row()] - if index.row() == self.hover_index.row() or model.should_highlight_item(data_item): - # Draw 'hover' state highlight for every cell of a row - option.state |= QStyle.State_MouseOver - if not self.paint_exact(painter, option, index, data_item): - # Draw the rest of the columns - super().paint(painter, option, index) - - def paint_exact(self, painter, option, index, data_item): - for column, drawing_action in self.column_drawing_actions: - if column in index.model().column_position and index.column() == index.model().column_position[column]: - return drawing_action(painter, option, index, data_item) - return False - - def editorEvent(self, event, model, option, index): - for control in self.controls: - result = control.check_clicked(event, model, option, index) - if result: - return result - return False - - def createEditor(self, parent, option, index): - # Add null editor to action buttons column - if index.column() == index.model().column_position[Column.ACTIONS]: - return - if index.column() == index.model().column_position[Column.CATEGORY]: - cbox = QComboBox(parent) - cbox.addItems(ContentCategories.codes) - return cbox - - return super().createEditor(parent, option, index) - - -class TagsMixin: - edit_tags_icon = QIcon(get_image_path("edit_white.png")) - edit_tags_icon_hover = QIcon(get_image_path("edit_orange.png")) - - def draw_title_and_tags(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex, - data_item: Dict) -> None: - debug = False # change to True to see the search rank of items and to highlight remote items - item_name = data_item["name"] - - if debug: - rank = data_item.get("rank") - if rank is not None: - item_name += f' rank: {rank:.6}' - if data_item.get('remote'): - item_name = '* ' + item_name - painter.setRenderHint(QPainter.Antialiasing, True) - title_text_pos = option.rect.topLeft() - title_text_height = 60 if data_item["type"] == SNIPPET else 28 - title_text_y = (title_text_pos.y() + 10) if data_item["type"] == SNIPPET else title_text_pos.y() - title_text_x = (title_text_pos.x() + 56) if data_item["type"] == SNIPPET else (title_text_pos.x() + 6) - painter.setPen(Qt.white) - - if data_item["type"] == SNIPPET: - # Increase the font size - font = painter.font() - font.setPixelSize(17) - painter.setFont(font) - - painter.drawText( - QRectF(title_text_x, title_text_y, option.rect.width() - 6, title_text_height), - Qt.AlignVCenter, - item_name, - ) - - if data_item["type"] == SNIPPET: - # Restore the font size - font = painter.font() - font.setPixelSize(13) - painter.setFont(font) - - # Draw the thumbnail + preview item if it's a content item - if data_item["type"] == SNIPPET: - painter.setPen(QColor(get_color(data_item["name"]))) - path = QPainterPath() - rect = QRectF(option.rect.x(), option.rect.topLeft().y() + 10, 40, 60) - path.addRect(rect) - painter.fillPath(path, QColor(get_color(data_item["name"]))) - painter.drawPath(path) - - # Draw the text in the thumbnail - font = painter.font() - font.setPixelSize(22) - painter.setFont(font) - painter.setPen(QColor("#ffffff")) - painter.drawText( - QRectF(rect.x(), rect.y(), rect.width(), rect.height()), - Qt.AlignCenter, - data_item["name"][0].capitalize(), - ) - font = painter.font() - font.setPixelSize(13) - painter.setFont(font) - - snippets_y = option.rect.topLeft().y() + 60 - - font = painter.font() - font.setPixelSize(15) - painter.setFont(font) - - index.model().download_popular_content_rects[index] = [] - for torrent_ind, torrent_in_snippet in enumerate(data_item["torrents_in_snippet"]): - is_hovering = self.hovering_over_download_popular_torrent_button == torrent_ind and \ - self.hover_index == index - painter.setPen(QColor(QColor(TRIBLER_ORANGE) if is_hovering else "#ccc")) - - torrent_in_snippet_rect = QRectF(title_text_x, snippets_y, option.rect.width() - 6, - TORRENT_IN_SNIPPET_HEIGHT) - painter.drawText( - torrent_in_snippet_rect, - Qt.AlignVCenter, - torrent_in_snippet["name"], - ) - - index.model().download_popular_content_rects[index].append(torrent_in_snippet_rect) - snippets_y += TORRENT_IN_SNIPPET_HEIGHT - - font = painter.font() - font.setPixelSize(13) - painter.setFont(font) - - cur_tag_x = option.rect.x() + 6 - cur_tag_y = option.rect.y() + TAG_TOP_MARGIN - - tags_disabled = self.get_bool_gui_setting("disable_tags") - if data_item["type"] != REGULAR_TORRENT or tags_disabled: - return - - edit_tags_button_hovered = self.hovering_over_tag_edit_button and self.hover_index == index - - # If there are no tags (yet), ask the user to add some tags - if len(get_objects_with_predicate(data_item, ResourceType.TAG)) == 0: - no_tags_text = tr("Be the first to suggest tags!") - painter.setPen(QColor(TRIBLER_ORANGE) if edit_tags_button_hovered else QColor("#aaa")) - text_width = painter.fontMetrics().horizontalAdvance(no_tags_text) - edit_tags_rect = QRectF(title_text_pos.x() + 6, title_text_pos.y() + 34, text_width + 4, 28) - index.model().edit_tags_rects[index] = edit_tags_rect - painter.drawText(edit_tags_rect, no_tags_text) - return - - for tag_text in get_objects_with_predicate(data_item, ResourceType.TAG)[:MAX_TAGS_TO_SHOW]: - text_width = painter.fontMetrics().horizontalAdvance(tag_text) - tag_box_width = text_width + 2 * TAG_TEXT_HORIZONTAL_PADDING - - # Check whether this tag is going to overflow to the next row - if cur_tag_x + tag_box_width >= option.rect.x() + option.rect.width(): - cur_tag_x = option.rect.x() + 6 - cur_tag_y += TAG_HEIGHT + 10 - - # Draw tag - painter.setPen(TAG_BORDER_COLOR) - path = QPainterPath() - rect = QRectF(cur_tag_x, cur_tag_y, tag_box_width, TAG_HEIGHT) - path.addRoundedRect(rect, TAG_HEIGHT // 2, TAG_HEIGHT // 2) - painter.fillPath(path, TAG_BACKGROUND_COLOR) - painter.drawPath(path) - - painter.setPen(Qt.white) - text_pos = rect.topLeft() + QPointF( - TAG_TEXT_HORIZONTAL_PADDING, - painter.fontMetrics().ascent() + ((rect.height() - painter.fontMetrics().height()) // 2) - 1, - ) - painter.setPen(TAG_TEXT_COLOR) - painter.drawText(text_pos, tag_text) - - cur_tag_x += rect.width() + TAG_HORIZONTAL_MARGIN - - # Draw the 'edit tags' button - if cur_tag_x + TAG_HEIGHT >= option.rect.x() + option.rect.width(): - cur_tag_x = option.rect.x() + 6 - cur_tag_y += TAG_HEIGHT + 10 - - edit_rect = QRect(int(cur_tag_x + 4), int(cur_tag_y), int(TAG_HEIGHT), int(TAG_HEIGHT)) - index.model().edit_tags_rects[index] = edit_rect - - if edit_tags_button_hovered: - self.edit_tags_icon_hover.paint(painter, edit_rect) - else: - self.edit_tags_icon.paint(painter, edit_rect) - - -def calculate_panel_y(y: int, index: int) -> int: - return y + 60 + TORRENT_IN_SNIPPET_HEIGHT // 2 + TORRENT_IN_SNIPPET_HEIGHT * index - 6 - - -class SnippetSizeColumnMixin: - def draw_size(self, painter: QPainter, option: QStyleOptionViewItem, _, data_item: Dict) -> None: - if data_item.get('type') != SNIPPET: - return - - for index, torrent in enumerate(data_item["torrents_in_snippet"]): - painter.save() - panel_y = calculate_panel_y(option.rect.topLeft().y(), index) - text_box = QRect(option.rect.left() + 5, panel_y + 5, 0, 0) - txt = format_size(torrent['size']) - draw_text(painter, text_box, txt, color=TRIBLER_NEUTRAL) - painter.restore() - - -class SnippetCreatedColumnMixin: - def draw_created(self, painter: QPainter, option: QStyleOptionViewItem, _, data_item: Dict) -> None: - if data_item.get('type') != SNIPPET: - return - - for index, torrent in enumerate(data_item["torrents_in_snippet"]): - painter.save() - panel_y = calculate_panel_y(option.rect.topLeft().y(), index) - text_box = QRect(option.rect.left() + 5, panel_y + 5, 0, 0) - txt = pretty_date(torrent['created']) - draw_text(painter, text_box, txt, color=TRIBLER_NEUTRAL) - painter.restore() - - -class RatingControlMixin: - def draw_rating_control(self, painter, option, index, data_item): - # Draw empty cell as the background - self.paint_empty_background(painter, option) - - return True - - -class CategoryLabelMixin: - def draw_category_label(self, painter, option, index, data_item): - # Draw empty cell as the background - self.paint_empty_background(painter, option) - - if 'type' in data_item and data_item['type'] == COLLECTION_NODE: - category_txt = "\U0001F4C1" # 'folder' emoji - else: - # Precautions to safely draw wrong category descriptions - category = ContentCategories.get(data_item['category']) - category_txt = category.emoji if category else '' - - CategoryLabel(category_txt).paint(painter, option, index, draw_border=False) - return True - - -class DownloadControlsMixin: - def draw_download_controls(self, painter, option, index, data_item): - # Draw empty cell as the background - self.paint_empty_background(painter, option) - - border_thickness = 2 - bordered_rect = QRect( - option.rect.left() + border_thickness, - option.rect.top() + border_thickness, - option.rect.width() - 2 * border_thickness, - option.rect.height() - 2 * border_thickness, - ) - # When cursor leaves the table, we must "forget" about the button_box - if self.hover_index.row() == -1: - self.button_box = QRect() - - progress = data_item.get('progress') - if progress is not None: - if int(progress) == 1.0: - draw_text(painter, bordered_rect, text="✔", text_flags=Qt.AlignCenter | Qt.TextSingleLine) - else: - draw_progress_bar(painter, bordered_rect, progress=progress) - return True - - if index.row() == self.hover_index.row(): - extended_border_height = int(option.rect.height() * self.button_box_extended_border_ratio) - button_box_extended_rect = option.rect.adjusted(0, -extended_border_height, 0, extended_border_height) - self.button_box = button_box_extended_rect - - active_buttons = [b for b in self.ondemand_container if b.should_draw(index)] - if active_buttons: - for rect, button in TriblerButtonsDelegate.split_rect_into_squares( - button_box_extended_rect, active_buttons - ): - button.paint(painter, rect, index) - return True - - -class HealthLabelMixin: - def draw_health_column(self, painter, option, index, data_item: dict): - # Draw empty cell as the background - self.paint_empty_background(painter, option) - if data_item.get('type') != SNIPPET: - hover = index == self.hover_index - else: - hover = False - self.health_status_widget.paint(painter, option.rect, index, hover) - - return True - - -class TriblerContentDelegate( - TriblerButtonsDelegate, - CategoryLabelMixin, - RatingControlMixin, - DownloadControlsMixin, - HealthLabelMixin, - TagsMixin, - SnippetSizeColumnMixin, - SnippetCreatedColumnMixin, -): - def __init__(self, table_view, parent=None): - TriblerButtonsDelegate.__init__(self, parent) - - self.download_button = DownloadIconButton() - self.ondemand_container = [self.download_button] - - self.commit_control = CommitStatusControl(Column.STATUS) - self.health_status_widget = HealthStatusControl(Column.HEALTH) - self.controls = [ - self.download_button, - self.health_status_widget, - ] - self.column_drawing_actions = [ - (Column.NAME, self.draw_title_and_tags), - (Column.VOTES, self.draw_rating_control), - (Column.ACTIONS, self.draw_action_column), - (Column.CATEGORY, self.draw_category_label), - (Column.HEALTH, self.draw_health_column), - (Column.STATUS, self.draw_commit_status_column), - (Column.SIZE, self.draw_size), - (Column.CREATED, self.draw_created), - ] - self.table_view = table_view - - def draw_action_column(self, painter, option, index, data_item): - if data_item['type'] == REGULAR_TORRENT: - return self.draw_download_controls(painter, option, index, data_item) - return False - - def draw_commit_status_column(self, painter, option, index, _): - # Draw empty cell as the background - self.paint_empty_background(painter, option) - self.commit_control.paint(painter, option.rect, index, hover=index == self.hover_index) - return True - - -class CategoryLabel(QObject): - """ - A label that indicates the category of some metadata. - """ - - def __init__(self, category, parent=None): - QObject.__init__(self, parent=parent) - self.category = category - - def paint(self, painter, option, _, draw_border=True): - painter.save() - - lines = QPen(QColor("#B5B5B5"), 1, Qt.SolidLine, Qt.RoundCap) - painter.setPen(lines) - - text_flags = Qt.AlignHCenter | Qt.AlignVCenter | Qt.TextSingleLine - text_box = painter.boundingRect(option.rect, text_flags, self.category) - - if platform == "linux": - # On Linux, the default font sometimes does not contain the emoji characters. - current_font = painter.font() - painter.setFont(QFont("Noto Color Emoji")) - painter.drawText(text_box, text_flags, self.category) - painter.setFont(current_font) - else: - painter.drawText(text_box, text_flags, self.category) - - if draw_border: - bezel_thickness = 4 - bezel_box = QRect( - text_box.left() - bezel_thickness, - text_box.top() - bezel_thickness, - text_box.width() + bezel_thickness * 2, - text_box.height() + bezel_thickness * 2, - ) - - painter.setRenderHint(QPainter.Antialiasing) - painter.drawRoundedRect(bezel_box, 20, 80, mode=Qt.RelativeSize) - - painter.restore() - - -class CommitStatusControl(QObject, CheckClickedMixin): - # Column-level controls are stateless collections of methods for visualizing cell data and - # triggering corresponding events. - icon_border = 4 - icon_size = 16 - h = icon_size + 2 * icon_border - w = h - size = QSize(w, h) - - clicked = pyqtSignal(QModelIndex) - new_icon = QIcon(get_image_path("plus.svg")) - committed_icon = QIcon(get_image_path("check.svg")) - todelete_icon = QIcon(get_image_path("minus.svg")) - updated_icon = QIcon(get_image_path("update.svg")) - - restore_action_icon = QIcon(get_image_path("undo.svg")) - - def __init__(self, column_name, parent=None): - QObject.__init__(self, parent=parent) - self.column_name = column_name - self.rect = QRect() - self.last_index = QModelIndex() - - def paint(self, painter, rect, index, hover=False): - data_item = index.model().data_items[index.row()] - column_key = index.model().columns[index.model().column_position[self.column_name]].dict_key - if data_item.get(column_key, '') == '': - return - state = data_item[column_key] - icon = QIcon() - - if state == COMMIT_STATUS_COMMITTED: - icon = self.committed_icon - elif state == COMMIT_STATUS_NEW: - icon = self.new_icon - elif state == COMMIT_STATUS_TODELETE: - icon = self.todelete_icon - elif state == COMMIT_STATUS_UPDATED: - icon = self.updated_icon - - x = rect.left() + (rect.width() - self.w) // 2 - y = rect.top() + (rect.height() - self.h) // 2 - icon_rect = QRect(x, y, self.w, self.h) - - icon.paint(painter, icon_rect) - self.rect = rect - - -class HealthStatusDisplay(QObject): - indicator_side = 10 - indicator_border = 6 - health_colors = { - HEALTH_GOOD: QColor(Qt.green), - HEALTH_DEAD: QColor(Qt.red), - HEALTH_MOOT: QColor(Qt.yellow), - HEALTH_UNCHECKED: QColor("#B5B5B5"), - HEALTH_CHECKING: QColor(Qt.yellow), - HEALTH_ERROR: QColor(Qt.red), - } - - def paint(self, painter, rect, index, hover=False): - data_item = index.model().data_items[index.row()] - - # ---------------- - # |b---b| | - # |b|i|b| 0S 0L | - # |b---b| | - # ---------------- - - if data_item["type"] == REGULAR_TORRENT: - if 'health' not in data_item or data_item['health'] == "updated": - data_item['health'] = get_health( - data_item['num_seeders'], data_item['num_leechers'], data_item['last_tracker_check'] - ) - health = data_item['health'] - - panel_y = rect.y() + rect.height() // 2 - 5 - self.paint_elements(painter, rect, panel_y, health, data_item, hover) - elif data_item["type"] == SNIPPET: - for ind, torrent_in_snippet in enumerate(data_item["torrents_in_snippet"]): - panel_y = calculate_panel_y(rect.topLeft().y(), ind) - health = get_health(torrent_in_snippet['num_seeders'], torrent_in_snippet['num_leechers'], - torrent_in_snippet['last_tracker_check']) - self.paint_elements(painter, rect, panel_y, health, torrent_in_snippet, hover, draw_health_text=True) - - def paint_elements(self, painter, rect, panel_y, health, data_item, hover=False, draw_health_text=True): - painter.save() - - # Indicator ellipse rectangle - y = panel_y # int(r.top() + (r.height() - self.indicator_side) // 2) - x = rect.left() + self.indicator_border - w = self.indicator_side - h = self.indicator_side - indicator_rect = QRect(x, y, w, h) - - # Paint indicator - painter.setBrush(QBrush(self.health_colors[health])) - painter.setPen(QPen(self.health_colors[health], 0, Qt.SolidLine, Qt.RoundCap)) - painter.drawEllipse(indicator_rect) - - x = indicator_rect.left() + indicator_rect.width() + 2 * self.indicator_border - y = panel_y - w = rect.width() - indicator_rect.width() - 2 * self.indicator_border - h = 10 - text_box = QRect(x, y, w, h) - - # Paint status text, if necessary - if draw_health_text: - if health in (HEALTH_CHECKING, HEALTH_UNCHECKED, HEALTH_ERROR): - txt = health - else: - seeders = int(data_item['num_seeders']) - leechers = int(data_item['num_leechers']) - - txt = 'S' + str(seeders) + ' L' + str(leechers) - - color = TRIBLER_PALETTE.light().color() if hover else TRIBLER_NEUTRAL - draw_text(painter, text_box, txt, color=color) - painter.restore() - - -class HealthStatusControl(HealthStatusDisplay, CheckClickedMixin): - clicked = pyqtSignal(QModelIndex) - - def __init__(self, column_name, parent=None): - QObject.__init__(self, parent=parent) - self.column_name = column_name - self.last_index = QModelIndex() diff --git a/src/tribler/gui/widgets/tablecontentmodel.py b/src/tribler/gui/widgets/tablecontentmodel.py deleted file mode 100644 index 2bf958cc7b..0000000000 --- a/src/tribler/gui/widgets/tablecontentmodel.py +++ /dev/null @@ -1,622 +0,0 @@ -import json -import logging -import time -import uuid -from collections import deque -from dataclasses import dataclass, field -from datetime import timedelta -from enum import Enum, auto -from typing import Callable, Dict, List - -from PyQt5.QtCore import QAbstractTableModel, QModelIndex, QRectF, QSize, QTimerEvent, Qt, pyqtSignal - -from tribler.core.database.ranks import item_rank -from tribler.core.database.serialization import COLLECTION_NODE, REGULAR_TORRENT -from tribler.gui.defs import BITTORRENT_BIRTHDAY, HEALTH_CHECKING -from tribler.gui.network.request_manager import request_manager -from tribler.core.database.queries import to_fts_query -from tribler.gui.utilities import connect, format_size, format_votes, get_votes_rating_description, pretty_date, tr - -EXPANDING = 0 -HIGHLIGHTING_PERIOD_SECONDS = 1.0 -HIGHLIGHTING_TIMER_INTERVAL_MILLISECONDS = 100 - - -class Column(Enum): - ACTIONS = auto() - CATEGORY = auto() - NAME = auto() - SIZE = auto() - HEALTH = auto() - CREATED = auto() - VOTES = auto() - STATUS = auto() - STATE = auto() - TORRENTS = auto() - SUBSCRIBED = auto() - - -@dataclass -class ColumnDefinition: - dict_key: str - header: str - width: int = 50 - tooltip_filter: Callable[[str], str] = field(default_factory=lambda: (lambda tooltip: None)) - display_filter: Callable[[str], str] = field(default_factory=lambda: (lambda txt: txt)) - sortable: bool = True - qt_flags: Qt.ItemFlags = Qt.ItemIsEnabled | Qt.ItemIsSelectable - - -def define_columns(): - d = ColumnDefinition - # fmt:off - # pylint: disable=line-too-long - columns_dict = { - Column.ACTIONS: d('', "", width=60, sortable=False), - Column.CATEGORY: d('category', "", width=30, tooltip_filter=lambda data: data), - Column.NAME: d('name', tr("Name"), width=EXPANDING), - Column.SIZE: d('size', tr("Size"), width=90, - display_filter=lambda data: (format_size(float(data)) if data != "" else "")), - Column.HEALTH: d('health', tr("Health"), width=120, tooltip_filter=lambda data: f"{data}" + ( - '' if data == HEALTH_CHECKING else '\n(Click to recheck)'), ), - Column.CREATED: d('created', tr("Created"), width=120, display_filter=lambda timestamp: pretty_date( - timestamp) if timestamp and timestamp > BITTORRENT_BIRTHDAY else "", ), - Column.VOTES: d('votes', tr("Popularity"), width=120, display_filter=format_votes, - tooltip_filter=lambda data: get_votes_rating_description(data) if data is not None else None, ), - Column.STATUS: d('status', "", sortable=False), - Column.STATE: d('state', "", width=80, tooltip_filter=lambda data: data, sortable=False), - Column.TORRENTS: d('torrents', tr("Torrents"), width=90), - Column.SUBSCRIBED: d('subscribed', tr("Subscribed"), width=95), - } - # pylint: enable=line-too-long - # fmt:on - return columns_dict - - -def get_item_uid(item): - if 'public_key' in item and 'id' in item: - return f"{item['public_key']}:{item['id']}" - return item['infohash'] - - -class RemoteTableModel(QAbstractTableModel): - info_changed = pyqtSignal(list) - query_complete = pyqtSignal() - query_started = pyqtSignal() - """ - The base model for the tables in the Tribler GUI. - It is specifically designed to fetch data from a remote data source, i.e. over a RESTful API. - """ - - default_sort_column = -1 - - def __init__(self, parent=None): - - super().__init__(parent) - self._logger = logging.getLogger(self.__class__.__name__) - - # Unique identifier mapping for items. For torrents, it is infohash and for channels, it is concatenated value - # of public key and channel id - self.item_uid_map = {} - - # ACHTUNG! The reason why this is here and not in the class variable is, QT i18 only works for - # tr() entries defined in the class instance constructor - self.columns_dict = define_columns() - - self.data_items = [] - self.max_rowid = None - self.local_total = None - self.item_load_batch = 50 - self.sort_by = self.columns[self.default_sort_column].dict_key if self.default_sort_column >= 0 else None - self.sort_desc = True - self.saved_header_state = None - self.saved_scroll_state = None - self.qt_object_destroyed = False - - self.sort_by_rank = False - self.text_filter = '' - - self.highlight_remote_results = False - self.highlighted_items = deque() - self.highlight_timer = self.startTimer(HIGHLIGHTING_TIMER_INTERVAL_MILLISECONDS) - - connect(self.destroyed, self.on_destroy) - # Every remote query must be attributed to its specific model to avoid updating wrong models - # on receiving a result. We achieve this by maintaining a set of in-flight remote queries. - # Note that this only applies to results that are returned through the events notification - # mechanism, because REST requests attribution is maintained by the RequestManager. - # We do not clean it up after receiving a result because we don't know if the result was the - # last one. In a sense, the queries' UUIDs play the role of "subscription topics" for the model. - self.remote_queries = set() - - self.loaded = False - - @property - def columns(self): - return tuple(self.columns_dict[c] for c in self.columns_shown) - - @property - def min_columns_width(self): - return sum(c.width for c in self.columns) - - @property - def all_local_entries_loaded(self): - return self.local_total is not None and self.local_total <= len(self.data_items) - - def on_destroy(self, *args): - self.qt_object_destroyed = True - - def reset(self): - self.beginResetModel() - self.loaded = False - self.data_items = [] - self.max_rowid = None - self.local_total = None - self.item_uid_map = {} - self.endResetModel() - self.perform_query() - - def should_highlight_item(self, data_item): - return (self.highlight_remote_results and data_item.get('remote') - and data_item['item_added_at'] > time.time() - HIGHLIGHTING_PERIOD_SECONDS) - - def timerEvent(self, event: QTimerEvent) -> None: - if self.highlight_remote_results and event.timerId() == self.highlight_timer: - self.stop_highlighting_old_items() - - def stop_highlighting_old_items(self): - now = time.time() - then = now - HIGHLIGHTING_PERIOD_SECONDS - last_column_offset = len(self.columns_dict) - 1 - while self.highlighted_items and self.highlighted_items[0]['item_added_at'] < then: - item = self.highlighted_items.popleft() - uid = get_item_uid(item) - row = self.item_uid_map.get(uid) - if row is not None: - self.dataChanged.emit(self.index(row, 0), self.index(row, last_column_offset)) - - def sort(self, column_index, order): - if not self.columns[column_index].sortable: - return - # If the column number is set to -1, this means we do not want to do sorting at all - # We have to set it to something (-1), because QT does not support setting it to "None" - self.sort_by = self.columns[column_index].dict_key if column_index >= 0 else None - self.sort_desc = bool(order) - self.reset() - - @staticmethod - def create_uid_map(items): - uid_map = {} - insert_index = 0 - for item in items: - item_uid = get_item_uid(item) - uid_map[item_uid] = insert_index - if 'infohash' in item: - uid_map[item['infohash']] = insert_index - insert_index += 1 - return uid_map - - def extract_unique_new_items(self, items: list, on_top: bool, remote: bool) -> tuple[List, int]: - # Only add unique items to the table model and reverse mapping from unique ids to rows is built. - insert_index = 0 if on_top else len(self.data_items) - unique_new_items = [] - now = time.time() - for item in items: - if remote: - item['remote'] = True - item['item_added_at'] = now - if self.highlight_remote_results: - self.highlighted_items.append(item) - if self.sort_by_rank and 'rank' not in item: - item['rank'] = item_rank(self.text_filter, item) - - item_uid = get_item_uid(item) - if item_uid not in self.item_uid_map: - - self.item_uid_map[item_uid] = insert_index - if 'infohash' in item: - self.item_uid_map[item['infohash']] = insert_index - unique_new_items.append(item) - - insert_index += 1 - return unique_new_items, insert_index - - def add_items(self, new_items, on_top=False, remote=False): - """ - Adds new items to the table model. All items are mapped to their unique ids to avoid the duplicates. - New items are prepended to the end of the model. - Note that item_uid_map tracks items twice: once by public_key+id and once by infohash. This is necessary to - support status updates from TorrentChecker based on infohash only. - :param new_items: list(item) - :param on_top: True if new_items should be added on top of the table - :param remote: True if new_items are from a remote peer. Default: False - :return: None - """ - if not new_items: - return - - unique_new_items, insert_index = self.extract_unique_new_items(new_items, on_top, remote) - # If no new items are found, skip - if not unique_new_items: - return - - if remote and self.sort_by_rank: - torrents = [item for item in self.data_items if item['type'] == REGULAR_TORRENT] - non_torrents = [item for item in self.data_items if item['type'] != REGULAR_TORRENT] - - new_torrents = [item for item in unique_new_items if item['type'] == REGULAR_TORRENT] - new_non_torrents = [item for item in unique_new_items if item['type'] != REGULAR_TORRENT] - - torrents += new_torrents - non_torrents += new_non_torrents - - torrents.sort(key=lambda item: item['rank'], reverse=True) - new_data_items = non_torrents + torrents - - self.beginResetModel() - self.data_items = new_data_items - self.item_uid_map = self.create_uid_map(new_data_items) - self.endResetModel() - return - - # Else if remote items, to make space for new unique items shift the existing items - if on_top and insert_index > 0: - new_items_map = {} - for item in self.data_items: - old_item_uid = get_item_uid(item) - if old_item_uid in self.item_uid_map: - shifted_index = insert_index + self.item_uid_map[old_item_uid] - new_items_map[old_item_uid] = shifted_index - if 'infohash' in item: - new_items_map[item['infohash']] = shifted_index - self.item_uid_map.update(new_items_map) - - # Update the table model - if on_top: - self.beginInsertRows(QModelIndex(), 0, len(unique_new_items) - 1) - self.data_items = unique_new_items + self.data_items - else: - self.beginInsertRows(QModelIndex(), len(self.data_items), len(self.data_items) + len(unique_new_items) - 1) - self.data_items.extend(unique_new_items) - self.endInsertRows() - - def remove_items(self, items): - uids_to_remove = [] - rows_to_remove = [] - for item in items: - uid = get_item_uid(item) - row = self.item_uid_map.get(uid) - if row is not None: - uids_to_remove.append(uid) - rows_to_remove.append(row) - - if not rows_to_remove: - return - - # Rows to remove must be grouped into continuous regions. - # We have to remove the rows in a reversed order because otherwise row indexes - # would be affected by the previous deletions. - rows_to_remove_reversed = sorted(rows_to_remove, reverse=True) - groups = [] - for n, row in enumerate(rows_to_remove_reversed): - if n == 0: - groups.append([row]) - elif row == (rows_to_remove_reversed[n - 1] - 1): - groups[-1].append(row) - else: - groups.append([row]) - - for uid in uids_to_remove: - self.item_uid_map.pop(uid) - for group in groups: - first, last = group[-1], group[0] - self.beginRemoveRows(QModelIndex(), first, last) - for row in group: - del self.data_items[row] - self.endRemoveRows() - - # Update uids of the shifted rows - for n, item in enumerate(self.data_items): - if n >= rows_to_remove[0]: # start from the first removed row - self.item_uid_map[get_item_uid(item)] = n - - self.info_changed.emit(items) - - def perform_initial_query(self): - self.perform_query() - - def perform_query(self, **kwargs): - """ - Fetch results for a given query. - """ - self.query_started.emit() - if 'first' not in kwargs or 'last' not in kwargs: - kwargs["first"], kwargs['last'] = self.rowCount() + 1, self.rowCount() + self.item_load_batch - - if self.sort_by is not None: - kwargs.update({"sort_by": self.sort_by, "sort_desc": self.sort_desc}) - if self.text_filter: - kwargs['filter'] = self.text_filter - if 'origin_id' not in kwargs: - kwargs.pop("include_total", None) - - if self.max_rowid is not None: - kwargs["max_rowid"] = self.max_rowid - - if self.hide_xxx is not None: - kwargs.update({"hide_xxx": self.hide_xxx}) - rest_endpoint_url = kwargs.pop("rest_endpoint_url") if "rest_endpoint_url" in kwargs else self.endpoint_url - self._logger.info(f'Request to "{rest_endpoint_url}":{kwargs}') - request_manager.get(rest_endpoint_url, self.on_query_results, url_params=kwargs) - - def on_query_results(self, response, remote=False, on_top=False): - """ - Updates the table with the response. - - :param response: List of the items to be added to the model - :param remote: True if response is from a remote peer. Default: False - :param on_top: True if items should be added at the top of the list - :return: True, if response, False otherwise - """ - if not response or self.qt_object_destroyed: - return False - self._logger.info( - f'Response. Remote: {remote}, results: {len(response.get("results"))}, ' f'uuid: {response.get("uuid")}' - ) - - # Trigger labels update on the initial table load - update_labels = len(self.data_items) == 0 - - if not remote or (uuid.UUID(response.get('uuid')) in self.remote_queries): - prev_total = self.channel_info.get("total") - if not remote: - if "total" in response: - self.local_total = response["total"] - self.channel_info["total"] = self.local_total - elif self.channel_info.get("total"): - self.channel_info["total"] += len(response["results"]) - - if prev_total != self.channel_info.get("total"): - update_labels = True - - self.add_items(response['results'], on_top=on_top, remote=remote) - - if update_labels: - self.info_changed.emit(response['results']) - - self.loaded = True - self.query_complete.emit() - return True - - -class ChannelContentModel(RemoteTableModel): - columns_shown = (Column.ACTIONS, Column.CATEGORY, Column.NAME, Column.SIZE, Column.HEALTH, Column.CREATED) - - def __init__( - self, - channel_info=None, - hide_xxx=None, - exclude_deleted=None, - subscribed_only=None, - endpoint_url=None, - text_filter='', - tags=None, - type_filter=None, - original_query='', - fts_text='', - ): - super().__init__(None) - - self.column_position = {name: i for i, name in enumerate(self.columns_shown)} - self.name_column_width = 0 - - # Remote query (model) parameters - self.hide_xxx = hide_xxx - self.text_filter = text_filter - self.tags = tags - self.subscribed_only = subscribed_only - self.exclude_deleted = exclude_deleted - self.type_filter = type_filter - self.category_filter = None - - # Stores metadata of the 'edit tags' button in each cell. - self.edit_tags_rects: Dict[QModelIndex, QRectF] = {} - self.download_popular_content_rects: Dict[QModelIndex, List[QRectF]] = {} - - self.channel_info = channel_info - - self.endpoint_url = endpoint_url - self.original_query = original_query - self.fts_text = fts_text - # Load the initial batch of entries - self.perform_initial_query() - - @property - def edit_enabled(self): - return False - - def headerData(self, num, orientation, role=None): - if orientation == Qt.Horizontal and role == Qt.DisplayRole: - header_text = self.columns[num].header - return str(header_text) # convert TranslatedString to str as Qt can't handle str subclasses here - if role == Qt.InitialSortOrderRole and num != self.column_position.get(Column.NAME): - return Qt.DescendingOrder - if role == Qt.TextAlignmentRole: - alignment = ( - Qt.AlignHCenter - if num in [self.column_position.get(Column.SUBSCRIBED), self.column_position.get(Column.TORRENTS)] - else Qt.AlignLeft - ) - return alignment | Qt.AlignVCenter - if role == Qt.SizeHintRole: - # It seems that Qt first queries - this signals that the row height is exclusively decided by the size - # hints returned by the data() method. - return QSize(0, 0) - return super().headerData(num, orientation, role) - - def rowCount(self, *_, **__): - return len(self.data_items) - - def columnCount(self, *_, **__): - return len(self.columns) - - def flags(self, index): - return self.columns[index.column()].qt_flags - - def item_txt(self, index, role, is_editing: bool = False): - # ACHTUNG! Dumb workaround for some mysterious race condition - try: - item = self.data_items[index.row()] - except IndexError: - return "" - - column = self.columns[index.column()] - column_type = self.columns_shown[index.column()] - data = item.get(column.dict_key, '') - - # Print number of torrents in the channel for channel rows in the "size" column - if ( - column_type == Column.SIZE - and "torrents" not in self.columns - and "torrents" in item - and item["type"] == COLLECTION_NODE - ): - return item["torrents"] - - # 'subscribed' column gets special treatment in case of ToolTipRole, because - # its tooltip uses information from both 'subscribed' and 'state' keys - if role == Qt.ToolTipRole and column_type == Column.SUBSCRIBED and 'subscribed' in item and 'state' in item: - state_message = f" ({item['state']})" if item['state'] != "Complete" else "" - tooltip_txt = ( - tr("Subscribed.%s\n(Click to unsubscribe)") % state_message - if item['subscribed'] - else tr("Not subscribed.\n(Click to subscribe)") - ) - return tooltip_txt - - if role == Qt.ToolTipRole and column_type == Column.HEALTH: - last_tracker_check = item.get('last_tracker_check') - if item.get('health') == HEALTH_CHECKING: - return 'Checking...' - if last_tracker_check is None: - return 'Unknown' - if last_tracker_check == 0: - return 'Not checked' - - td = timedelta(seconds=time.time() - last_tracker_check) - if td.days > 0: - return f'Checked: {td.days} days ago' - - time_without_microseconds = str(td).partition('.')[0] - return f'Checked: {time_without_microseconds} ago' - - if role == Qt.ToolTipRole and column_type == Column.NAME and "infohash" in item: - return f'{item["infohash"][:8]}' - - # The 'name' column is special in a sense that we want to draw the title and tags ourselves. - # At the same time, we want to name this column to not break the renaming of torrent files, hence this check. - if column_type == Column.NAME and not is_editing: - return "" - - return (column.tooltip_filter if role == Qt.ToolTipRole else column.display_filter)(data) - - def data(self, index, role): - if role in (Qt.DisplayRole, Qt.EditRole, Qt.ToolTipRole): - return self.item_txt(index, role, is_editing=(role == Qt.EditRole)) - if role == Qt.TextAlignmentRole: - if index.column() == self.column_position.get(Column.VOTES, -1): - return Qt.AlignLeft | Qt.AlignVCenter - if index.column() == self.column_position.get(Column.TORRENTS, -1): - return Qt.AlignHCenter | Qt.AlignVCenter - return None - - def reset(self): - self.item_uid_map.clear() - self.edit_tags_rects.clear() - self.download_popular_content_rects.clear() - super().reset() - - def update_node_info(self, update_dict): - """ - This method updates/inserts rows based on updated_dict. It should be typically invoked - by a signal from Events endpoint. One special case it when the channel_info of the model - itself is updated. In that case, info_changed signal is emitted, so the controller/widget knows - it is time to update the labels. - """ - - MISSING = object() # to avoid false positive comparison with None - public_key_is_equal = self.channel_info.get("public_key", None) == update_dict.get("public_key", MISSING) - id_is_equal = self.channel_info.get("id", None) == update_dict.get("id", MISSING) - if public_key_is_equal and id_is_equal: - self.channel_info.update(**update_dict) - self.info_changed.emit([]) - return - - uid = get_item_uid(update_dict) - row = self.item_uid_map.get(uid) - if row is not None and row < len(self.data_items): - self.data_items[row].update(**update_dict) - self.dataChanged.emit(self.index(row, 0), self.index(row, len(self.columns)), []) - - def perform_query(self, **kwargs): - """ - Fetch search results. - """ - kwargs['original_query'] = self.original_query - kwargs['fts_text'] = self.fts_text - if self.type_filter is not None: - kwargs.update({"metadata_type": self.type_filter}) - else: - kwargs.update({"metadata_type": [REGULAR_TORRENT, COLLECTION_NODE]}) - if self.subscribed_only is not None: - kwargs.update({"subscribed": self.subscribed_only}) - if self.exclude_deleted is not None: - kwargs.update({"exclude_deleted": self.exclude_deleted}) - if self.category_filter is not None: - if self.category_filter == "Channels": - kwargs.update({'metadata_type': 'channel'}) - else: - kwargs.update({"category": self.category_filter}) - - if "total" not in self.channel_info: - # Only include total for the first query to the endpoint - kwargs.update({"include_total": 1}) - - if self.tags: - kwargs['tags'] = self.tags - - super().perform_query(**kwargs) - - def setData(self, index, new_value, role=None): - if role != Qt.EditRole: - return True - item = self.data_items[index.row()] - attribute_name = self.columns[index.column()].dict_key - attribute_name = 'tags' if attribute_name == 'category' else attribute_name - attribute_name = 'title' if attribute_name == 'name' else attribute_name - - if attribute_name == 'subscribed': - return True - - def on_row_update_results(response): - if not response: - return - item_row = self.item_uid_map.get(get_item_uid(item)) - if item_row is None: - return - try: - data_item_dict = index.model().data_items[item_row] - except IndexError: - return - data_item_dict.update(response) - self.info_changed.emit([data_item_dict]) - - request_manager.patch(f"metadata/{item['public_key']}/{item['id']}", on_row_update_results, - data=json.dumps({attribute_name: new_value})) - - # ACHTUNG: instead of reloading the whole row from DB, this line just changes the displayed value! - self.data_items[index.row()][attribute_name] = new_value - return True - - def on_new_entry_received(self, response): - self.on_query_results(response, remote=True) diff --git a/src/tribler/gui/widgets/tableiconbuttons.py b/src/tribler/gui/widgets/tableiconbuttons.py deleted file mode 100644 index 62cd68ebb1..0000000000 --- a/src/tribler/gui/widgets/tableiconbuttons.py +++ /dev/null @@ -1,62 +0,0 @@ -from PyQt5.QtCore import QEvent, QModelIndex, QObject, QRect, QSize, pyqtSignal -from PyQt5.QtGui import QIcon - -from tribler.gui.utilities import get_image_path - - -class IconButton(QObject): - icon = QIcon() - icon_border_ratio = float(0.1) - clicked = pyqtSignal(QModelIndex) - - icon_border = 4 - icon_size = 16 - h = icon_size + 2 * icon_border - w = h - size = QSize(w, h) - - def __init__(self, parent=None): - super().__init__(parent=parent) - # rect property contains the active zone for the button - self.rect = QRect() - self.icon_rect = QRect() - self.icon_mode = QIcon.Normal - - def should_draw(self, _): - return True - - def paint(self, painter, rect, _): - # Update button activation rect from the drawing call - self.rect = rect - - x = rect.left() + (rect.width() - self.w) // 2 - y = rect.top() + (rect.height() - self.h) // 2 - icon_rect = QRect(x, y, self.w, self.h) - - self.icon.paint(painter, icon_rect, mode=self.icon_mode) - - def check_clicked(self, event, _, __, index): - if event.type() == QEvent.MouseButtonRelease and self.rect.contains(event.pos()): - self.clicked.emit(index) - return True - return False - - def on_mouse_moved(self, pos, _): - if self.rect.contains(pos): - self.icon_mode = QIcon.Selected - else: - self.icon_mode = QIcon.Normal - - def size_hint(self, _, __): - return self.size - - -class DownloadIconButton(IconButton): - icon = QIcon(get_image_path("downloads.png")) - - -class DeleteIconButton(IconButton): - icon = QIcon(get_image_path("trash.svg")) - - def should_draw(self, index): - return index.model().edit_enabled diff --git a/src/tribler/gui/widgets/tagbutton.py b/src/tribler/gui/widgets/tagbutton.py deleted file mode 100644 index 89a8f7596a..0000000000 --- a/src/tribler/gui/widgets/tagbutton.py +++ /dev/null @@ -1,31 +0,0 @@ -from PyQt5.QtCore import QSize, Qt -from PyQt5.QtGui import QCursor -from PyQt5.QtWidgets import QPushButton - -from tribler.gui.defs import SUGGESTED_TAG_BACKGROUND_COLOR, SUGGESTED_TAG_BORDER_COLOR, SUGGESTED_TAG_TEXT_COLOR, \ - TAG_HEIGHT, TAG_TEXT_HORIZONTAL_PADDING - - -class TagButton(QPushButton): - """ - This class represents a clickable tag. - """ - - def __init__(self, parent, tag_text): - QPushButton.__init__(self, parent) - - self.setText(tag_text) - - # Update the width and height (Qt won't do this automatically) - text_width = self.fontMetrics().horizontalAdvance(tag_text) - tag_box_width = text_width + 2 * TAG_TEXT_HORIZONTAL_PADDING + 2 - self.setFixedSize(QSize(tag_box_width, TAG_HEIGHT)) - - self.setCursor(QCursor(Qt.PointingHandCursor)) - self.setStyleSheet(f"color: {SUGGESTED_TAG_TEXT_COLOR.name()};" - f"border-radius: {TAG_HEIGHT // 2}px;" - f"border: 1px solid {SUGGESTED_TAG_BORDER_COLOR.name()};" - f"background-color: {SUGGESTED_TAG_BACKGROUND_COLOR.name()};" - f"padding-left: {TAG_TEXT_HORIZONTAL_PADDING}px;" - f"padding-right: {TAG_TEXT_HORIZONTAL_PADDING}px;") - self.update() diff --git a/src/tribler/gui/widgets/tagslineedit.py b/src/tribler/gui/widgets/tagslineedit.py deleted file mode 100644 index ba0e6de8c5..0000000000 --- a/src/tribler/gui/widgets/tagslineedit.py +++ /dev/null @@ -1,512 +0,0 @@ -from dataclasses import dataclass -from typing import List, Tuple - -from PyQt5.QtCore import QLineF, QPoint, QPointF, QRectF, QSizeF, QTimerEvent, Qt, pyqtSignal -from PyQt5.QtGui import ( - QColor, - QGuiApplication, - QKeyEvent, - QKeySequence, - QMouseEvent, - QPainter, - QPainterPath, - QPalette, - QTextLayout, -) -from PyQt5.QtWidgets import QLineEdit, QStyle, QStyleOptionFrame - -from tribler.gui.defs import ( - EDIT_TAG_BACKGROUND_COLOR, - EDIT_TAG_BORDER_COLOR, - EDIT_TAG_TEXT_COLOR, - TAG_HEIGHT, - TAG_TEXT_HORIZONTAL_PADDING, -) - - -@dataclass -class Tag: - text: str - rect: QRectF - - -TAG_HORIZONTAL_MARGIN = 3 -TAG_CROSS_WIDTH = 6 -TAG_CROSS_LEFT_PADDING = 3 -TAG_CROSS_RIGHT_PADDING = 4 -TAG_VERTICAL_MARGIN = 4 - - -class TagsLineEdit(QLineEdit): - """ - Represents a QLineEdit widget in which a user can type tags. - Ported C++ implementation, see https://github.com/nicktrandafil/tags. - """ - - escape_pressed = pyqtSignal() - enter_pressed = pyqtSignal() - - def __init__(self, parent): - QLineEdit.__init__(self, parent) - self.tags: List[Tag] = [Tag("", QRectF())] - self.blink_timer: int = 0 - self.cursor_ind: int = 0 - self.blink_status: bool = True - self.select_start: int = 0 - self.select_size: int = 0 - self.text_layout: QTextLayout = QTextLayout() - self.editing_index: int = 0 # The position of the tag being edited - self.set_cursor_visible(self.hasFocus()) - - self.move_cursor(0, False) - self.update_display_text() - self.compute_tag_rects() - - self.update() - - def set_tags(self, tags: List[str]) -> None: - """ - Initialize this widget with the provided tags and move the cursor to the end of the line. - """ - self.tags = [] - for tag_text in tags: - self.tags.append(Tag(tag_text, QRectF())) - - self.tags.append(Tag("", QRectF())) - self.edit_tag(len(self.tags) - 1) - - def get_entered_tags(self) -> List[str]: - """ - Return a list of strings with all tags the user has entered in the input field. - """ - return [tag.text for tag in self.tags if tag.text] - - @staticmethod - def compute_cross_rect(tag_rect) -> QRectF: - """ - Compute and return the rectangle that contains the cross button. - """ - cross = QRectF(QPointF(0, 0), QSizeF(TAG_CROSS_WIDTH, TAG_CROSS_WIDTH)) - cross.moveCenter(QPointF(tag_rect.right() - TAG_CROSS_WIDTH - TAG_CROSS_RIGHT_PADDING, tag_rect.center().y())) - return cross - - def in_cross_area(self, tag_index: int, point: QPoint) -> bool: - """ - Return whether the provided point is within the cross rect of the tag with a particular index. - """ - return TagsLineEdit.compute_cross_rect(self.tags[tag_index].rect).adjusted(-2, 0, 0, 0).contains(point) and ( - not self.cursor_is_visible() or tag_index != self.editing_index - ) - - def resizeEvent(self, _) -> None: - self.compute_tag_rects() - - def focusInEvent(self, _) -> None: - self.set_cursor_visible(True) - self.update_display_text() - self.compute_tag_rects() - self.update() - - def focusOutEvent(self, _) -> None: - self.set_cursor_visible(False) - self.edit_previous_tag() - self.update_display_text() - self.compute_tag_rects() - self.update() - - def set_cursor_visible(self, visible: bool) -> None: - if self.blink_timer: - self.killTimer(self.blink_timer) - self.blink_timer = 0 - self.blink_status = True - - if visible: - flashTime = QGuiApplication.styleHints().cursorFlashTime() - if flashTime >= 2: - self.blink_timer = self.startTimer(int(flashTime / 2)) - else: - self.blink_status = False - - def cursor_is_visible(self) -> bool: - return bool(self.blink_timer) - - def update_cursor_blinking(self) -> None: - self.set_cursor_visible(self.cursor_is_visible()) - - def update_display_text(self) -> None: - """ - Update the text that currently is being edited. - """ - self.text_layout.clearLayout() - self.text_layout.setText(self.tags[self.editing_index].text) - self.text_layout.beginLayout() - self.text_layout.createLine() - self.text_layout.endLayout() - - def set_editing_index(self, new_index: int) -> None: - """ - Update the index of the tag being edited. Also remove the tags that are empty (e.g., contain no text). - """ - if not self.tags[self.editing_index].text: - self.tags.pop(self.editing_index) - if self.editing_index <= new_index: - new_index -= 1 - - self.editing_index = new_index - - def edit_new_tag(self) -> None: - """ - Start editing a new tag at the end of the input field. - """ - self.tags.append(Tag("", QRectF())) - self.set_editing_index(len(self.tags) - 1) - self.move_cursor(0, False) - - def current_rect(self) -> QRectF: - """ - Return the bounding rectangle of the tag currently being edited. - """ - return self.tags[self.editing_index].rect - - def formatting(self) -> List[QTextLayout.FormatRange]: - """ - Determine the formatting rules of the display text. - """ - if self.select_size == 0: - return [] - - selection = QTextLayout.FormatRange() - selection.start = self.select_start - selection.length = self.select_size - selection.format.setBackground(self.palette().brush(QPalette.Highlight)) - selection.format.setForeground(self.palette().brush(QPalette.HighlightedText)) - return [selection] - - def draw_tags(self, painter: QPainter, from_ind: int, to_ind: int) -> None: - """ - Draw the tags between two particular indices. - """ - for ind in range(from_ind, to_ind): - i_r = self.tags[ind].rect - text_pos = i_r.topLeft() + QPointF( - TAG_TEXT_HORIZONTAL_PADDING, - self.fontMetrics().ascent() + ((i_r.height() - self.fontMetrics().height()) // 2), - ) - - # draw rect - painter.setPen(EDIT_TAG_BORDER_COLOR) - path = QPainterPath() - path.addRoundedRect(i_r, TAG_HEIGHT // 2, TAG_HEIGHT // 2) - painter.fillPath(path, EDIT_TAG_BACKGROUND_COLOR) - painter.drawPath(path) - - # draw text - painter.setPen(EDIT_TAG_TEXT_COLOR) - painter.drawText(text_pos, self.tags[ind].text) - - # calc cross rect - i_cross_r = TagsLineEdit.compute_cross_rect(i_r) - - pen = painter.pen() - pen.setWidth(2) - - painter.setPen(pen) - painter.drawLine(QLineF(i_cross_r.topLeft(), i_cross_r.bottomRight())) - painter.drawLine(QLineF(i_cross_r.bottomLeft(), i_cross_r.topRight())) - - def input_field_rect(self) -> QRectF: - panel = QStyleOptionFrame() - self.initStyleOption(panel) - r = self.style().subElementRect(QStyle.SE_LineEditContents, panel, self) - return r - - def compute_tag_rects(self) -> None: - """ - (Re)compute the bounding rectangles of entered tags. - """ - r = self.input_field_rect() - lt = r.topLeft() - - if self.cursor_is_visible(): - self.compute_tag_rects_with_range(lt, TAG_HEIGHT, (0, self.editing_index)) - - w = ( - self.fontMetrics().horizontalAdvance(self.text_layout.text()) - + TAG_TEXT_HORIZONTAL_PADDING - + TAG_TEXT_HORIZONTAL_PADDING - ) - - # Check if we overflow and if so, move the editor rect to the next line in the input field. - if lt.x() + w >= r.topRight().x(): - lt.setX(r.x()) - lt.setY(lt.y() + 24) - - self.tags[self.editing_index].rect = QRectF(lt, QSizeF(w, TAG_HEIGHT)) - lt += QPoint(w + TAG_HORIZONTAL_MARGIN, 0) - - self.compute_tag_rects_with_range(lt, TAG_HEIGHT, (self.editing_index + 1, len(self.tags))) - else: - self.compute_tag_rects_with_range(lt, TAG_HEIGHT, (0, len(self.tags))) - - # Adjust the height of the input field - self.setMinimumHeight(lt.y() + TAG_HEIGHT + TAG_VERTICAL_MARGIN) - - def compute_tag_rects_with_range(self, lt: QPoint, height: int, tags_range: Tuple[int, int]) -> None: - for tag_index in range(*tags_range): - i_width = self.fontMetrics().horizontalAdvance(self.tags[tag_index].text) - i_r = QRectF(lt, QSizeF(i_width, height)) - i_r.translate(TAG_TEXT_HORIZONTAL_PADDING, 0) - i_r.adjust( - -TAG_TEXT_HORIZONTAL_PADDING, - 0, - TAG_TEXT_HORIZONTAL_PADDING + TAG_CROSS_LEFT_PADDING + TAG_CROSS_RIGHT_PADDING + TAG_CROSS_WIDTH, - 0, - ) - - # Check if we overflow and if so, move this tag to the next line in the input field. - input_rect = self.input_field_rect() - if i_r.topRight().x() >= input_rect.topRight().x(): - i_r.setRect(input_rect.x(), i_r.y() + TAG_HEIGHT + TAG_VERTICAL_MARGIN, i_r.width(), i_r.height()) - lt.setY(lt.y() + TAG_HEIGHT + TAG_VERTICAL_MARGIN) - - lt.setX(int(i_r.right() + TAG_HORIZONTAL_MARGIN)) - self.tags[tag_index].rect = i_r - - def has_selection_active(self) -> bool: - return self.select_size > 0 - - def remove_selection(self) -> None: - self.cursor_ind = self.select_start - txt = self.tags[self.editing_index].text - self.tags[self.editing_index].text = txt[: self.cursor_ind] + txt[self.cursor_ind + self.select_size:] - self.deselectAll() - - def remove_backwards_character(self) -> None: - if self.has_selection_active(): - self.remove_selection() - else: - self.cursor_ind -= 1 - txt = self.tags[self.editing_index].text - txt = txt[: self.cursor_ind] + txt[self.cursor_ind + 1:] - self.tags[self.editing_index].text = txt - - def selectAll(self) -> None: - self.select_start = 0 - self.select_size = len(self.tags[self.editing_index].text) - - def deselectAll(self) -> None: - self.select_start = 0 - self.select_size = 0 - - def move_cursor(self, pos: int, mark: bool) -> None: - if mark: - select_end = self.select_start + self.select_size - anchor = None - if self.select_size > 0 and self.cursor_ind == self.select_start: - anchor = select_end - elif self.select_size > 0 and self.cursor_ind == select_end: - anchor = self.select_start - else: - anchor = self.cursor_ind - - self.select_start = min(anchor, pos) - self.select_size = max(anchor, pos) - self.select_start - else: - self.deselectAll() - - self.cursor_ind = pos - - def cursorToX(self): - return self.text_layout.lineAt(0).cursorToX(self.cursor_ind)[0] - - def edit_previous_tag(self) -> None: - if self.editing_index > 0: - self.set_editing_index(self.editing_index - 1) - self.move_cursor(len(self.tags[self.editing_index].text), False) - - def edit_next_tag(self) -> None: - if self.editing_index < len(self.tags) - 1: - self.set_editing_index(self.editing_index + 1) - self.move_cursor(0, False) - - def edit_tag(self, tag_index: int) -> None: - self.set_editing_index(tag_index) - self.move_cursor(len(self.tags[self.editing_index].text), False) - - def paintEvent(self, _) -> None: - p: QPainter = QPainter() - p.begin(self) - p.setRenderHint(QPainter.Antialiasing) - - panel: QStyleOptionFrame = QStyleOptionFrame() - self.initStyleOption(panel) - self.style().drawPrimitive(QStyle.PE_PanelLineEdit, panel, p, self) - - if self.cursor_is_visible(): - r = self.current_rect() - txt_p = r.topLeft() + QPointF(TAG_TEXT_HORIZONTAL_PADDING, 4) - - # Draw the tags up to the current point where we are editing. - self.draw_tags(p, 0, self.editing_index) - - # Draw the display text. - p.setPen(QColor("#222")) - formatting = self.formatting() - self.text_layout.draw(p, txt_p, formatting) - p.setPen(Qt.white) - - # Draw the cursor. - if self.blink_status: - self.text_layout.drawCursor(p, txt_p, self.cursor_ind) - - # Draw the tags after the cursor. - self.draw_tags(p, self.editing_index + 1, len(self.tags)) - elif len(self.tags) > 1 or self.tags[0].text: - self.draw_tags(p, 0, len(self.tags)) - - p.end() - - def timerEvent(self, event: QTimerEvent) -> None: - if event.timerId() == self.blink_timer: - self.blink_status = not self.blink_status - self.update() - - def mousePressEvent(self, event: QMouseEvent) -> None: - found = False - for tag_index in range(len(self.tags)): - if self.in_cross_area(tag_index, event.pos()): - self.tags.pop(tag_index) - if tag_index <= self.editing_index: - self.editing_index -= 1 - found = True - break - - if not self.tags[tag_index].rect.contains(event.pos()): - continue - - if self.editing_index == tag_index: - self.move_cursor( - self.text_layout.lineAt(0).xToCursor((event.pos() - self.current_rect().topLeft()).x()), False - ) - else: - self.edit_tag(tag_index) - - found = True - break - - if not found: - self.edit_new_tag() - event.accept() - - if event.isAccepted(): - self.update_display_text() - self.compute_tag_rects() - self.update_cursor_blinking() - self.update() - - def keyPressEvent(self, event: QKeyEvent) -> None: - event.setAccepted(False) - unknown = False - - if event == QKeySequence.SelectAll: - self.selectAll() - event.accept() - elif event == QKeySequence.SelectPreviousChar: - self.move_cursor(self.text_layout.previousCursorPosition(self.cursor_ind), True) - event.accept() - elif event == QKeySequence.SelectNextChar: - self.move_cursor(self.text_layout.nextCursorPosition(self.cursor_ind), True) - event.accept() - else: - if event.key() == Qt.Key_Left: - if self.cursor_ind == 0: - self.edit_previous_tag() - else: - self.move_cursor(self.text_layout.previousCursorPosition(self.cursor_ind), False) - - event.accept() - elif event.key() == Qt.Key_Right: - if self.cursor_ind == len(self.tags[self.editing_index].text): - self.edit_next_tag() - else: - self.move_cursor(self.text_layout.nextCursorPosition(self.cursor_ind), False) - - event.accept() - elif event.key() == Qt.Key_Home: - if self.cursor_ind == 0 and self.editing_index > 0: - self.edit_tag(0) - else: - self.move_cursor(0, False) - - event.accept() - elif event.key() == Qt.Key_End: - if ( - self.cursor_ind == len(self.tags[self.editing_index].text) - and self.editing_index < len(self.tags) - 1 - ): - self.edit_tag(len(self.tags) - 1) - else: - self.move_cursor(len(self.tags[self.editing_index].text), False) - - event.accept() - elif event.key() == Qt.Key_Backspace: - if self.tags[self.editing_index].text: - self.remove_backwards_character() - elif self.editing_index > 0: - self.edit_previous_tag() - - event.accept() - elif event.key() == Qt.Key_Space: - if self.tags[self.editing_index].text: - self.tags.insert(self.editing_index + 1, Tag("", QRectF())) - self.edit_next_tag() - - event.accept() - elif event.key() == Qt.Key_Escape: - self.escape_pressed.emit() - event.accept() - - elif event.key() == Qt.Key_Return: - self.enter_pressed.emit() - event.accept() - else: - unknown = True - - if unknown: - if self.has_selection_active(): - self.remove_selection() - txt = self.tags[self.editing_index].text - txt = txt[: self.cursor_ind] + event.text().lower() + txt[self.cursor_ind:] - self.tags[self.editing_index].text = txt - self.cursor_ind += len(event.text()) - event.accept() - - if event.isAccepted(): - self.update_display_text() - self.compute_tag_rects() - self.update_cursor_blinking() - - self.update() - - def mouseMoveEvent(self, event: QMouseEvent) -> None: - for tag_index in range(len(self.tags)): - if self.in_cross_area(tag_index, event.pos()): - self.setCursor(Qt.PointingHandCursor) - return - - self.setCursor(Qt.IBeamCursor) - - def add_tag(self, tag_text: str) -> None: - """ - Add a particular tag to the end. - """ - if self.editing_index == len(self.tags) - 1 and not self.tags[self.editing_index].text: - self.tags[self.editing_index].text = tag_text - self.edit_new_tag() - else: - self.tags.append(Tag(tag_text, QRectF())) - - self.update_display_text() - self.compute_tag_rects() - self.update_cursor_blinking() - self.update() diff --git a/src/tribler/gui/widgets/timeoutprogressbar.py b/src/tribler/gui/widgets/timeoutprogressbar.py deleted file mode 100644 index c12a1d3852..0000000000 --- a/src/tribler/gui/widgets/timeoutprogressbar.py +++ /dev/null @@ -1,32 +0,0 @@ -from PyQt5.QtCore import QTimer, pyqtSignal -from PyQt5.QtWidgets import QProgressBar - -from tribler.gui.utilities import connect - - -class TimeoutProgressBar(QProgressBar): - timeout = pyqtSignal() - - def __init__(self, parent=None, timeout=10000): - super().__init__(parent) - self.timeout_interval = timeout - self.timer = QTimer() - self.timer.setSingleShot(False) - self.timer.setInterval(100) # update the progress bar tick - - connect(self.timer.timeout, self._update) - self.setMaximum(self.timeout_interval) - - def _update(self): - self.setValue(self.value() + self.timer.interval()) - if self.value() >= self.maximum(): - self.timer.stop() - self.timeout.emit() - - def start(self): - self.setValue(0) - self.timer.start() - - def stop(self): - self.setValue(0) - self.timer.stop() diff --git a/src/tribler/gui/widgets/togglebutton.py b/src/tribler/gui/widgets/togglebutton.py deleted file mode 100644 index 5f0e893e35..0000000000 --- a/src/tribler/gui/widgets/togglebutton.py +++ /dev/null @@ -1,133 +0,0 @@ -from PyQt5.QtCore import QPropertyAnimation, QRectF, QSize, Qt, pyqtProperty -from PyQt5.QtGui import QPainter -from PyQt5.QtWidgets import QAbstractButton, QSizePolicy - -# Toggle button in pure QT. Copy-pasted from StackOverflow answer https://stackoverflow.com/a/51825815/5553928 -# Courtesy of Stefan Scherfke. -from tribler.gui.widgets.tablecontentdelegate import TRIBLER_PALETTE - - -class ToggleButton(QAbstractButton): - def __init__(self, parent=None, track_radius=10, thumb_radius=8, auto_check_on_click=False): - super().__init__(parent=parent) - self.setCheckable(True) - self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - - self.auto_check_on_click = auto_check_on_click - - self._track_radius = track_radius - self._thumb_radius = thumb_radius - - self._margin = max(0, self._thumb_radius - self._track_radius) - self._base_offset = max(self._thumb_radius, self._track_radius) - self._end_offset = {True: lambda: self.width() - self._base_offset, False: lambda: self._base_offset} - self._offset = self._base_offset - - palette = TRIBLER_PALETTE - if self._thumb_radius > self._track_radius: - self._track_color = {True: palette.highlight(), False: palette.dark()} - self._thumb_color = {True: palette.highlight(), False: palette.light()} - self._text_color = {True: palette.highlightedText().color(), False: palette.dark().color()} - self._thumb_text = {True: '', False: ''} - self._track_opacity = 0.5 - else: - self._thumb_color = {True: palette.highlightedText(), False: palette.light()} - self._track_color = {True: palette.highlight(), False: palette.dark()} - self._text_color = {True: palette.highlight().color(), False: palette.dark().color()} - self._thumb_text = {True: '✔', False: '✕'} - self._track_opacity = 1 - - @pyqtProperty(int) - def offset(self): - return self._offset - - @offset.setter - def offset(self, value): - self._offset = value - self.update() - - def sizeHint(self): # pylint: disable=invalid-name - return QSize(4 * self._track_radius + 2 * self._margin, 2 * self._track_radius + 2 * self._margin) - - def setCheckedInstant(self, checked): - super().setChecked(checked) - self.offset = self._end_offset[checked]() - - def setChecked(self, checked): - super().setChecked(checked) - if self.auto_check_on_click: - self.offset = self._end_offset[checked]() - else: - self.animate_thumb_move(checked) - - def animate_thumb_move(self, checked): - anim = QPropertyAnimation(self, b'offset', self) - anim.setDuration(120) - anim.setStartValue(self.offset) - anim.setEndValue(self._end_offset[checked]()) - anim.start() - - def resizeEvent(self, event): - super().resizeEvent(event) - self.offset = self._end_offset[self.isChecked()]() - - def paintEvent(self, event): # pylint: disable=invalid-name, unused-argument - p = QPainter(self) - p.setRenderHint(QPainter.Antialiasing, True) - p.setPen(Qt.NoPen) - track_opacity = self._track_opacity - thumb_opacity = 1.0 - text_opacity = 1.0 - if self.isEnabled(): - track_brush = self._track_color[self.isChecked()] - thumb_brush = self._thumb_color[self.isChecked()] - text_color = self._text_color[self.isChecked()] - else: - track_opacity *= 0.8 - track_brush = self.palette().shadow() - thumb_brush = self.palette().mid() - text_color = self.palette().shadow().color() - - p.setBrush(track_brush) - p.setOpacity(track_opacity) - p.drawRoundedRect( - self._margin, - self._margin, - self.width() - 2 * self._margin, - self.height() - 2 * self._margin, - self._track_radius, - self._track_radius, - ) - p.setBrush(thumb_brush) - p.setOpacity(thumb_opacity) - p.drawEllipse( - self.offset - self._thumb_radius, - self._base_offset - self._thumb_radius, - 2 * self._thumb_radius, - 2 * self._thumb_radius, - ) - p.setPen(text_color) - p.setOpacity(text_opacity) - font = p.font() - font.setPixelSize(int(1.5 * self._thumb_radius)) - p.setFont(font) - p.drawText( - QRectF( - self.offset - self._thumb_radius, - self._base_offset - self._thumb_radius, - 2 * self._thumb_radius, - 2 * self._thumb_radius, - ), - Qt.AlignCenter, - self._thumb_text[self.isChecked()], - ) - - def mouseReleaseEvent(self, event): # pylint: disable=invalid-name - super().mouseReleaseEvent(event) - if self.auto_check_on_click: - if event.button() == Qt.LeftButton: - self.animate_thumb_move(self.isChecked()) - - def enterEvent(self, event): # pylint: disable=invalid-name - self.setCursor(Qt.PointingHandCursor) - super().enterEvent(event) diff --git a/src/tribler/gui/widgets/torrentfiletreewidget.py b/src/tribler/gui/widgets/torrentfiletreewidget.py deleted file mode 100644 index 3418806174..0000000000 --- a/src/tribler/gui/widgets/torrentfiletreewidget.py +++ /dev/null @@ -1,914 +0,0 @@ -from __future__ import annotations - -import re -import sys -from bisect import bisect -from contextlib import suppress -from dataclasses import dataclass, field -from pathlib import Path - -import PyQt5 -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QIcon, QMovie -from PyQt5.QtWidgets import QHeaderView, QTreeWidget, QTreeWidgetItem, QTableWidget, QTableWidgetItem, QWidget - -from tribler.gui.defs import KB, MB, GB, TB, PB -from tribler.gui.network.request_manager import request_manager -from tribler.gui.utilities import connect, format_size, get_image_path -from tribler.gui.widgets.downloadwidgetitem import create_progress_bar_widget - -MAX_ALLOWED_RECURSION_DEPTH = sys.getrecursionlimit() - 100 - -CHECKBOX_COL = 1 -FILENAME_COL = 0 -SIZE_COL = 1 -PROGRESS_COL = 2 - -NAT_SORT_PATTERN = re.compile('([0-9]+)') - -""" - !!! ACHTUNG !!!! - The following series of QT and PyQT bugs forces us to put checkboxes styling here: - 1. It is impossible to style checkboxes using CSS stylesheets due to QTBUG-48023; - 2. We can't put URL with local image path into the associated .ui file - CSS in those don't - support relative paths; - 3. Some funny race condition or a rogue setStyleSheet overwrites the stylesheet if we put it into - the widget init method or even into this dialog init method. - 4. Applying ResizeToContents simultaneously with ANY padding/margin on item results in - seemingly random eliding of the root item, if the checkbox is added to the first column. - 5. Without border-bottom set, checkbox images overlap the text of their column - In other words, the only place where it works is *right before showing results*, - p.s: - putting *any* styling for ::indicator thing into the .ui file result in broken styling. - """ - -TORRENT_FILES_TREE_STYLESHEET_NO_ITEM = """ - TorrentFileTreeWidget::indicator { width: 18px; height: 18px;} - TorrentFileTreeWidget::indicator:checked { image: url("%s"); } - TorrentFileTreeWidget::indicator:unchecked { image: url("%s"); } - TorrentFileTreeWidget::indicator:indeterminate { image: url("%s"); } - TorrentFileTreeWidget { border: none; font-size: 13px; } - TorrentFileTreeWidget::item:hover { background-color: #303030; } - """ % ( - get_image_path('toggle-checked.svg', convert_slashes_to_forward=True), - get_image_path('toggle-unchecked.svg', convert_slashes_to_forward=True), - get_image_path('toggle-undefined.svg', convert_slashes_to_forward=True), -) - -# Note the amount of padding is aligned to the size of progress bars to give both list variants -# (with and without progress bars) a similiar look -TORRENT_FILES_TREE_STYLESHEET = ( - TORRENT_FILES_TREE_STYLESHEET_NO_ITEM - + """ - TorrentFileTreeWidget::item { color: white; padding-top: 7px; padding-bottom: 7px; } -""" -) - - -class DownloadFileTreeWidgetItem(QTreeWidgetItem): - def __init__(self, parent, file_size=None, file_index=None, file_progress=None): - QTreeWidgetItem.__init__(self, parent) - self.file_size = file_size - self.file_index = file_index - self.file_progress = file_progress - - self.progress_bytes = 0 - - if file_size is not None and file_progress is not None: - self.progress_bytes = file_size * file_progress - - @property - def children(self): - return (self.child(index) for index in range(0, self.childCount())) - - def subtree(self, filter_by=lambda x: True): - if not filter_by(self): - return [] - result = [self] - for child in self.children: - if filter_by(child): - result.extend(child.subtree()) - return result - - def fill_directory_sizes(self) -> int: - if self.file_size is None: - self.file_size = 0 - for child in self.children: - self.file_size += child.fill_directory_sizes() - - # On Windows, with display scaling bigger than 100%, the width of the Size column may be too narrow to display - # the full text of the cell. Adding unbreakable spaces makes the column wider, so it can display all the info - non_breaking_spaces = '\u00A0\u00A0' - - self.setText(SIZE_COL, format_size(float(self.file_size)) + non_breaking_spaces) - return self.file_size - - def subtree_progress_update(self, updates, force_update=False, draw_progress_bars=False): - # The trick is, file nodes receive changes in the form of percentage (relative values), - # while folder nodes require bytes (absolute values) - - old_progress_bytes = self.progress_bytes - # File node case - if self.file_index is not None: - upd_progress = updates.get(self.file_index) - if (upd_progress is not None and self.file_progress != upd_progress) or force_update: - self.file_progress = upd_progress - self.progress_bytes = self.file_size * self.file_progress - self.setText(PROGRESS_COL, f"{self.file_progress:.1%}") - - child_changed = False - for child in self.children: - # Case of folder node - old_bytes, new_bytes = child.subtree_progress_update( - updates, force_update=force_update, draw_progress_bars=draw_progress_bars - ) - if old_bytes != new_bytes: - child_changed = True - self.progress_bytes = self.progress_bytes - old_bytes + new_bytes - - if child_changed or force_update: - if self.progress_bytes is not None and self.file_size: - self.file_progress = self.progress_bytes / self.file_size - self.setText(PROGRESS_COL, f"{self.file_progress:.1%}") - - # ACHTUNG! This can be _very_ slow for torrents with lots of files, hence disabled by default - # To draw progress bars with acceptable performance we'd have to use QT's MVC stuff - if draw_progress_bars: - bar_container, progress_bar = create_progress_bar_widget() - progress_bar.setValue(int(self.file_progress * 100)) - self.treeWidget().setItemWidget(self, PROGRESS_COL, bar_container) - - return old_progress_bytes, self.progress_bytes - - def __lt__(self, other): - column = self.treeWidget().sortColumn() - - if column == SIZE_COL: - return float(self.file_size or 0) > float(other.file_size or 0) - if column == PROGRESS_COL: - return int((self.file_progress or 0) * 100) > int((other.file_progress or 0) * 100) - return self.text(column) > other.text(column) - - -class TorrentFileTreeWidget(QTreeWidget): - selected_files_changed = pyqtSignal() - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.total_files_size = None - connect(self.itemChanged, self.update_selected_files_size) - self.header().setStretchLastSection(False) - - self.selected_files_size = 0 - - self.header().setSortIndicator(FILENAME_COL, Qt.DescendingOrder) - - @property - def is_empty(self): - return self.topLevelItemCount() == 0 - - def clear(self): - self.total_files_size = None - super().clear() - - def fill_entries(self, files): - if not files: - return - - # Block the signals to prevent unnecessary recalculation of directory sizes - self.blockSignals(True) - self.clear() - - # ACHTUNG! - # Workaround for QT eliding size text too aggressively, resulting in incorrect column size - # The downside is no eliding for the names column - self.setTextElideMode(Qt.ElideNone) - self.header().setSectionResizeMode(QHeaderView.ResizeToContents) - single_item_torrent = len(files) == 1 - - # !!! ACHTUNG !!! - # The styling must be applied right before or right after filling the table, - # otherwise it won't work properly. - self.setStyleSheet(TORRENT_FILES_TREE_STYLESHEET) - - self.total_files_size = 0 - items = {'': self} - for file_index, file in enumerate(files): - path = file['path'] - for i, obj_name in enumerate(path): - parent_path = "/".join(path[:i]) - full_path = "/".join(path[: i + 1]) - if full_path in items: - continue - - is_file = i == len(path) - 1 - - if i >= MAX_ALLOWED_RECURSION_DEPTH: - is_file = True - obj_name = "/".join(path[i:]) - full_path = "/".join(path) - - item = items[full_path] = DownloadFileTreeWidgetItem( - items[parent_path], - file_index=file_index if is_file else None, - file_progress=file.get('progress'), - ) - item.setText(FILENAME_COL, obj_name) - item.setData(FILENAME_COL, Qt.UserRole, obj_name) - - file_included = file.get('included', True) - - item.setCheckState(CHECKBOX_COL, Qt.Checked if file_included else Qt.Unchecked) - - if single_item_torrent: - item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) - - if is_file: - # Add file size info for file entries - item.file_size = int(file['length']) - self.total_files_size += item.file_size - item.setText(SIZE_COL, format_size(float(file['length']))) - break - - # Make folder checkboxes automatically affect subtree items - item.setFlags(item.flags() | Qt.ItemIsAutoTristate) - - for ind in range(self.topLevelItemCount()): - self.topLevelItem(ind).fill_directory_sizes() - - # Automatically open the toplevel item - if self.topLevelItemCount() == 1: - item = self.topLevelItem(0) - if item.childCount() > 0: - self.expandItem(item) - - self.blockSignals(False) - self.selected_files_size = sum( - item.file_size for item in self.get_selected_items() if item.file_index is not None - ) - - def update_progress(self, updates, force_update=False, draw_progress_bars=False): - self.blockSignals(True) - if draw_progress_bars: - # make vertical space for progress bars - stylesheet = ( - TORRENT_FILES_TREE_STYLESHEET_NO_ITEM - + """ - TorrentFileTreeWidget::item { color: white; padding-top: 0px; padding-bottom: 0px; } - """ - ) - self.setStyleSheet(stylesheet) - updates_dict = {} - for upd in updates: - updates_dict[upd['index']] = upd['progress'] - for ind in range(self.topLevelItemCount()): - item = self.topLevelItem(ind) - item.subtree_progress_update(updates_dict, force_update=force_update, draw_progress_bars=draw_progress_bars) - self.blockSignals(False) - - def get_selected_items(self): - selected_items = [] - for ind in range(self.topLevelItemCount()): - item = self.topLevelItem(ind) - for subitem in item.subtree( - filter_by=lambda x: x.checkState(CHECKBOX_COL) in (Qt.PartiallyChecked, Qt.Checked) - ): - if subitem.checkState(CHECKBOX_COL) == Qt.Checked: - selected_items.append(subitem) - return selected_items - - def get_selected_files_indexes(self): - return [item.file_index for item in self.get_selected_items() if item.file_index is not None] - - def update_selected_files_size(self, item, _): - # We only process real files to avoid double counting - if item.file_index is None: - return - - if item.checkState(CHECKBOX_COL): - self.selected_files_size += item.file_size - else: - self.selected_files_size -= item.file_size - - -@dataclass -class FilesPage: - """ - A page of file/directory names (and their selected status) in the PreformattedTorrentFileTreeWidget. - """ - - query: Path - """ - The query that was used (loaded=True) OR can be used (loaded=False) to fetch this page. - """ - - index: int - """ - The index of this page in the "PreformattedTorrentFileTreeWidget.pages" list. - """ - - states: dict[Path, int] = field(default_factory=dict) - """ - All Paths belonging to this page and their Qt.CheckState (unselected, partially selected, or selected). - """ - - loaded: bool = False - """ - Whether this is a placeholder (when still unloaded or after memory has been freed) or fully loaded. - """ - - next_query: Path | None = None - """ - The Path to use for the next page, or None if there is no next page to be fetched. - """ - - def load(self, states: dict[Path, int]) -> None: - """ - Load this page from the given states. - """ - self.states = states - self.loaded = True - - # This is black magic: we want to peek the last added entry (the next query) but there is no method for this. - # Instead, popitem() removes the last entry, which we then add again (note: this does not violate the order!). - with suppress(KeyError): - k, v = states.popitem() - self.states[k] = v - self.next_query = k - - def unload(self) -> None: - """ - Unload the states to free up some memory and lessen the front-end load of shifting rows and selecting files. - - The "query" can be used to fetch the states again. - """ - self.states = {} - self.loaded = False - self.next_query = None - - def num_files(self) -> int: - """ - Return the number of files in this page. - """ - return len(self.states) - - def is_last_page(self): - """ - Whether there are more pages to be fetched after this page. - """ - return self.loaded and len(self.states) == 0 - - @staticmethod - def path_to_sort_key(path: Path): - """ - We mimic the sorting of the underlying TorrentFileTree to avoid Qt messing up our pages. - """ - return tuple(int(part) if part.isdigit() else part for part in NAT_SORT_PATTERN.split(str(path))) - - def __lt__(self, other: FilesPage | Path) -> bool: - """ - Python 3.8 quirk/shortcoming is that FilesPage needs to be a SupportsRichComparisonT (instead of using a key). - """ - query = self.path_to_sort_key(self.query) - other_query = self.path_to_sort_key(other) if isinstance(other, Path) else self.path_to_sort_key(other.query) - return query < other_query - - def __le__(self, other: FilesPage | Path) -> bool: - """ - Python 3.8 quirk/shortcoming is that FilesPage needs to be a SupportsRichComparisonT (instead of using a key). - """ - query = self.path_to_sort_key(self.query) - other_query = self.path_to_sort_key(other) if isinstance(other, Path) else self.path_to_sort_key(other.query) - return query <= other_query - - def __gt__(self, other: FilesPage | Path) -> bool: - """ - Python 3.8 quirk/shortcoming is that FilesPage needs to be a SupportsRichComparisonT (instead of using a key). - """ - query = self.path_to_sort_key(self.query) - other_query = self.path_to_sort_key(other) if isinstance(other, Path) else self.path_to_sort_key(other.query) - return query > other_query - - def __ge__(self, other: FilesPage | Path) -> bool: - """ - Python 3.8 quirk/shortcoming is that FilesPage needs to be a SupportsRichComparisonT (instead of using a key). - """ - query = self.path_to_sort_key(self.query) - other_query = self.path_to_sort_key(other) if isinstance(other, Path) else self.path_to_sort_key(other.query) - return query >= other_query - - def __eq__(self, other: FilesPage | Path) -> bool: - """ - Python 3.8 quirk/shortcoming is that FilesPage needs to be a SupportsRichComparisonT (instead of using a key). - """ - query = self.path_to_sort_key(self.query) - other_query = self.path_to_sort_key(other) if isinstance(other, Path) else self.path_to_sort_key(other.query) - return query == other_query - - def __ne__(self, other: FilesPage | Path) -> bool: - """ - Python 3.8 quirk/shortcoming is that FilesPage needs to be a SupportsRichComparisonT (instead of using a key). - """ - query = self.path_to_sort_key(self.query) - other_query = self.path_to_sort_key(other) if isinstance(other, Path) else self.path_to_sort_key(other.query) - return query != other_query - - -class PreformattedTorrentFileTreeWidget(QTableWidget): - """ - A widget for paged file views that use an underlying (core process) TorrentFileTree. - """ - - def __init__(self, parent: QWidget | None, page_size: int = 20, view_size_pre: int = 1, view_size_post: int = 2): - """ - - :param page_size: The number of items (directory/file Paths) per page. - :param view_size_pre: The number of pages to keep preloaded "above" the visible items. - :param view_size_post: The number of pages to keep preloaded "below" the visible items. - """ - super().__init__(1, 4, parent) - - # Parameters - self.page_size = page_size - self.view_size_pre = view_size_pre - self.view_size_post = view_size_post - - # Torrent information variables - self.infohash = None - self.pages: list[FilesPage] = [FilesPage(Path('.'), 0)] - - # View related variables - self.view_start_index: int = 0 - self.previous_view_start_index: int | None = None - - # GUI state variables - self.exp_contr_requests: dict[Path, int] = {} - - # Setup vertical scrollbar - self.verticalScrollBar().setPageStep(self.page_size) - self.verticalScrollBar().setSingleStep(1) - self.reset_scroll_bar() - self.setAutoScroll(False) - - # Setup (hide) table columns - self.horizontalHeader().hide() - self.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) - self.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) - self.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) - self.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) - self.horizontalHeader().setContentsMargins(0, 0, 0, 0) - self.setShowGrid(False) - - self.verticalHeader().hide() - - # Setup selection and focus modes - self.setEditTriggers(PyQt5.QtWidgets.QAbstractItemView.NoEditTriggers) - self.setSelectionBehavior(PyQt5.QtWidgets.QAbstractItemView.SelectRows) - self.setSelectionMode(PyQt5.QtWidgets.QAbstractItemView.NoSelection) - self.setFocusPolicy(Qt.NoFocus) - - # Set style - self.setStyleSheet(""" - QTableView::item::hover { background-color: rgba(255,255,255, 0); } - QTableView::item:selected{ background-color: #444; } - """) - - # Reset the underlying data - self.clear() - - # Initialize signals and moving graphics - connect(self.itemClicked, self.item_clicked) - - self.loading_movie = QMovie() - self.loading_movie.setFileName(get_image_path("spinner.gif")) - connect(self.loading_movie.frameChanged, self.spin_spinner) - - def clear(self) -> None: - """ - Clear the table data and then add a spinner to signify the loading state. - """ - super().clear() - - self.pages: list[FilesPage] = [FilesPage(Path('.'), 0)] - self.infohash = None - - loading_icon = QIcon(get_image_path("spinner.gif")) - self.loading_widget = QTableWidgetItem(loading_icon, "", QTableWidgetItem.UserType) - - self.setSelectionMode(PyQt5.QtWidgets.QAbstractItemView.NoSelection) - - self.setRowCount(1) - self.setItem(0, 1, self.loading_widget) - - def reset_scroll_bar(self) -> None: - """ - Make sure we never reach the end of the scrollbar, as long as we are not on the first or last page. - - This allows "scroll down" and "scroll up" even though we reached the end of the data loaded in the GUI. - Otherwise, we could get "stuck" on a page that is not the last page. - """ - first_visible_row = self.rowAt(0) - last_visible_row = self.rowAt(self.height() - 1) - if self.verticalScrollBar().sliderPosition() == self.verticalScrollBar().minimum(): - if first_visible_row != -1 and self.row_to_page_index(first_visible_row) != 0: - self.verticalScrollBar().blockSignals(True) - self.verticalScrollBar().setSliderPosition(1) - self.verticalScrollBar().blockSignals(False) - elif self.verticalScrollBar().sliderPosition() == self.verticalScrollBar().maximum(): - if last_visible_row != -1 and self.row_to_page_index(last_visible_row) != len(self.pages) - 1: - self.verticalScrollBar().blockSignals(True) - self.verticalScrollBar().setSliderPosition(self.verticalScrollBar().maximum() - 1) - self.verticalScrollBar().blockSignals(False) - - def row_to_page_index(self, row: int) -> int: - """ - Convert a row index to a page index. - - Because of the underlying view construction all pages are equal to the page_size, except for the last page. - """ - return self.view_start_index + row // self.page_size - - def initialize(self, infohash: str): - """ - Set the current infohash and fetch its first page after unloading or when first initializing. - - NOTE: This widget is reused between infohashes. - """ - self.infohash = infohash - self.fetch_page(0) - - def item_clicked(self, clicked: QTableWidgetItem): - """ - The user clicked on a cell. - - Figure out if we should update the selection or expanded/collapsed state. If we do, let the core know. - - Case 1: When the user clicks the checkbox we want to update the selection state and exit. - Case 2: When the user clicks next to the checkbox (or there is no checkbox) we expand/collapse a directory. - """ - file_desc = clicked.data(Qt.UserRole) - # Determine if we are in case 1: if the clicked cell doesn't even have a checkbox, we don't investigate further. - if isinstance(file_desc, dict): - is_checked = clicked.checkState() - was_checked = Qt.Checked - for page in reversed(self.pages): - file_path = Path(file_desc["name"]) - if file_path in page.states: - was_checked, page.states[file_path] = page.states[file_path], is_checked - break - # The checkbox state changed, meaning the user actually clicked the checkbox. - if is_checked != was_checked: - modifier = "select" if is_checked == Qt.Checked else "deselect" - request_manager.get(f"downloads/{self.infohash}/files/{modifier}", - url_params={"path": file_desc["name"]}) - # Don't wait for a core refresh but immediately update all loaded rows with the expected check status. - for row in range(self.rowCount()): - item = self.item(row, 0) - user_data = item.data(Qt.UserRole) - if user_data["name"].startswith(file_desc["name"]): - item.setCheckState(is_checked) - self.pages[user_data["page"]].states[Path(user_data["name"])] = is_checked - return # End of case 1, exit out! - # Case 2: We would've returned if a checkbox got toggled, this is a collapse/expand event! - if clicked in self.selectedItems(): - # Only the first widget stores the data but the entire row can be clicked. - expand_select_widget, _, _, _ = self.selectedItems() - file_desc = expand_select_widget.data(Qt.UserRole) - if file_desc["index"] in [-1, -2]: - self.exp_contr_requests[file_desc["name"]] = self.row_to_page_index(self.row(clicked)) - mode = "expand" if file_desc["index"] == -1 else "collapse" - request_manager.get( - endpoint=f"downloads/{self.infohash}/files/{mode}", - url_params={ - 'path': file_desc["name"] - }, - on_success=self.on_expanded_contracted, - ) - - def fetch_page(self, page_index: int) -> None: - """ - Query the core for a given page index. - """ - search_path = None - if page_index < len(self.pages): - # We have fetched the query for this page before. - search_path = self.pages[page_index].query - elif page_index == len(self.pages): - # We need to check the previous page for the next query. - if self.pages[page_index - 1].next_query is not None: - search_path = self.pages[page_index - 1].next_query - - if search_path is None: - # This can happen if we request a page more than 1 page past our currently loaded pages or if there are no - # more pages to load. - # Whatever the case, we can't fetch pages without a query and we simply exit out. - return - - request_manager.get( - endpoint=f"downloads/{self.infohash}/files", - url_params={ - 'view_start_path': search_path, - 'view_size': self.page_size - }, - on_success=self.fill_entries, - ) - - def free_page(self, page_index: int) -> None: - """ - Free memory for a single page. - """ - if page_index != 0 and page_index == len(self.pages) - 1: - self.pages.pop() - else: - self.pages[page_index].unload() - - def truncate_pages(self, page_index: int) -> None: - """ - Truncate the list of pages to only CONTAIN UP TO a given page index. - - For example, truncating for page index 3 of the page list [0, 1, 2, 3, 4] will remove the page at index 4. - """ - self.pages = self.pages[:(page_index + 1)] - - def scrollContentsBy(self, dx: int, dy: int) -> None: - """ - The user scrolled. Do the infinite scroll thing. - """ - super().scrollContentsBy(dx, dy) - self.reset_scroll_bar() # Do not allow the user to get into an unrecoverable state, even if we don't update! - - if dy == 0: - # No vertical scroll, no change in content - return - - first_visible_row = self.rowAt(0) - if first_visible_row == -1: - # Scrolling without content - self.fetch_page(self.view_start_index) - return - - last_visible_row = self.rowAt(self.height() - 1) - if last_visible_row == -1: - # Scrolling when already at the end - self.fetch_page(len(self.pages)) - return - - first_visible_page = self.row_to_page_index(first_visible_row) - last_visible_page = self.row_to_page_index(last_visible_row) - self.previous_view_start_index = self.view_start_index - self.view_start_index = max(0, first_visible_page - self.view_size_pre) - - if dy < 0: - # Scrolling down - last_loaded_page = len(self.pages) - 1 - if last_visible_page + self.view_size_post >= last_loaded_page: - # Not enough pages! Load more! - self.fetch_page(len(self.pages)) - else: - # Scrolling up - self.truncate_pages(last_visible_page + self.view_size_post) - for page_index in range(self.previous_view_start_index, - max(0, self.view_start_index - self.view_size_pre), - -1): - # Reload any unloaded pages! - if not self.pages[page_index].loaded: - self.fetch_page(page_index) - self.reset_scroll_bar() - - # Hard refresh all visible rows, in case of really fast scrolling and tearing. This is important on slower - # machines, which can "outscroll" the Qt updates. - self.refresh_visible() - - def on_expanded_contracted(self, response) -> None: - """ - The core finished expanding or collapsing a directory. - - ALL pages after and including the page that the expansion/collapse happened need to be refreshed. - """ - if response is None: - return - - page_index = self.exp_contr_requests.pop(response["path"]) - if page_index is None: - return - page_index = max(0, page_index - 1) - - self.truncate_pages(page_index) - - request_manager.get( - endpoint=f"downloads/{self.infohash}/files", - url_params={ - 'view_start_path': self.pages[page_index].query, - 'view_size': self.page_size - }, - on_success=self.fill_entries, - ) - - def hideEvent(self, a0) -> None: - """ - We are not shown, no need to do the loading animation. - """ - super().hideEvent(a0) - self.loading_movie.stop() - - def showEvent(self, a0) -> None: - """ - We are shown, continue the loading animation. - """ - super().showEvent(a0) - self.loading_movie.start() - - def resizeEvent(self, e) -> None: - """ - We got resized, causing the previous first and last visible row to be invalidated: perform a hard refresh. - """ - super().resizeEvent(e) - if self.isVisible(): - self.refresh_visible() - - def refresh_visible(self) -> None: - """ - Determine the visible rows and refresh what we are missing. - - Note that fetch_page will drop unattainable pages beyond our current knowledge and fill_entries will recursively - pull those pages in afterward. - """ - first_visible_row = self.rowAt(0) - if first_visible_row == -1: - first_visible_row = 0 - last_visible_row = self.rowAt(self.height() - 1) - if last_visible_row == -1: - last_visible_row = first_visible_row - for page_index in (range(self.view_start_index, self.view_start_index + last_visible_row - first_visible_row) - or [self.view_start_index]): - self.fetch_page(page_index) - - def spin_spinner(self, _) -> None: - """ - Perform the loading square spinning animation. - - Note that the spinner object may be suddenly removed when Qt fills in the table with our data. - """ - if self.isVisible(): - with suppress(RuntimeError): - self.loading_widget.setIcon(QIcon(self.loading_movie.currentPixmap())) - - def format_size(self, size_in_bytes: int) -> str: - """ - Stringify the given number of bytes to more human-readable units. - """ - if size_in_bytes < KB: - return f"{size_in_bytes} bytes" - if size_in_bytes < MB: - return f"{round(size_in_bytes / KB, 2)} KB" - if size_in_bytes < GB: - return f"{round(size_in_bytes / MB, 2)} MB" - if size_in_bytes < TB: - return f"{round(size_in_bytes / GB, 2)} GB" - if size_in_bytes < PB: - return f"{round(size_in_bytes / TB, 2)} TB" - return f"{round(size_in_bytes / PB, 2)} PB" - - def render_to_table(self, row: int, page_index: int, states: dict[Path, int], file_desc) -> None: - """ - Render the core's download endpoint response data for a single file (file_desc) to the given row in our table - and store the state in the given states dir for the given page index. - - Note that - at this point - the given states dir is not complete yet and not loaded in the page index yet. We - use this to our advantage when remembering directory states in between updates. - """ - description = file_desc["name"] - file_desc["page"] = page_index - collapse_icon = " " - if file_desc["index"] >= 0: - # Indent with file depth and only show name - *folders, name = Path(description).parts - description = len(folders) * " " + name - else: - collapse_icon = ("\u1405 " if file_desc["index"] == -1 else "\u1401 ") - - # File name - file_name_widget = QTableWidgetItem(description) - file_name_widget.setTextAlignment(Qt.AlignVCenter) - - # Checkbox and expansion arrow - expand_select_widget = QTableWidgetItem(collapse_icon) - expand_select_widget.setTextAlignment(Qt.AlignCenter) - expand_select_widget.setData(Qt.UserRole, file_desc) - expand_select_widget.setFlags(expand_select_widget.flags() | Qt.ItemIsTristate | Qt.ItemIsUserCheckable) - - if file_desc["included"]: - expand_select_widget.setCheckState(Qt.Checked) - states[Path(file_desc["name"])] = Qt.Checked - elif file_desc["index"] >= 0: - expand_select_widget.setCheckState(Qt.Unchecked) - states[Path(file_desc["name"])] = Qt.Unchecked - else: - # Directory, determine from previous state - checked_state = self.pages[page_index].states.get(file_desc["name"], Qt.Checked) - states[Path(file_desc["name"])] = checked_state - expand_select_widget.setCheckState(checked_state) - - # File size - file_size_widget = QTableWidgetItem(self.format_size(file_desc['size'])) - - # Progress - # Note: directories are derived: they are not a real entry in the torrent file list and they have no completion. - file_progress_widget = QTableWidgetItem(f"{round(file_desc['progress'] * 100.0, 2)} %" - if file_desc["index"] >= 0 else "") - - self.setItem(row, 0, expand_select_widget) - self.setItem(row, 1, file_name_widget) - self.setItem(row, 2, file_size_widget) - self.setItem(row, 3, file_progress_widget) - - def render_page_to_table(self, page_index: int, files_dict) -> None: - """ - Given the core's response to the view that we requested at a given page index, fill our table. - """ - base_row = (page_index - self.view_start_index) * self.page_size - states: dict[Path, int] = {} - for row, file_desc in enumerate(files_dict): - target_row = base_row + row - if 0 <= target_row < self.rowCount(): - self.render_to_table(target_row, page_index, states, file_desc) - self.pages[page_index].load(states) - - def shift_pages(self, previous_row_count: int) -> None: - """ - Shift the old data in our table after a scroll. This avoids waiting for hard refreshes (slow). However, - sometimes the user "outscrolls" Qt and hard refreshes have to be used to fill in the gaps. Therefore, this - method should be used to complement hard refreshes, not replace them. - """ - if self.view_start_index < self.previous_view_start_index: - # Move existing down, start from last existing item. - shift = (self.previous_view_start_index - self.view_start_index) * self.page_size - for row in range(self.rowCount() - 1, self.rowCount() - previous_row_count - shift + 1, -1): - if row < 0 or row - shift < 0: - return - self.setItem(row, 0, self.takeItem(row - shift, 0)) - self.setItem(row, 1, self.takeItem(row - shift, 1)) - self.setItem(row, 2, self.takeItem(row - shift, 2)) - self.setItem(row, 3, self.takeItem(row - shift, 3)) - elif self.view_start_index > self.previous_view_start_index: - # Move existing up, start from the first existing item. - shift = (self.view_start_index - self.previous_view_start_index) * self.page_size - for row in range(0, previous_row_count - shift): - if row > self.rowCount() or row + shift > self.rowCount(): - return - self.setItem(row, 0, self.takeItem(row + shift, 0)) - self.setItem(row, 1, self.takeItem(row + shift, 1)) - self.setItem(row, 2, self.takeItem(row + shift, 2)) - self.setItem(row, 3, self.takeItem(row + shift, 3)) - - def fill_entries(self, entry_dict) -> None: - """ - Handle a raw core response. - """ - self.infohash = entry_dict['infohash'] - files_dict = entry_dict['files'] - num_files = len(files_dict) - - # Special loading response - if num_files == 1 and files_dict[0]["index"] == -3: - return - - self.blockSignals(True) - self.loading_movie.stop() # Stop loading and make interactive - - # Determine the page index of the given data and prepare the data structures for it. - query = Path(entry_dict["query"]) - current_page = bisect(self.pages, query) - total_files = sum(page.num_files() for page in self.pages[self.view_start_index:]) - if current_page - 1 >= 0 and self.pages[current_page - 1].query == query: - current_page -= 1 - if current_page == len(self.pages): - total_files += len(files_dict) - if self.pages[-1].next_query == query: - self.pages.append(FilesPage(query, current_page)) - else: - return - if len(self.pages) == 1 and query == Path("."): - total_files = len(files_dict) - - # Make space for all visible pages - previous_row_count = self.rowCount() - self.setRowCount(total_files) - - # First shift previous entries out of the way - if self.previous_view_start_index is not None: - self.shift_pages(previous_row_count) - self.previous_view_start_index = None - - # Inject the individual files - self.render_page_to_table(current_page, files_dict) - - self.setSelectionMode(PyQt5.QtWidgets.QAbstractItemView.SingleSelection) - - self.blockSignals(False) - - # Fill out the remaining area, if possible. - if self.rowAt(self.height() - 1) == -1 and not self.pages[-1].is_last_page(): - self.fetch_page(current_page + 1) - - self.reset_scroll_bar() diff --git a/src/tribler/gui/widgets/triblertablecontrollers.py b/src/tribler/gui/widgets/triblertablecontrollers.py deleted file mode 100644 index 578c79a820..0000000000 --- a/src/tribler/gui/widgets/triblertablecontrollers.py +++ /dev/null @@ -1,241 +0,0 @@ -""" -This file contains various controllers for table views. -The responsibility of the controller is to populate the table view with some data, contained in a specific model. -""" -import logging - -from PyQt5.QtCore import QObject, QTimer, Qt -from PyQt5.QtGui import QCursor -from PyQt5.QtNetwork import QNetworkRequest -from PyQt5.QtWidgets import QAction - -from tribler.core.database.serialization import REGULAR_TORRENT -from tribler.gui.defs import HEALTH_CHECKING, HEALTH_UNCHECKED -from tribler.gui.network.request_manager import request_manager -from tribler.gui.tribler_action_menu import TriblerActionMenu -from tribler.gui.utilities import connect, dict_item_is_any_of, tr -from tribler.gui.widgets.tablecontentmodel import Column - -HEALTHCHECK_DELAY_MS = 500 - - -class TriblerTableViewController(QObject): - """ - Base controller for a table view that displays some data. - """ - - def __init__(self, table_view, *args, filter_input=None, **kwargs): - super().__init__(*args, **kwargs) - - self.model = None - self.table_view = table_view - connect(self.table_view.verticalScrollBar().valueChanged, self._on_list_scroll) - - connect(self.table_view.delegate.download_button.clicked, self.table_view.start_download_from_index) - connect(self.table_view.torrent_doubleclicked, self.table_view.start_download_from_dataitem) - - self.filter_input = filter_input - if self.filter_input: - connect(self.filter_input.returnPressed, self.on_filter_input_return_pressed) - - def set_model(self, model): - self.model = model - self.table_view.setModel(self.model) - if self.model.saved_header_state: - self.table_view.horizontalHeader().restoreState(self.model.saved_header_state) - if self.model.saved_scroll_state is not None: - # ACHTUNG! Repeating this line is necessary due to a bug(?) in QT. Otherwise, it has no effect. - self.table_view.scrollTo(self.model.index(self.model.saved_scroll_state, 0), 1) - self.table_view.scrollTo(self.model.index(self.model.saved_scroll_state, 0), 1) - - def _on_list_scroll(self, event): # pylint: disable=W0613 - if ( - self.table_view.verticalScrollBar().value() == self.table_view.verticalScrollBar().maximum() - and self.model.data_items - and not self.model.all_local_entries_loaded - ): # workaround for duplicate calls to _on_list_scroll on view creation - self.model.perform_query() - - def _get_sort_parameters(self): - """ - Return a tuple (column_name, sort_desc) that indicates the sorting column/order of the table view. - """ - sort_column_number = self.table_view.horizontalHeader().sortIndicatorSection() - # If the column number is set to -1, this means we do not want to do sorting at all - # We have to set it to something (-1), because QT does not support setting it to "None" - sort_by = self.model.columns[sort_column_number].dict_key if sort_column_number >= 0 else None - sort_asc = self.table_view.horizontalHeader().sortIndicatorOrder() - return sort_by, sort_asc - - def on_filter_input_return_pressed(self): - self.model.text_filter = self.filter_input.text().lower() - self.model.reset() - - def brain_dead_refresh(self): - """ - ACHTUNG! Brain-dead refresh is back! - It shows the rows eaten by a closed channel description widget. - Note that none of the more civilized ways to fix it work: - various updateGeometry, viewport().update, adjustSize - nothing works! - """ - window = self.table_view.window() - window.resize(window.geometry().width() + 1, window.geometry().height()) - window.resize(window.geometry().width() - 1, window.geometry().height()) - - def unset_model(self): - self.model = None - - -class TableLoadingAnimationMixin: - def set_model(self, model): - if not model.loaded: - self.table_view.show_loading_animation_delayed() - connect(model.query_complete, self.table_view.hide_loading_animation) - connect(model.query_started, self.table_view.show_loading_animation_delayed) - super().set_model(model) - - def unset_model(self): - if self.table_view.model: - self.model.query_complete.disconnect() - self.model.query_started.disconnect() - - self.table_view.hide_loading_animation() - super().unset_model() - - -class TableSelectionMixin: - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.healthcheck_cooldown = QTimer() - self.healthcheck_cooldown.setSingleShot(True) - - # When the user stops scrolling and selection settles on a row, - # trigger the health check. - connect(self.healthcheck_cooldown.timeout, lambda: self._on_selection_changed(None, None)) - - def set_model(self, model): - super().set_model(model) - connect(self.table_view.selectionModel().selectionChanged, self._on_selection_changed) - - def unset_model(self): - if self.table_view.model: - self.table_view.selectionModel().selectionChanged.disconnect() - super().unset_model() - - def _on_selection_changed(self, selected, deselected): # pylint: disable=W0613 - selected_indices = self.table_view.selectedIndexes() - if not selected_indices: - self.table_view.clearSelection() - return - - data_item = selected_indices[-1].model().data_items[selected_indices[-1].row()] - if not dict_item_is_any_of(data_item, 'type', [REGULAR_TORRENT]): - return - - if issubclass(type(self), HealthCheckerMixin): - # Trigger health check if necessary - # When the user scrolls the list, we only want to trigger health checks on the line - # that the user stopped on, so we do not generate excessive health checks. - if data_item['last_tracker_check'] == 0 and data_item.get('health') != HEALTH_CHECKING: - if self.healthcheck_cooldown.isActive(): - self.healthcheck_cooldown.stop() - else: - self.check_torrent_health(data_item) - self.healthcheck_cooldown.start(HEALTHCHECK_DELAY_MS) - - -class HealthCheckerMixin: - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.health_checker_logger = logging.getLogger('HealthCheckerMixin') - - connect( - self.table_view.delegate.health_status_widget.clicked, - lambda index: self.check_torrent_health(index.model().data_items[index.row()], forced=True), - ) - connect(self.table_view.torrent_clicked, self.check_torrent_health) - - def check_torrent_health(self, data_item, forced=False): - if not dict_item_is_any_of(data_item, 'type', [REGULAR_TORRENT]): - return - if Column.HEALTH not in self.model.column_position: - return - # Check if the entry still exists in the table - infohash = data_item['infohash'] - row = self.model.item_uid_map.get(infohash) - if row is None: - return - - if not forced and data_item.get('health', HEALTH_UNCHECKED) != HEALTH_UNCHECKED: - return - data_item['health'] = HEALTH_CHECKING - health_cell_index = self.model.index(row, self.model.column_position[Column.HEALTH]) - self.model.dataChanged.emit(health_cell_index, health_cell_index, []) - request_manager.get( - f"metadata/torrents/{infohash}/health", - capture_errors=False, - priority=QNetworkRequest.LowPriority - ) - - -class ContextMenuMixin: - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.enable_context_menu(self.table_view) - - def enable_context_menu(self, widget): - self.table_view = widget - self.table_view.setContextMenuPolicy(Qt.CustomContextMenu) - connect(self.table_view.customContextMenuRequested, self._show_context_menu) - - def _trigger_name_editor(self, index): - model = index.model() - title_index = model.index(index.row(), model.columns_shown.index(Column.NAME)) - self.table_view.edit(title_index) - - def _trigger_category_editor(self, index): - model = index.model() - title_index = model.index(index.row(), model.columns_shown.index(Column.CATEGORY)) - self.table_view.edit(title_index) - - def _show_context_menu(self, pos): - if not self.table_view or not self.model: - return - - item_index = self.table_view.indexAt(pos) - if not item_index or item_index.row() < 0: - return - - menu = TriblerActionMenu(self.table_view) - - # Single selection menu items - num_selected = len(self.table_view.selectionModel().selectedRows()) - if num_selected == 1 and item_index.model().data_items[item_index.row()]["type"] == REGULAR_TORRENT: - self.add_menu_item(menu, tr(" Download "), item_index, self.table_view.start_download_from_index) - if issubclass(type(self), HealthCheckerMixin): - self.add_menu_item( - menu, - tr(" Recheck health"), - item_index.model().data_items[item_index.row()], - lambda x: self.check_torrent_health(x, forced=True), - ) - - menu.exec_(QCursor.pos()) - - def add_menu_item(self, menu, name, item_index, callback): - action = QAction(name, self.table_view) - connect(action.triggered, lambda _: callback(item_index)) - menu.addAction(action) - - -class PopularContentTableViewController( - TableSelectionMixin, ContextMenuMixin, TableLoadingAnimationMixin, TriblerTableViewController -): - pass - - -class ContentTableViewController( - TableSelectionMixin, ContextMenuMixin, HealthCheckerMixin, TableLoadingAnimationMixin, TriblerTableViewController -): - pass diff --git a/src/tribler/gui/widgets/underlinetabbutton.py b/src/tribler/gui/widgets/underlinetabbutton.py deleted file mode 100644 index 8c5b6a51eb..0000000000 --- a/src/tribler/gui/widgets/underlinetabbutton.py +++ /dev/null @@ -1,16 +0,0 @@ -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QToolButton - - -class UnderlineTabButton(QToolButton): - """ - This class is responsible for the buttons in the tab panels that can often be found at the top of the page. - """ - - clicked_tab_button = pyqtSignal(object) - - def __init__(self, parent): - QToolButton.__init__(self, parent) - - def mouseReleaseEvent(self, _): - self.clicked_tab_button.emit(self) From c8ef81e9c30228cc8ed7268dd45a9ca0deba2bac Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Fri, 14 Jun 2024 12:53:05 +0200 Subject: [PATCH 08/13] Increase timeout for getting metainfo --- src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py | 4 ++-- .../core/libtorrent/restapi/test_torrentinfo_endpoint.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py b/src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py index 71b998b14b..2f9c530907 100644 --- a/src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py +++ b/src/tribler/core/libtorrent/restapi/torrentinfo_endpoint.py @@ -172,7 +172,7 @@ async def get_torrent_info(self, request: Request) -> RESTResponse: # noqa: C90 status=HTTP_INTERNAL_SERVER_ERROR ) - metainfo = await self.download_manager.get_metainfo(infohash, timeout=10.0, hops=i_hops, + metainfo = await self.download_manager.get_metainfo(infohash, timeout=60, hops=i_hops, url=response.decode()) else: metainfo = lt.bdecode(response) @@ -191,7 +191,7 @@ async def get_torrent_info(self, request: Request) -> RESTResponse: # noqa: C90 {"error": f'Error while getting an infohash from magnet: {e.__class__.__name__}: {e}'}, status=HTTP_BAD_REQUEST ) - metainfo = await self.download_manager.get_metainfo(infohash, timeout=10.0, hops=i_hops, url=uri) + metainfo = await self.download_manager.get_metainfo(infohash, timeout=60, hops=i_hops, url=uri) else: return RESTResponse({"error": "invalid uri"}, status=HTTP_BAD_REQUEST) diff --git a/src/tribler/test_unit/core/libtorrent/restapi/test_torrentinfo_endpoint.py b/src/tribler/test_unit/core/libtorrent/restapi/test_torrentinfo_endpoint.py index c4b224da80..ed0f6b00a5 100644 --- a/src/tribler/test_unit/core/libtorrent/restapi/test_torrentinfo_endpoint.py +++ b/src/tribler/test_unit/core/libtorrent/restapi/test_torrentinfo_endpoint.py @@ -166,7 +166,7 @@ async def test_get_torrent_info_magnet_no_metainfo(self) -> None: self.assertEqual(HTTP_INTERNAL_SERVER_ERROR, response.status) self.assertEqual("metainfo error", response_body_json["error"]) self.assertEqual(b"\x01" * 20, self.download_manager.get_metainfo.call_args.args[0]) - self.assertEqual(10, self.download_manager.get_metainfo.call_args.kwargs["timeout"]) + self.assertEqual(60, self.download_manager.get_metainfo.call_args.kwargs["timeout"]) self.assertEqual(0, self.download_manager.get_metainfo.call_args.kwargs["hops"]) self.assertEqual("magnet://", self.download_manager.get_metainfo.call_args.kwargs["url"]) @@ -403,7 +403,7 @@ async def test_get_torrent_info_http_redirect_magnet_no_metainfo(self) -> None: self.assertEqual(HTTP_INTERNAL_SERVER_ERROR, response.status) self.assertEqual("metainfo error", response_body_json["error"]) self.assertEqual(b"\x01" * 20, self.download_manager.get_metainfo.call_args.args[0]) - self.assertEqual(10.0, self.download_manager.get_metainfo.call_args.kwargs["timeout"]) + self.assertEqual(60.0, self.download_manager.get_metainfo.call_args.kwargs["timeout"]) self.assertEqual(0, self.download_manager.get_metainfo.call_args.kwargs["hops"]) self.assertEqual("magnet://", self.download_manager.get_metainfo.call_args.kwargs["url"]) @@ -426,7 +426,7 @@ async def test_get_torrent_info_https_redirect_magnet_no_metainfo(self) -> None: self.assertEqual(HTTP_INTERNAL_SERVER_ERROR, response.status) self.assertEqual("metainfo error", response_body_json["error"]) self.assertEqual(b"\x01" * 20, self.download_manager.get_metainfo.call_args.args[0]) - self.assertEqual(10.0, self.download_manager.get_metainfo.call_args.kwargs["timeout"]) + self.assertEqual(60.0, self.download_manager.get_metainfo.call_args.kwargs["timeout"]) self.assertEqual(0, self.download_manager.get_metainfo.call_args.kwargs["hops"]) self.assertEqual("magnet://", self.download_manager.get_metainfo.call_args.kwargs["url"]) From 0035a04f1b8db7d5c9a0c07c21800c114dd8cc59 Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Fri, 14 Jun 2024 17:27:18 +0200 Subject: [PATCH 09/13] Changed API key/port config logic --- src/run_tribler.py | 18 ++++++++--- src/tribler/core/restapi/rest_manager.py | 32 +++++++++---------- .../core/restapi/test_rest_manager.py | 12 +------ 3 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/run_tribler.py b/src/run_tribler.py index 6cde856ed7..d73773121a 100644 --- a/src/run_tribler.py +++ b/src/run_tribler.py @@ -60,8 +60,7 @@ async def main() -> None: root_state_dir = get_root_state_directory(os.environ.get('TSTATEDIR', 'state_directory')) logger.info("Root state dir: %s", root_state_dir) - api_port, api_key = int(os.environ.get('CORE_API_PORT', '-1')), os.environ.get('CORE_API_KEY') - logger.info("Start tribler core. API port: %d. API key: %s.", api_port, api_key) + api_port, api_key = int(os.environ.get('CORE_API_PORT', '0')), os.environ.get('CORE_API_KEY') config = TriblerConfigManager(root_state_dir / "configuration.json") config.set("state_dir", str(root_state_dir)) @@ -70,17 +69,26 @@ async def main() -> None: config.set("api/http_port", 0) config.set("api/https_port", 0) - if api_key != config.get("api/key"): + if api_key is None and config.get("api/key") is None: + api_key = os.urandom(16).hex() + + if api_key is not None and api_key != config.get("api/key"): config.set("api/key", api_key) config.write() + if api_port is not None and api_port != config.get("api/http_port"): + config.set("api/http_port", api_port) + config.write() + + logger.info("Start tribler core. API port: %d. API key: %s.", api_port, config.get("api/key")) + session = Session(config) await session.start() image_path = Path(__file__).absolute() / "../tribler/ui/public/tribler.png" image = Image.open(image_path.resolve()) - real_api_port = config.get("api/http_port") - menu = (pystray.MenuItem('Open', lambda: webbrowser.open_new_tab(f'http://localhost:{real_api_port}')), + url = f"http://localhost:{session.rest_manager.get_api_port()}/ui/#/downloads/all?key={config.get('api/key')}" + menu = (pystray.MenuItem('Open', lambda: webbrowser.open_new_tab(url)), pystray.MenuItem('Quit', lambda: session.shutdown_event.set())) icon = pystray.Icon("Tribler", icon=image, title="Tribler", menu=menu) threading.Thread(target=icon.run).start() diff --git a/src/tribler/core/restapi/rest_manager.py b/src/tribler/core/restapi/rest_manager.py index adc84b028f..619404e0b3 100644 --- a/src/tribler/core/restapi/rest_manager.py +++ b/src/tribler/core/restapi/rest_manager.py @@ -73,7 +73,7 @@ def authenticate(self, request: Request) -> bool: if any(request.path.startswith(path) for path in ["/docs", "/static", "/ui"]): return True # The api key can either be in the headers or as part of the url query - api_key = request.headers.get("X-Api-Key") or request.query.get("apikey") or request.cookies.get("api_key") + api_key = request.headers.get("X-Api-Key") or request.query.get("key") or request.cookies.get("api_key") expected_api_key = self.api_key return not expected_api_key or expected_api_key == api_key @@ -113,6 +113,10 @@ async def error_middleware(request: Request, handler: Callable[[Request], Awaita @web.middleware async def ui_middleware(request: Request, handler: Callable[[Request], Awaitable[RESTResponse]]) -> RESTResponse: + """ + Forward request to a unknown pathname to /ui. + This enables the GUI to request e.g. /index.html instead of using /ui/index.html. + """ if not any(request.path.startswith(path) for path in ["/docs", "/static", "/ui", "/api"]): raise web.HTTPFound('/ui' + request.rel_url.path) return await handler(request) @@ -149,7 +153,7 @@ def __init__(self, config: TriblerConfigManager, shutdown_timeout: int = 1) -> N """ super().__init__() self._logger = logging.getLogger(self.__class__.__name__) - self.root_endpoint = RootEndpoint(middlewares=(ApiKeyMiddleware(config.get("api/key")), ui_middleware, + self.root_endpoint = RootEndpoint(middlewares=(ui_middleware, ApiKeyMiddleware(config.get("api/key")), error_middleware, required_components_middleware)) self.runner: web.AppRunner | None = None self.site: web.TCPSite | None = None @@ -174,13 +178,13 @@ def get_endpoint(self, name: str) -> RESTEndpoint: """ return self.root_endpoint.endpoints.get(name) - def set_api_port(self, api_port: int) -> None: + def get_api_port(self) -> int | None: """ - Set the API port and write the config to disk. + Get the API port of the currently running server. """ - if self.config.get("api/http_port") != api_port: - self.config.set("api/http_port", api_port) - self.config.write() + if self.site: + return cast(Server, self.site._server).sockets[0].getsockname()[1] # noqa: SLF001 + return None async def start(self) -> None: """ @@ -218,15 +222,15 @@ async def start(self) -> None: self._logger.info("Https enabled") await self.start_https_site(self.runner) - self._logger.info("Swagger docs: http://%s:%d/docs", self.http_host, self.config.get("api/http_port")) - self._logger.info("Swagger JSON: http://%s:%d/docs/swagger.json", self.http_host, - self.config.get("api/http_port")) + api_port = self.get_api_port() + self._logger.info("Swagger docs: http://%s:%d/docs", self.http_host, api_port) + self._logger.info("Swagger JSON: http://%s:%d/docs/swagger.json", self.http_host, api_port) async def start_http_site(self, runner: web.AppRunner) -> None: """ Start serving HTTP requests. """ - api_port = max(self.config.get("api/http_port"), 0) # if the value in config is <0 we convert it to 0 + api_port = self.config.get("api/http_port") or 0 self.site = web.TCPSite(runner, self.http_host, api_port, shutdown_timeout=self.shutdown_timeout) self._logger.info("Starting HTTP REST API server on port %d...", api_port) @@ -238,11 +242,7 @@ async def start_http_site(self, runner: web.AppRunner) -> None: str(e)) raise - if not api_port: - api_port = cast(Server, self.site._server).sockets[0].getsockname()[1] # noqa: SLF001 - - self.set_api_port(api_port) - self._logger.info("HTTP REST API server started on port %d", api_port) + self._logger.info("HTTP REST API server started on port %d", self.get_api_port()) async def start_https_site(self, runner: web.AppRunner) -> None: """ diff --git a/src/tribler/test_unit/core/restapi/test_rest_manager.py b/src/tribler/test_unit/core/restapi/test_rest_manager.py index bf4f94d412..092b9fb8e0 100644 --- a/src/tribler/test_unit/core/restapi/test_rest_manager.py +++ b/src/tribler/test_unit/core/restapi/test_rest_manager.py @@ -91,7 +91,7 @@ async def test_key_middleware_valid_query(self) -> None: """ middleware = ApiKeyMiddleware("123") - response = await middleware(GenericRequest(query={"apikey": "123"}), GenericRequest.generic_handler) + response = await middleware(GenericRequest(query={"key": "123"}), GenericRequest.generic_handler) response_body_json = await response_to_json(response) self.assertTrue(response_body_json["passed"]) @@ -186,13 +186,3 @@ def test_add_endpoint(self) -> None: manager.add_endpoint(endpoint) self.assertEqual(endpoint, manager.get_endpoint("/test")) - - def test_set_api_port(self) -> None: - """ - Test if the api port can be set. - """ - manager = RESTManager(MockTriblerConfigManager()) - - manager.set_api_port(123) - - self.assertEqual(123, manager.config.get("api/http_port")) From c1c4c2c50973b5ad11702f80be17d7d0d8ad6f10 Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Sat, 15 Jun 2024 12:59:10 +0200 Subject: [PATCH 10/13] Simplify EventSource messages --- src/tribler/core/restapi/events_endpoint.py | 4 +- .../core/restapi/test_events_endpoint.py | 52 ++++++++----------- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/src/tribler/core/restapi/events_endpoint.py b/src/tribler/core/restapi/events_endpoint.py index b580e4ac22..9d39d6036d 100644 --- a/src/tribler/core/restapi/events_endpoint.py +++ b/src/tribler/core/restapi/events_endpoint.py @@ -121,7 +121,9 @@ def encode_message(self, message: MessageDict) -> bytes: Use JSON to dump the given message to bytes. """ try: - return b"data: " + json.dumps(message).encode() + b"\n\n" + event = message.get("topic", "message").encode() + data = json.dumps(message.get("kwargs", {})).encode() + return b"event: " + event + b"\ndata: " + data + b"\n\n" except (UnicodeDecodeError, TypeError) as e: # The message contains invalid characters; fix them self._logger.exception("Event contains non-unicode characters, dropping %s", repr(message)) diff --git a/src/tribler/test_unit/core/restapi/test_events_endpoint.py b/src/tribler/test_unit/core/restapi/test_events_endpoint.py index cb09a649d9..6c244d6747 100644 --- a/src/tribler/test_unit/core/restapi/test_events_endpoint.py +++ b/src/tribler/test_unit/core/restapi/test_events_endpoint.py @@ -101,10 +101,9 @@ async def test_establish_connection(self) -> None: response = await self.endpoint.get_events(request) self.assertEqual(200, response.status) - self.assertEqual((b'data: {' - b'"topic": "events_start", ' - b'"kwargs": {"public_key": "", "version": "Tribler Experimental"}' - b'}\n\n'), request.payload_writer.captured[0]) + self.assertEqual((b'event: events_start\n' + b'data: {"public_key": "", "version": "Tribler Experimental"}' + b'\n\n'), request.payload_writer.captured[0]) async def test_establish_connection_with_error(self) -> None: """ @@ -116,10 +115,9 @@ async def test_establish_connection_with_error(self) -> None: response = await self.endpoint.get_events(request) self.assertEqual(200, response.status) - self.assertEqual((b'data: {' - b'"topic": "tribler_exception", ' - b'"kwargs": {"error": "test message"}' - b'}\n\n'), request.payload_writer.captured[1]) + self.assertEqual((b'event: tribler_exception\n' + b'data: {"error": "test message"}' + b'\n\n'), request.payload_writer.captured[1]) async def test_forward_error(self) -> None: """ @@ -134,10 +132,9 @@ async def test_forward_error(self) -> None: self.assertEqual(200, response.status) self.assertIsNone(self.endpoint.undelivered_error) - self.assertEqual((b'data: {' - b'"topic": "tribler_exception", ' - b'"kwargs": {"error": "test message"}' - b'}\n\n'), request.payload_writer.captured[1]) + self.assertEqual((b'event: tribler_exception\n' + b'data: {"error": "test message"}' + b'\n\n'), request.payload_writer.captured[1]) async def test_error_before_connection(self) -> None: """ @@ -156,14 +153,14 @@ async def test_send_event(self) -> None: response_future = ensure_future(self.endpoint.get_events(request)) await sleep(0) - self.endpoint.send_event({"key": "value"}) + self.endpoint.send_event({"topic": "message", "kwargs": {"key": "value"}}) response = await response_future self.assertEqual(200, response.status) self.assertIsNone(self.endpoint.undelivered_error) - self.assertEqual((b'data: {' - b'"key": "value"' - b'}\n\n'), request.payload_writer.captured[1]) + self.assertEqual((b'event: message\n' + b'data: {"key": "value"}' + b'\n\n'), request.payload_writer.captured[1]) async def test_send_event_illegal_chars(self) -> None: """ @@ -173,15 +170,14 @@ async def test_send_event_illegal_chars(self) -> None: response_future = ensure_future(self.endpoint.get_events(request)) await sleep(0) - self.endpoint.send_event({"something": b"\x80"}) + self.endpoint.send_event({"topic": "message", "kwargs": {"something": b"\x80"}}) response = await response_future self.assertEqual(200, response.status) self.assertIsNone(self.endpoint.undelivered_error) - self.assertEqual((b'data: {' - b'"topic": "tribler_exception", ' - b'"kwargs": {"error": "Object of type bytes is not JSON serializable"}' - b'}\n\n'), request.payload_writer.captured[1]) + self.assertEqual((b'event: tribler_exception\n' + b'data: {"error": "Object of type bytes is not JSON serializable"}' + b'\n\n'), request.payload_writer.captured[1]) async def test_forward_notification(self) -> None: """ @@ -196,10 +192,9 @@ async def test_forward_notification(self) -> None: self.assertEqual(200, response.status) self.assertIsNone(self.endpoint.undelivered_error) - self.assertEqual((b'data: {' - b'"topic": "tribler_new_version", ' - b'"kwargs": {"version": "super cool version"}' - b'}\n\n'), request.payload_writer.captured[1]) + self.assertEqual((b'event: tribler_new_version\n' + b'data: {"version": "super cool version"}' + b'\n\n'), request.payload_writer.captured[1]) async def test_no_forward_illegal_notification(self) -> None: """ @@ -215,7 +210,6 @@ async def test_no_forward_illegal_notification(self) -> None: self.assertEqual(200, response.status) self.assertIsNone(self.endpoint.undelivered_error) - self.assertEqual((b'data: {' - b'"topic": "tribler_new_version", ' - b'"kwargs": {"version": "super cool version"}' - b'}\n\n'), request.payload_writer.captured[1]) + self.assertEqual((b'event: tribler_new_version\n' + b'data: {"version": "super cool version"}' + b'\n\n'), request.payload_writer.captured[1]) From 00f71a8847f63876482b394e76cfb5b35c586375 Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Sat, 15 Jun 2024 13:22:15 +0200 Subject: [PATCH 11/13] Simplify torrent health notifications --- src/tribler/core/notifier.py | 4 +++- src/tribler/core/restapi/events_endpoint.py | 2 +- src/tribler/core/torrent_checker/torrent_checker.py | 13 ++++++------- .../core/torrent_checker/test_torrent_checker.py | 4 ++-- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/tribler/core/notifier.py b/src/tribler/core/notifier.py index 55a9a5cea1..f9660e0b85 100644 --- a/src/tribler/core/notifier.py +++ b/src/tribler/core/notifier.py @@ -34,7 +34,9 @@ class Notification(Enum): tunnel_removed = Desc("tunnel_removed", ["circuit_id", "bytes_up", "bytes_down", "uptime", "additional_info"], [int, int, int, float, str]) watch_folder_corrupt_file = Desc("watch_folder_corrupt_file", ["file_name"], [str]) - channel_entity_updated = Desc("channel_entity_updated", ["channel_update_dict"], [dict]) + torrent_health_updated = Desc("torrent_health_updated", + ["infohash", "num_seeders", "num_leechers", "last_tracker_check", "health"], + [str, int, int, int, str]) low_space = Desc("low_space", ["disk_usage_data"], [dict]) events_start = Desc("events_start", ["public_key", "version"], [str, str]) tribler_exception = Desc("tribler_exception", ["error"], [dict]) diff --git a/src/tribler/core/restapi/events_endpoint.py b/src/tribler/core/restapi/events_endpoint.py index 9d39d6036d..f5cc8e4d95 100644 --- a/src/tribler/core/restapi/events_endpoint.py +++ b/src/tribler/core/restapi/events_endpoint.py @@ -25,7 +25,7 @@ Notification.tribler_new_version, Notification.channel_discovered, Notification.torrent_finished, - Notification.channel_entity_updated, + Notification.torrent_health_updated, Notification.tribler_shutdown_state, Notification.remote_query_results, Notification.low_space, diff --git a/src/tribler/core/torrent_checker/torrent_checker.py b/src/tribler/core/torrent_checker/torrent_checker.py index e0f2d78d8f..3ed7d90851 100644 --- a/src/tribler/core/torrent_checker/torrent_checker.py +++ b/src/tribler/core/torrent_checker/torrent_checker.py @@ -439,10 +439,9 @@ def notify(self, health: HealthInfo) -> None: """ Send a health update to the GUI. """ - self.notifier.notify(Notification.channel_entity_updated, channel_update_dict={ - 'infohash': hexlify(health.infohash).decode(), - 'num_seeders': health.seeders, - 'num_leechers': health.leechers, - 'last_tracker_check': health.last_check, - 'health': 'updated' - }) + self.notifier.notify(Notification.torrent_health_updated, + infohash=hexlify(health.infohash).decode(), + num_seeders=health.seeders, + num_leechers=health.leechers, + last_tracker_check=health.last_check, + health='updated') diff --git a/src/tribler/test_unit/core/torrent_checker/test_torrent_checker.py b/src/tribler/test_unit/core/torrent_checker/test_torrent_checker.py index d9eb9923ac..5bf1ac9b0e 100644 --- a/src/tribler/test_unit/core/torrent_checker/test_torrent_checker.py +++ b/src/tribler/test_unit/core/torrent_checker/test_torrent_checker.py @@ -328,7 +328,7 @@ async def test_update_torrent_health_no_replace(self) -> None: now = int(time.time()) mocked_handler = Mock() self.torrent_checker.notifier = Notifier() - self.torrent_checker.notifier.add(Notification.channel_entity_updated, mocked_handler) + self.torrent_checker.notifier.add(Notification.torrent_health_updated, mocked_handler) self.torrent_checker.mds.TorrentState.instances = [MockTorrentState(infohash=unhexlify('abcd0123'), seeders=2, leechers=1, last_check=now, self_checked=True)] @@ -338,7 +338,7 @@ async def test_update_torrent_health_no_replace(self) -> None: self.assertFalse(self.torrent_checker.update_torrent_health(health)) - notified = mocked_handler.call_args.kwargs["channel_update_dict"] + notified = mocked_handler.call_args.kwargs self.assertEqual(prev_health.infohash, unhexlify(notified["infohash"])) self.assertEqual(prev_health.seeders, notified["num_seeders"]) self.assertEqual(prev_health.leechers, notified["num_leechers"]) From 62ee4a28de4088acea9a56ce364a5f95d49a63da Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Sat, 15 Jun 2024 13:33:25 +0200 Subject: [PATCH 12/13] Simplify search results notifications --- .../core/content_discovery/community.py | 10 ++-- .../database/restapi/database_endpoint.py | 7 ++- src/tribler/core/notifier.py | 5 +- src/tribler/core/restapi/events_endpoint.py | 1 - src/tribler/core/user_activity/manager.py | 6 +-- .../core/content_discovery/test_community.py | 18 +++---- .../core/user_activity/test_manager.py | 48 +++---------------- 7 files changed, 25 insertions(+), 70 deletions(-) diff --git a/src/tribler/core/content_discovery/community.py b/src/tribler/core/content_discovery/community.py index 978ba7545e..c098ce38f4 100644 --- a/src/tribler/core/content_discovery/community.py +++ b/src/tribler/core/content_discovery/community.py @@ -237,12 +237,10 @@ def notify_gui(request: SelectRequest, processing_results: list[ProcessingResult ] if self.composition.notifier: self.composition.notifier.notify(Notification.remote_query_results, - data={ - "query": kwargs.get("txt_filter"), - "results": results, - "uuid": str(request_uuid), - "peer": hexlify(request.peer.mid).decode() - }) + query=kwargs.get("txt_filter"), + results=results, + uuid=str(request_uuid), + peer=hexlify(request.peer.mid).decode()) peers_to_query = self.get_random_peers(self.composition.max_query_peers) diff --git a/src/tribler/core/database/restapi/database_endpoint.py b/src/tribler/core/database/restapi/database_endpoint.py index d863d5d4ee..bf48558373 100644 --- a/src/tribler/core/database/restapi/database_endpoint.py +++ b/src/tribler/core/database/restapi/database_endpoint.py @@ -296,10 +296,9 @@ def search_db() -> tuple[list[dict], int, int]: else: total = max_rowid = None if self.download_manager is not None: - self.download_manager.notifier.notify(Notification.local_query_results, data={ - "query": request.query.get("fts_text"), - "results": list(search_results) - }) + self.download_manager.notifier.notify(Notification.local_query_results, + query=request.query.get("fts_text"), + results=list(search_results)) return search_results, total, max_rowid try: diff --git a/src/tribler/core/notifier.py b/src/tribler/core/notifier.py index f9660e0b85..16be1715ab 100644 --- a/src/tribler/core/notifier.py +++ b/src/tribler/core/notifier.py @@ -27,9 +27,8 @@ class Notification(Enum): torrent_status_changed = Desc("torrent_status_changed", ["infohash", "status"], [str, str]) tribler_shutdown_state = Desc("tribler_shutdown_state", ["state"], [str]) tribler_new_version = Desc("tribler_new_version", ["version"], [str]) - channel_discovered = Desc("channel_discovered", ["data"], [dict]) - remote_query_results = Desc("remote_query_results", ["data"], [dict]) - local_query_results = Desc("local_query_results", ["data"], [dict]) + remote_query_results = Desc("remote_query_results", ["query", "results", "uuid", "peer"], [str, list, str, str]) + local_query_results = Desc("local_query_results", ["query", "results"], [str, list]) circuit_removed = Desc("circuit_removed", ["circuit", "additional_info"], [str, Circuit]) tunnel_removed = Desc("tunnel_removed", ["circuit_id", "bytes_up", "bytes_down", "uptime", "additional_info"], [int, int, int, float, str]) diff --git a/src/tribler/core/restapi/events_endpoint.py b/src/tribler/core/restapi/events_endpoint.py index f5cc8e4d95..9c6d83aef0 100644 --- a/src/tribler/core/restapi/events_endpoint.py +++ b/src/tribler/core/restapi/events_endpoint.py @@ -23,7 +23,6 @@ Notification.tunnel_removed, Notification.watch_folder_corrupt_file, Notification.tribler_new_version, - Notification.channel_discovered, Notification.torrent_finished, Notification.torrent_health_updated, Notification.tribler_shutdown_state, diff --git a/src/tribler/core/user_activity/manager.py b/src/tribler/core/user_activity/manager.py index 5e26413154..6875397450 100644 --- a/src/tribler/core/user_activity/manager.py +++ b/src/tribler/core/user_activity/manager.py @@ -41,15 +41,11 @@ def __init__(self, task_manager: TaskManager, session: Session, max_query_histor self.task_manager.register_task("Check preferable", self.check_preferable, interval=session.config.get("user_activity/health_check_interval")) - def on_query_results(self, data: dict) -> None: + def on_query_results(self, query: str, **data: dict) -> None: """ Start tracking a query and its results. If any of the results get downloaded, we store the query (see ``on_torrent_finished``). """ - query = data.get("query") - if query is None: - return - results = {tmd["infohash"] for tmd in data["results"]} for infohash in results: self.infohash_to_queries[infohash].append(query) diff --git a/src/tribler/test_unit/core/content_discovery/test_community.py b/src/tribler/test_unit/core/content_discovery/test_community.py index bdff8b223f..3d27ac257a 100644 --- a/src/tribler/test_unit/core/content_discovery/test_community.py +++ b/src/tribler/test_unit/core/content_discovery/test_community.py @@ -159,9 +159,9 @@ async def test_popularity_search(self) -> None: uuid, peers = self.overlay(0).send_search_request(txt_filter="ubuntu*") await self.deliver_messages() - self.assertEqual(str(uuid), notifications["data"]["uuid"]) - self.assertEqual([], notifications["data"]["results"]) - self.assertEqual(hexlify(peers[0].mid).decode(), notifications["data"]["peer"]) + self.assertEqual(str(uuid), notifications["uuid"]) + self.assertEqual([], notifications["results"]) + self.assertEqual(hexlify(peers[0].mid).decode(), notifications["peer"]) async def test_popularity_search_deprecated(self) -> None: """ @@ -175,9 +175,9 @@ async def test_popularity_search_deprecated(self) -> None: metadata_type=REGULAR_TORRENT, exclude_deleted="1") await self.deliver_messages() - self.assertEqual(str(uuid), notifications["data"]["uuid"]) - self.assertEqual([], notifications["data"]["results"]) - self.assertEqual(hexlify(peers[0].mid).decode(), notifications["data"]["peer"]) + self.assertEqual(str(uuid), notifications["uuid"]) + self.assertEqual([], notifications["results"]) + self.assertEqual(hexlify(peers[0].mid).decode(), notifications["peer"]) async def test_popularity_search_unparsed_metadata_type(self) -> None: """ @@ -191,9 +191,9 @@ async def test_popularity_search_unparsed_metadata_type(self) -> None: metadata_type=str(REGULAR_TORRENT), exclude_deleted="1") await self.deliver_messages() - self.assertEqual(str(uuid), notifications["data"]["uuid"]) - self.assertEqual([], notifications["data"]["results"]) - self.assertEqual(hexlify(peers[0].mid).decode(), notifications["data"]["peer"]) + self.assertEqual(str(uuid), notifications["uuid"]) + self.assertEqual([], notifications["results"]) + self.assertEqual(hexlify(peers[0].mid).decode(), notifications["peer"]) async def test_request_for_version(self) -> None: """ diff --git a/src/tribler/test_unit/core/user_activity/test_manager.py b/src/tribler/test_unit/core/user_activity/test_manager.py index 8040c9ecfa..d741229aa3 100644 --- a/src/tribler/test_unit/core/user_activity/test_manager.py +++ b/src/tribler/test_unit/core/user_activity/test_manager.py @@ -36,42 +36,6 @@ async def tearDown(self) -> None: await self.task_manager.shutdown_task_manager() await super().tearDown() - async def test_notify_local_query_empty(self) -> None: - """ - Test that local query notifications without a query get ignored. - """ - fake_infohashes = [InfoHash(bytes([i]) * 20) for i in range(2)] - fake_torrent_metadata = [{"infohash": fake_infohashes[i]} for i in range(2)] - fake_query = None - - self.session.notifier.notify(Notification.local_query_results, - data={"query": fake_query, "results": fake_torrent_metadata}) - await sleep(0) - - self.assertNotIn(fake_query, self.manager.queries) - self.assertNotIn(fake_infohashes[0], self.manager.infohash_to_queries) - self.assertNotIn(fake_infohashes[1], self.manager.infohash_to_queries) - self.assertNotIn(fake_query, self.manager.infohash_to_queries[fake_infohashes[0]]) - self.assertNotIn(fake_query, self.manager.infohash_to_queries[fake_infohashes[1]]) - - async def test_notify_remote_query_empty(self) -> None: - """ - Test that remote query notifications without a query get ignored. - """ - fake_infohashes = [InfoHash(bytes([i]) * 20) for i in range(2)] - fake_torrent_metadata = [{"infohash": fake_infohashes[i]} for i in range(2)] - fake_query = None - - self.session.notifier.notify(Notification.remote_query_results, - data={"query": fake_query, "results": fake_torrent_metadata}) - await sleep(0) - - self.assertNotIn(fake_query, self.manager.queries) - self.assertNotIn(fake_infohashes[0], self.manager.infohash_to_queries) - self.assertNotIn(fake_infohashes[1], self.manager.infohash_to_queries) - self.assertNotIn(fake_query, self.manager.infohash_to_queries[fake_infohashes[0]]) - self.assertNotIn(fake_query, self.manager.infohash_to_queries[fake_infohashes[1]]) - async def test_notify_local_query_results(self) -> None: """ Test that local query notifications get processed correctly. @@ -81,7 +45,7 @@ async def test_notify_local_query_results(self) -> None: fake_query = "test query" self.session.notifier.notify(Notification.local_query_results, - data={"query": fake_query, "results": fake_torrent_metadata}) + query=fake_query, results=fake_torrent_metadata) await sleep(0) self.assertIn(fake_query, self.manager.queries) @@ -99,7 +63,7 @@ async def test_notify_remote_query_results(self) -> None: fake_query = "test query" self.session.notifier.notify(Notification.remote_query_results, - data={"query": fake_query, "results": fake_torrent_metadata}) + query=fake_query, results=fake_torrent_metadata, uuid='123', peer=[]) await sleep(0) self.assertIn(fake_query, self.manager.queries) @@ -121,10 +85,10 @@ async def test_notify_local_query_results_overflow(self) -> None: fake_query_2 = "test query 2" self.session.notifier.notify(Notification.local_query_results, - data={"query": fake_query_1, "results": fake_torrent_metadata}) + query=fake_query_1, results=fake_torrent_metadata) await sleep(0) self.session.notifier.notify(Notification.local_query_results, - data={"query": fake_query_2, "results": fake_torrent_metadata[:1]}) + query=fake_query_2, results=fake_torrent_metadata[:1]) await sleep(0) self.assertNotIn(fake_query_1, self.manager.queries) @@ -149,10 +113,10 @@ async def test_notify_remote_query_results_overflow(self) -> None: fake_query_2 = "test query 2" self.session.notifier.notify(Notification.remote_query_results, - data={"query": fake_query_1, "results": fake_torrent_metadata}) + query=fake_query_1, results=fake_torrent_metadata, uuid='123', peer=[]) await sleep(0) self.session.notifier.notify(Notification.remote_query_results, - data={"query": fake_query_2, "results": fake_torrent_metadata[:1]}) + query=fake_query_2, results=fake_torrent_metadata[:1], uuid='123', peer=[]) await sleep(0) self.assertNotIn(fake_query_1, self.manager.queries) From 4ee8608a18c2fc50a9624495356ad7eaa497573f Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Tue, 18 Jun 2024 12:40:03 +0200 Subject: [PATCH 13/13] Added React web UI --- src/tribler/ui/.gitignore | 24 + src/tribler/ui/README.md | 26 + src/tribler/ui/components.json | 16 + src/tribler/ui/index.html | 14 + src/tribler/ui/package-lock.json | 6638 +++++++++++++++++ src/tribler/ui/package.json | 63 + src/tribler/ui/postcss.config.js | 6 + src/tribler/ui/public/favicon.ico | Bin 0 -> 24190 bytes src/tribler/ui/public/locales/en_US.json | 127 + src/tribler/ui/public/locales/es_ES.json | 128 + src/tribler/ui/public/locales/pt_BR.json | 120 + src/tribler/ui/public/locales/ru_RU.json | 128 + src/tribler/ui/public/locales/zh_CN.json | 126 + src/tribler/ui/public/tribler.png | Bin 0 -> 14157 bytes src/tribler/ui/src/App.tsx | 16 + src/tribler/ui/src/Router.tsx | 114 + src/tribler/ui/src/components/add-torrent.tsx | 142 + src/tribler/ui/src/components/icons.tsx | 22 + .../ui/src/components/language-select.tsx | 46 + .../ui/src/components/layouts/AppLayout.tsx | 15 + .../ui/src/components/layouts/Header.tsx | 92 + .../ui/src/components/layouts/Search.tsx | 20 + .../ui/src/components/layouts/SideLayout.tsx | 124 + src/tribler/ui/src/components/mode-toggle.tsx | 38 + src/tribler/ui/src/components/path-input.tsx | 50 + .../ui/src/components/swarm-health.tsx | 52 + .../ui/src/components/ui/accordion.tsx | 55 + .../ui/src/components/ui/autocomplete.tsx | 86 + src/tribler/ui/src/components/ui/avatar.tsx | 48 + src/tribler/ui/src/components/ui/badge.tsx | 36 + src/tribler/ui/src/components/ui/button.tsx | 57 + src/tribler/ui/src/components/ui/card.tsx | 76 + src/tribler/ui/src/components/ui/checkbox.tsx | 30 + src/tribler/ui/src/components/ui/dialog.tsx | 130 + .../ui/src/components/ui/dropdown-menu.tsx | 203 + src/tribler/ui/src/components/ui/form.tsx | 176 + .../ui/src/components/ui/inline-code.tsx | 14 + src/tribler/ui/src/components/ui/input.tsx | 25 + src/tribler/ui/src/components/ui/label.tsx | 24 + src/tribler/ui/src/components/ui/progress.tsx | 32 + .../ui/src/components/ui/radiogroup.tsx | 44 + .../ui/src/components/ui/resizable.tsx | 45 + .../ui/src/components/ui/scroll-area.tsx | 51 + src/tribler/ui/src/components/ui/select.tsx | 162 + .../ui/src/components/ui/separator.tsx | 29 + .../ui/src/components/ui/simple-table.tsx | 240 + src/tribler/ui/src/components/ui/slider.tsx | 28 + src/tribler/ui/src/components/ui/table.tsx | 125 + src/tribler/ui/src/components/ui/tabs.tsx | 55 + src/tribler/ui/src/components/ui/textarea.tsx | 23 + src/tribler/ui/src/components/ui/tooltip.tsx | 30 + src/tribler/ui/src/config/app.ts | 7 + src/tribler/ui/src/config/menu.ts | 116 + .../ui/src/contexts/LanguageContext.tsx | 27 + src/tribler/ui/src/contexts/ThemeContext.tsx | 60 + src/tribler/ui/src/dialogs/CreateTorrent.tsx | 180 + src/tribler/ui/src/dialogs/SaveAs.tsx | 259 + .../ui/src/dialogs/SelectRemotePath.tsx | 97 + src/tribler/ui/src/hooks/useInterval.tsx | 23 + src/tribler/ui/src/hooks/usePrevious.tsx | 10 + .../ui/src/hooks/useResizeObserver.tsx | 26 + src/tribler/ui/src/hooks/useTheme.tsx | 11 + src/tribler/ui/src/i18n/index.ts | 21 + src/tribler/ui/src/index.css | 125 + src/tribler/ui/src/lib/utils.ts | 145 + src/tribler/ui/src/main.tsx | 10 + .../ui/src/models/bittorrentpeer.model.tsx | 26 + src/tribler/ui/src/models/bucket.model.tsx | 20 + src/tribler/ui/src/models/circuit.model.tsx | 15 + src/tribler/ui/src/models/download.model.tsx | 40 + .../ui/src/models/downloadconfig.model.tsx | 9 + src/tribler/ui/src/models/exit.model.tsx | 11 + src/tribler/ui/src/models/file.model.tsx | 9 + src/tribler/ui/src/models/keyvalue.model.tsx | 6 + src/tribler/ui/src/models/metainfo.tsx | 14 + src/tribler/ui/src/models/overlay.model.tsx | 54 + src/tribler/ui/src/models/path.model.tsx | 7 + src/tribler/ui/src/models/relay.model.tsx | 13 + src/tribler/ui/src/models/settings.model.tsx | 107 + src/tribler/ui/src/models/swarm.model.tsx | 12 + src/tribler/ui/src/models/torrent.model.tsx | 24 + src/tribler/ui/src/models/tracker.model .tsx | 7 + .../ui/src/models/tunnelpeer.model.tsx | 9 + src/tribler/ui/src/pages/Dashboard.tsx | 7 + .../ui/src/pages/Debug/Asyncio/Health.tsx | 116 + .../ui/src/pages/Debug/Asyncio/SlowTasks.tsx | 58 + .../ui/src/pages/Debug/Asyncio/Tasks.tsx | 59 + .../ui/src/pages/Debug/Asyncio/index.tsx | 26 + .../ui/src/pages/Debug/DHT/Buckets.tsx | 39 + .../ui/src/pages/Debug/DHT/Statistics.tsx | 35 + src/tribler/ui/src/pages/Debug/DHT/index.tsx | 26 + .../ui/src/pages/Debug/General/index.tsx | 36 + .../ui/src/pages/Debug/IPv8/Details.tsx | 95 + .../ui/src/pages/Debug/IPv8/Overlays.tsx | 133 + src/tribler/ui/src/pages/Debug/IPv8/index.tsx | 21 + .../ui/src/pages/Debug/Libtorrent/index.tsx | 79 + .../ui/src/pages/Debug/Tunnels/Circuits.tsx | 68 + .../ui/src/pages/Debug/Tunnels/Exits.tsx | 50 + .../ui/src/pages/Debug/Tunnels/Peers.tsx | 44 + .../ui/src/pages/Debug/Tunnels/Relays.tsx | 54 + .../ui/src/pages/Debug/Tunnels/Swarms.tsx | 62 + .../ui/src/pages/Debug/Tunnels/index.tsx | 36 + .../ui/src/pages/Downloads/Actions.tsx | 208 + .../ui/src/pages/Downloads/Details.tsx | 91 + src/tribler/ui/src/pages/Downloads/Files.tsx | 106 + src/tribler/ui/src/pages/Downloads/Peers.tsx | 84 + src/tribler/ui/src/pages/Downloads/Pieces.tsx | 62 + .../ui/src/pages/Downloads/Trackers.tsx | 25 + src/tribler/ui/src/pages/Downloads/index.tsx | 219 + src/tribler/ui/src/pages/NoMatch.tsx | 15 + src/tribler/ui/src/pages/Popular/index.tsx | 85 + src/tribler/ui/src/pages/Search/index.tsx | 142 + .../ui/src/pages/Settings/Anonymity.tsx | 54 + .../ui/src/pages/Settings/Bandwidth.tsx | 75 + .../ui/src/pages/Settings/Connection.tsx | 185 + .../ui/src/pages/Settings/Debugging.tsx | 72 + src/tribler/ui/src/pages/Settings/General.tsx | 167 + .../ui/src/pages/Settings/SaveButton.tsx | 37 + src/tribler/ui/src/pages/Settings/Seeding.tsx | 104 + src/tribler/ui/src/services/ipv8.service.ts | 79 + .../ui/src/services/tribler.service.ts | 200 + src/tribler/ui/src/vite-env.d.ts | 5 + src/tribler/ui/tailwind.config.js | 100 + src/tribler/ui/tsconfig.json | 25 + src/tribler/ui/tsconfig.node.json | 9 + src/tribler/ui/vite.config.ts | 19 + 126 files changed, 14683 insertions(+) create mode 100644 src/tribler/ui/.gitignore create mode 100644 src/tribler/ui/README.md create mode 100644 src/tribler/ui/components.json create mode 100644 src/tribler/ui/index.html create mode 100644 src/tribler/ui/package-lock.json create mode 100644 src/tribler/ui/package.json create mode 100644 src/tribler/ui/postcss.config.js create mode 100644 src/tribler/ui/public/favicon.ico create mode 100644 src/tribler/ui/public/locales/en_US.json create mode 100644 src/tribler/ui/public/locales/es_ES.json create mode 100644 src/tribler/ui/public/locales/pt_BR.json create mode 100644 src/tribler/ui/public/locales/ru_RU.json create mode 100644 src/tribler/ui/public/locales/zh_CN.json create mode 100644 src/tribler/ui/public/tribler.png create mode 100644 src/tribler/ui/src/App.tsx create mode 100644 src/tribler/ui/src/Router.tsx create mode 100644 src/tribler/ui/src/components/add-torrent.tsx create mode 100644 src/tribler/ui/src/components/icons.tsx create mode 100644 src/tribler/ui/src/components/language-select.tsx create mode 100644 src/tribler/ui/src/components/layouts/AppLayout.tsx create mode 100644 src/tribler/ui/src/components/layouts/Header.tsx create mode 100644 src/tribler/ui/src/components/layouts/Search.tsx create mode 100644 src/tribler/ui/src/components/layouts/SideLayout.tsx create mode 100644 src/tribler/ui/src/components/mode-toggle.tsx create mode 100644 src/tribler/ui/src/components/path-input.tsx create mode 100644 src/tribler/ui/src/components/swarm-health.tsx create mode 100644 src/tribler/ui/src/components/ui/accordion.tsx create mode 100644 src/tribler/ui/src/components/ui/autocomplete.tsx create mode 100644 src/tribler/ui/src/components/ui/avatar.tsx create mode 100644 src/tribler/ui/src/components/ui/badge.tsx create mode 100644 src/tribler/ui/src/components/ui/button.tsx create mode 100644 src/tribler/ui/src/components/ui/card.tsx create mode 100644 src/tribler/ui/src/components/ui/checkbox.tsx create mode 100644 src/tribler/ui/src/components/ui/dialog.tsx create mode 100644 src/tribler/ui/src/components/ui/dropdown-menu.tsx create mode 100644 src/tribler/ui/src/components/ui/form.tsx create mode 100644 src/tribler/ui/src/components/ui/inline-code.tsx create mode 100644 src/tribler/ui/src/components/ui/input.tsx create mode 100644 src/tribler/ui/src/components/ui/label.tsx create mode 100644 src/tribler/ui/src/components/ui/progress.tsx create mode 100644 src/tribler/ui/src/components/ui/radiogroup.tsx create mode 100644 src/tribler/ui/src/components/ui/resizable.tsx create mode 100644 src/tribler/ui/src/components/ui/scroll-area.tsx create mode 100644 src/tribler/ui/src/components/ui/select.tsx create mode 100644 src/tribler/ui/src/components/ui/separator.tsx create mode 100644 src/tribler/ui/src/components/ui/simple-table.tsx create mode 100644 src/tribler/ui/src/components/ui/slider.tsx create mode 100644 src/tribler/ui/src/components/ui/table.tsx create mode 100644 src/tribler/ui/src/components/ui/tabs.tsx create mode 100644 src/tribler/ui/src/components/ui/textarea.tsx create mode 100644 src/tribler/ui/src/components/ui/tooltip.tsx create mode 100644 src/tribler/ui/src/config/app.ts create mode 100644 src/tribler/ui/src/config/menu.ts create mode 100644 src/tribler/ui/src/contexts/LanguageContext.tsx create mode 100644 src/tribler/ui/src/contexts/ThemeContext.tsx create mode 100644 src/tribler/ui/src/dialogs/CreateTorrent.tsx create mode 100644 src/tribler/ui/src/dialogs/SaveAs.tsx create mode 100644 src/tribler/ui/src/dialogs/SelectRemotePath.tsx create mode 100644 src/tribler/ui/src/hooks/useInterval.tsx create mode 100644 src/tribler/ui/src/hooks/usePrevious.tsx create mode 100644 src/tribler/ui/src/hooks/useResizeObserver.tsx create mode 100644 src/tribler/ui/src/hooks/useTheme.tsx create mode 100644 src/tribler/ui/src/i18n/index.ts create mode 100644 src/tribler/ui/src/index.css create mode 100644 src/tribler/ui/src/lib/utils.ts create mode 100644 src/tribler/ui/src/main.tsx create mode 100644 src/tribler/ui/src/models/bittorrentpeer.model.tsx create mode 100644 src/tribler/ui/src/models/bucket.model.tsx create mode 100644 src/tribler/ui/src/models/circuit.model.tsx create mode 100644 src/tribler/ui/src/models/download.model.tsx create mode 100644 src/tribler/ui/src/models/downloadconfig.model.tsx create mode 100644 src/tribler/ui/src/models/exit.model.tsx create mode 100644 src/tribler/ui/src/models/file.model.tsx create mode 100644 src/tribler/ui/src/models/keyvalue.model.tsx create mode 100644 src/tribler/ui/src/models/metainfo.tsx create mode 100644 src/tribler/ui/src/models/overlay.model.tsx create mode 100644 src/tribler/ui/src/models/path.model.tsx create mode 100644 src/tribler/ui/src/models/relay.model.tsx create mode 100644 src/tribler/ui/src/models/settings.model.tsx create mode 100644 src/tribler/ui/src/models/swarm.model.tsx create mode 100644 src/tribler/ui/src/models/torrent.model.tsx create mode 100644 src/tribler/ui/src/models/tracker.model .tsx create mode 100644 src/tribler/ui/src/models/tunnelpeer.model.tsx create mode 100644 src/tribler/ui/src/pages/Dashboard.tsx create mode 100644 src/tribler/ui/src/pages/Debug/Asyncio/Health.tsx create mode 100644 src/tribler/ui/src/pages/Debug/Asyncio/SlowTasks.tsx create mode 100644 src/tribler/ui/src/pages/Debug/Asyncio/Tasks.tsx create mode 100644 src/tribler/ui/src/pages/Debug/Asyncio/index.tsx create mode 100644 src/tribler/ui/src/pages/Debug/DHT/Buckets.tsx create mode 100644 src/tribler/ui/src/pages/Debug/DHT/Statistics.tsx create mode 100644 src/tribler/ui/src/pages/Debug/DHT/index.tsx create mode 100644 src/tribler/ui/src/pages/Debug/General/index.tsx create mode 100644 src/tribler/ui/src/pages/Debug/IPv8/Details.tsx create mode 100644 src/tribler/ui/src/pages/Debug/IPv8/Overlays.tsx create mode 100644 src/tribler/ui/src/pages/Debug/IPv8/index.tsx create mode 100644 src/tribler/ui/src/pages/Debug/Libtorrent/index.tsx create mode 100644 src/tribler/ui/src/pages/Debug/Tunnels/Circuits.tsx create mode 100644 src/tribler/ui/src/pages/Debug/Tunnels/Exits.tsx create mode 100644 src/tribler/ui/src/pages/Debug/Tunnels/Peers.tsx create mode 100644 src/tribler/ui/src/pages/Debug/Tunnels/Relays.tsx create mode 100644 src/tribler/ui/src/pages/Debug/Tunnels/Swarms.tsx create mode 100644 src/tribler/ui/src/pages/Debug/Tunnels/index.tsx create mode 100644 src/tribler/ui/src/pages/Downloads/Actions.tsx create mode 100644 src/tribler/ui/src/pages/Downloads/Details.tsx create mode 100644 src/tribler/ui/src/pages/Downloads/Files.tsx create mode 100644 src/tribler/ui/src/pages/Downloads/Peers.tsx create mode 100644 src/tribler/ui/src/pages/Downloads/Pieces.tsx create mode 100644 src/tribler/ui/src/pages/Downloads/Trackers.tsx create mode 100644 src/tribler/ui/src/pages/Downloads/index.tsx create mode 100644 src/tribler/ui/src/pages/NoMatch.tsx create mode 100644 src/tribler/ui/src/pages/Popular/index.tsx create mode 100644 src/tribler/ui/src/pages/Search/index.tsx create mode 100644 src/tribler/ui/src/pages/Settings/Anonymity.tsx create mode 100644 src/tribler/ui/src/pages/Settings/Bandwidth.tsx create mode 100644 src/tribler/ui/src/pages/Settings/Connection.tsx create mode 100644 src/tribler/ui/src/pages/Settings/Debugging.tsx create mode 100644 src/tribler/ui/src/pages/Settings/General.tsx create mode 100644 src/tribler/ui/src/pages/Settings/SaveButton.tsx create mode 100644 src/tribler/ui/src/pages/Settings/Seeding.tsx create mode 100644 src/tribler/ui/src/services/ipv8.service.ts create mode 100644 src/tribler/ui/src/services/tribler.service.ts create mode 100644 src/tribler/ui/src/vite-env.d.ts create mode 100644 src/tribler/ui/tailwind.config.js create mode 100644 src/tribler/ui/tsconfig.json create mode 100644 src/tribler/ui/tsconfig.node.json create mode 100644 src/tribler/ui/vite.config.ts diff --git a/src/tribler/ui/.gitignore b/src/tribler/ui/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/src/tribler/ui/.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/src/tribler/ui/README.md b/src/tribler/ui/README.md new file mode 100644 index 0000000000..a4453a2bde --- /dev/null +++ b/src/tribler/ui/README.md @@ -0,0 +1,26 @@ +# Tribler web UI + +## Getting started + +After checking out the Tribler repo, the web UI will not run out-of-the box. You'll either have to build the web UI or serve it from the Vite development server. + +Building the web UI works as follows: + +``` +cd tribler/src/webui +npm install +npm run build +``` + +This will create a `dist` folder which will automatically be served after Tribler restarts. + +Alternatively, while working on the web UI, it's often more convenient use the development server: + +``` +cd tribler/src/webui +rm dist -r +npm install +npm run dev +``` + +After restarting Tribler, requests to `/ui` will be forwarded to the development server. Tribler assumes that the development server will run at `http://localhost:5173`. \ No newline at end of file diff --git a/src/tribler/ui/components.json b/src/tribler/ui/components.json new file mode 100644 index 0000000000..1cd599c2c5 --- /dev/null +++ b/src/tribler/ui/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/src/tribler/ui/index.html b/src/tribler/ui/index.html new file mode 100644 index 0000000000..91c6c95c14 --- /dev/null +++ b/src/tribler/ui/index.html @@ -0,0 +1,14 @@ + + + + + + + Tribler + + + +
+ + + diff --git a/src/tribler/ui/package-lock.json b/src/tribler/ui/package-lock.json new file mode 100644 index 0000000000..86c493fd31 --- /dev/null +++ b/src/tribler/ui/package-lock.json @@ -0,0 +1,6638 @@ +{ + "name": "tribler-webui", + "version": "0.1", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "tribler-webui", + "version": "0.1", + "dependencies": { + "@hookform/resolvers": "^3.3.2", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.7", + "@tanstack/react-table": "^8.10.7", + "axios": "^1.6.8", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "i18next": "^23.11.4", + "javascript-time-ago": "^2.5.10", + "js-cookie": "^3.0.5", + "lucide-react": "^0.292.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.48.2", + "react-i18next": "^14.1.1", + "react-resizable-panels": "^2.0.16", + "react-router-dom": "^6.16.0", + "tailwind-merge": "^1.14.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.22.4" + }, + "devDependencies": { + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-slider": "^1.1.2", + "@types/js-cookie": "^3.0.6", + "@types/node": "^20.8.4", + "@types/react": "^18.0.17", + "@types/react-dom": "^18.0.6", + "@vitejs/plugin-react": "^2.1.0", + "autoprefixer": "^10.4.16", + "i18next-http-backend": "^2.5.1", + "npm-watch": "^0.11.0", + "postcss": "^8.4.31", + "react-hot-toast": "^2.4.1", + "tailwindcss": "^3.3.3", + "typescript": "^4.6.4", + "vite": "^3.1.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.20.tgz", + "integrity": "sha512-BQYjKbpXjoXwFW5jGqiizJQQT/aC7pFm9Ok1OWssonuguICi264lbgMzRp2ZMmRSlfkX6DsWDDcsrctK8Rwfiw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.0.tgz", + "integrity": "sha512-97z/ju/Jy1rZmDxybphrBuI+jtJjFVoz7Mr9yUQVVVi+DNZE333uFQeMOqcCIy1x3WYBIbWftUSLmbNXNT7qFQ==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.0", + "@babel/helpers": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.0", + "@babel/types": "^7.23.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz", + "integrity": "sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.3", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.15", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz", + "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", + "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.23.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.1.tgz", + "integrity": "sha512-chNpneuK18yW5Oxsr+t553UZzzAs3aZnFm4bxhebsNTeshrC95yA7l5yl7GBAG+JG1rF0F7zzD2EixK9mWSDoA==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.0", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.3.tgz", + "integrity": "sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.22.5.tgz", + "integrity": "sha512-gvyP4hZrgrs/wWMaocvxZ44Hw0b3W8Pe+cMxc8V1ULQ07oh8VNbIRaoD1LRZVTvD+0nieDKjfgKg89sD7rrKrg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.22.15.tgz", + "integrity": "sha512-oKckg2eZFa8771O/5vi7XeTvmM6+O9cxZu+kanTU7tD4sin5nO/G8jGJhq8Hvt2Z0kUoEDRayuZLaUlYl8QuGA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-jsx": "^7.22.5", + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz", + "integrity": "sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A==", + "dev": true, + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.22.5.tgz", + "integrity": "sha512-nTh2ogNUtxbiSbxaT4Ds6aXnXEipHweN9YRgOX/oNXdf0cCrGn/+2LozFa3lnPV5D90MkjhgckCPBrsoSc1a7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.22.5.tgz", + "integrity": "sha512-yIiRO6yobeEIaI0RTbIr8iAK9FcBHLtZq0S89ZPjDLQXBA4xvghaKqI0etp/tF3htTM0sazJKKLz9oEiGRtu7w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", + "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.3.tgz", + "integrity": "sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.3", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.3", + "@babel/types": "^7.23.3", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz", + "integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", + "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", + "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz", + "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==", + "dependencies": { + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", + "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", + "dependencies": { + "@floating-ui/core": "^1.4.2", + "@floating-ui/utils": "^0.1.3" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.2.tgz", + "integrity": "sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ==", + "dependencies": { + "@floating-ui/dom": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", + "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + }, + "node_modules/@hookform/resolvers": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz", + "integrity": "sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.19", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", + "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", + "integrity": "sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz", + "integrity": "sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collapsible": "1.0.3", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", + "integrity": "sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.0.4.tgz", + "integrity": "sha512-kVK2K7ZD3wwj3qhle0ElXhOjbezIgyl2hVvgwfIdexL3rN6zJmy5AqqIf+D31lxVppdzV8CjAfZ6PklkmInZLw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.0.4.tgz", + "integrity": "sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", + "integrity": "sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", + "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", + "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", + "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz", + "integrity": "sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-menu": "2.0.6", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", + "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", + "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.0.tgz", + "integrity": "sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x" + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.0.2.tgz", + "integrity": "sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.0.6.tgz", + "integrity": "sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-callback-ref": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.1.4.tgz", + "integrity": "sha512-Cc+seCS3PmWmjI51ufGG7zp1cAAIRqHVw7C9LOA2TZ+R4hG6rDvHcTqIsEEFLmZO3zNVH72jOOE7kKNy8W+RtA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.3.tgz", + "integrity": "sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-rect": "1.0.1", + "@radix-ui/react-use-size": "1.0.1", + "@radix-ui/rect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.0.3.tgz", + "integrity": "sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.1.3.tgz", + "integrity": "sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", + "integrity": "sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.5.tgz", + "integrity": "sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz", + "integrity": "sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz", + "integrity": "sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz", + "integrity": "sha512-NKs15MJylfzVsCagVSWKhGGLNR1W9qWs+HtgbmjjVUB3B9+lb3PYoXxVju3kOrpf0VKyVCtZp+iTwVoqpa1Chw==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz", + "integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", + "integrity": "sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.0.1.tgz", + "integrity": "sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz", + "integrity": "sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/rect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz", + "integrity": "sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz", + "integrity": "sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.0.1.tgz", + "integrity": "sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/@remix-run/router": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.9.0.tgz", + "integrity": "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.10.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.10.7.tgz", + "integrity": "sha512-bXhjA7xsTcsW8JPTTYlUg/FuBpn8MNjiEPhkNhIGCUR6iRQM2+WEco4OBpvDeVcR9SE+bmWLzdfiY7bCbCSVuA==", + "dependencies": { + "@tanstack/table-core": "8.10.7" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.10.7", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.10.7.tgz", + "integrity": "sha512-KQk5OMg5OH6rmbHZxuNROvdI+hKDIUxANaHlV+dPlNN7ED3qYQ/WkpY2qlXww1SIdeMlkIhpN/2L00rof0fXFw==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.8.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.4.tgz", + "integrity": "sha512-ZVPnqU58giiCjSxjVUESDtdPk4QR5WQhhINbc9UBrKLU68MX5BF6kbQzTrkwbolyr0X8ChBpXfavr5mZFKZQ5A==", + "dev": true, + "dependencies": { + "undici-types": "~5.25.1" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.8", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.8.tgz", + "integrity": "sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==", + "devOptional": true + }, + "node_modules/@types/react": { + "version": "18.2.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.28.tgz", + "integrity": "sha512-ad4aa/RaaJS3hyGz0BGegdnSRXQBkd1CCYDCdNjBPg90UUpLgo+WlJqb9fMYUxtehmzF3PJaTWqRZjko6BRzBg==", + "devOptional": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.2.13", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.13.tgz", + "integrity": "sha512-eJIUv7rPP+EC45uNYp/ThhSpE16k22VJUknt5OLoH9tbXoi8bMhwLf5xRuWMywamNbWzhrSmU7IBJfPup1+3fw==", + "devOptional": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/scheduler": { + "version": "0.16.4", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.4.tgz", + "integrity": "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==", + "devOptional": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-2.2.0.tgz", + "integrity": "sha512-FFpefhvExd1toVRlokZgxgy2JtnBOdp4ZDsq7ldCWaqGSGn9UhWMAVm/1lxPL14JfNS5yGz+s9yFrQY6shoStA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.19.6", + "@babel/plugin-transform-react-jsx": "^7.19.0", + "@babel/plugin-transform-react-jsx-development": "^7.18.6", + "@babel/plugin-transform-react-jsx-self": "^7.18.6", + "@babel/plugin-transform-react-jsx-source": "^7.19.6", + "magic-string": "^0.26.7", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^3.0.0" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/aria-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz", + "integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/autoprefixer": { + "version": "10.4.16", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", + "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.21.10", + "caniuse-lite": "^1.0.30001538", + "fraction.js": "^4.3.6", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001547", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001547.tgz", + "integrity": "sha512-W7CrtIModMAxobGhz8iXmDfuJiiKg1WADMO/9x7/CLNin5cpSbuBjooyoIUVB5eyCc36QuTVlkVa1iB2S5+/eA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", + "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "dependencies": { + "clsx": "2.0.0" + }, + "funding": { + "url": "https://joebell.co.uk" + } + }, + "node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "devOptional": true + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/electron-to-chromium": { + "version": "1.4.549", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.549.tgz", + "integrity": "sha512-gpXfJslSi4hYDkA0mTLEpYKRv9siAgSUgZ+UWyk+J5Cttpd1ThCVwdclzIwQSclz3hYn049+M2fgrP1WpvF8xg==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", + "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.15.18", + "@esbuild/linux-loong64": "0.15.18", + "esbuild-android-64": "0.15.18", + "esbuild-android-arm64": "0.15.18", + "esbuild-darwin-64": "0.15.18", + "esbuild-darwin-arm64": "0.15.18", + "esbuild-freebsd-64": "0.15.18", + "esbuild-freebsd-arm64": "0.15.18", + "esbuild-linux-32": "0.15.18", + "esbuild-linux-64": "0.15.18", + "esbuild-linux-arm": "0.15.18", + "esbuild-linux-arm64": "0.15.18", + "esbuild-linux-mips64le": "0.15.18", + "esbuild-linux-ppc64le": "0.15.18", + "esbuild-linux-riscv64": "0.15.18", + "esbuild-linux-s390x": "0.15.18", + "esbuild-netbsd-64": "0.15.18", + "esbuild-openbsd-64": "0.15.18", + "esbuild-sunos-64": "0.15.18", + "esbuild-windows-32": "0.15.18", + "esbuild-windows-64": "0.15.18", + "esbuild-windows-arm64": "0.15.18" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", + "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", + "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", + "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", + "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", + "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", + "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", + "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", + "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", + "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", + "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", + "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", + "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", + "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", + "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", + "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", + "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", + "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", + "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", + "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", + "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.6.tgz", + "integrity": "sha512-n2aZ9tNfYDwaHhvFTkhFErqOMIb8uyzSQ+vGJBjZyanAKZVbGUQ1sngfk9FdkBw7G26O7AgNjLcecLffD1c7eg==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/goober": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", + "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", + "dev": true, + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/has": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", + "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "23.11.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.11.4.tgz", + "integrity": "sha512-CCUjtd5TfaCl+mLUzAA0uPSN+AVn4fP/kWCYt/hocPUwusTpMVczdrRyOBUwk6N05iH40qiKx6q1DoNJtBIwdg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.5.1.tgz", + "integrity": "sha512-+rNX1tghdVxdfjfPt0bI1sNg5ahGW9kA7OboG7b4t03Fp69NdDlRIze6yXhIbN8rbHxJ8IP4dzRm/okZ15lkQg==", + "dev": true, + "dependencies": { + "cross-fetch": "4.0.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", + "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/javascript-time-ago": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/javascript-time-ago/-/javascript-time-ago-2.5.10.tgz", + "integrity": "sha512-EUxp4BP74QH8xiYHyeSHopx1XhMMJ9qEX4rcBdFtpVWmKRdzpxbNzz2GSbuekZr5wt0rmLehuyp0PE34EAJT9g==", + "dependencies": { + "relative-time-format": "^1.1.6" + } + }, + "node_modules/jiti": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.20.0.tgz", + "integrity": "sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.292.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.292.0.tgz", + "integrity": "sha512-rRgUkpEHWpa5VCT66YscInCQmQuPCB1RFRzkkxMxg4b+jaL0V12E3riWWR2Sh5OIiUhCwGW/ZExuEO4Az32E6Q==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.7.tgz", + "integrity": "sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow==", + "dev": true, + "dependencies": { + "sourcemap-codec": "^1.4.8" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-releases": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", + "dev": true + }, + "node_modules/nodemon": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", + "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^5.7.1", + "simple-update-notifier": "^1.0.7", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=8.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "dev": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-watch": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/npm-watch/-/npm-watch-0.11.0.tgz", + "integrity": "sha512-wAOd0moNX2kSA2FNvt8+7ORwYaJpQ1ZoWjUYdb1bBCxq4nkWuU0IiJa9VpVxrj5Ks+FGXQd62OC/Bjk0aSr+dg==", + "dev": true, + "dependencies": { + "nodemon": "^2.0.7", + "through2": "^4.0.2" + }, + "bin": { + "npm-watch": "cli.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.1.tgz", + "integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^2.1.1" + }, + "engines": { + "node": ">= 14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dependencies": { + "postcss-selector-parser": "^6.0.11" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.48.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.48.2.tgz", + "integrity": "sha512-H0T2InFQb1hX7qKtDIZmvpU1Xfn/bdahWBN1fH19gSe4bBEqTfmlr7H3XWTaVtiK4/tpPaI1F3355GPMZYge+A==", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "dev": true, + "dependencies": { + "goober": "^2.1.10" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-i18next": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.1.tgz", + "integrity": "sha512-QSiKw+ihzJ/CIeIYWrarCmXJUySHDwQr5y8uaNIkbxoGRm/5DukkxZs+RPla79IKyyDPzC/DRlgQCABHtrQuQQ==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", + "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", + "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.3", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz", + "integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==", + "dependencies": { + "react-style-singleton": "^2.2.1", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.0.16.tgz", + "integrity": "sha512-UrnxmTZaTnbCl/xIOX38ig35RicqGfLuqt2x5fytpNlQvCRuxyXZwIBEhmF+pmrEGxfajyXFBoCplNxLvhF0CQ==", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-router": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.16.0.tgz", + "integrity": "sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==", + "dependencies": { + "@remix-run/router": "1.9.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.16.0.tgz", + "integrity": "sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==", + "dependencies": { + "@remix-run/router": "1.9.0", + "react-router": "6.16.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", + "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", + "dependencies": { + "get-nonce": "^1.0.0", + "invariant": "^2.2.4", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, + "node_modules/relative-time-format": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/relative-time-format/-/relative-time-format-1.1.6.tgz", + "integrity": "sha512-aCv3juQw4hT1/P/OrVltKWLlp15eW1GRcwP1XdxHrPdZE9MtgqFpegjnTjLhi2m2WI9MT/hQQtE+tjEWG1hgkQ==" + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "2.79.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", + "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/simple-update-notifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", + "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "dev": true, + "dependencies": { + "semver": "~7.0.0" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/sucrase": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", + "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "7.1.6", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", + "integrity": "sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz", + "integrity": "sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.12", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.18.2", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "dependencies": { + "nopt": "~1.0.10" + }, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "node_modules/undici-types": { + "version": "5.25.3", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", + "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", + "dev": true + }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz", + "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", + "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/vite": { + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.10.tgz", + "integrity": "sha512-Dx3olBo/ODNiMVk/cA5Yft9Ws+snLOXrhLtrI3F4XLt4syz2Yg8fayZMWScPKoz12v5BUv7VEmQHnsfpY80fYw==", + "dev": true, + "dependencies": { + "esbuild": "^0.15.9", + "postcss": "^8.4.18", + "resolve": "^1.22.1", + "rollup": "^2.79.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yaml": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", + "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==", + "engines": { + "node": ">= 14" + } + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + }, + "dependencies": { + "@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==" + }, + "@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@babel/code-frame": { + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "dev": true, + "requires": { + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" + } + }, + "@babel/compat-data": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.20.tgz", + "integrity": "sha512-BQYjKbpXjoXwFW5jGqiizJQQT/aC7pFm9Ok1OWssonuguICi264lbgMzRp2ZMmRSlfkX6DsWDDcsrctK8Rwfiw==", + "dev": true + }, + "@babel/core": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.0.tgz", + "integrity": "sha512-97z/ju/Jy1rZmDxybphrBuI+jtJjFVoz7Mr9yUQVVVi+DNZE333uFQeMOqcCIy1x3WYBIbWftUSLmbNXNT7qFQ==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.0", + "@babel/helpers": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.0", + "@babel/types": "^7.23.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + } + }, + "@babel/generator": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz", + "integrity": "sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==", + "dev": true, + "requires": { + "@babel/types": "^7.23.3", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.15", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + } + }, + "@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true + }, + "@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.15" + } + }, + "@babel/helper-module-transforms": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz", + "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true + }, + "@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-string-parser": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", + "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "dev": true + }, + "@babel/helpers": { + "version": "7.23.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.1.tgz", + "integrity": "sha512-chNpneuK18yW5Oxsr+t553UZzzAs3aZnFm4bxhebsNTeshrC95yA7l5yl7GBAG+JG1rF0F7zzD2EixK9mWSDoA==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.0", + "@babel/types": "^7.23.0" + } + }, + "@babel/highlight": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.3.tgz", + "integrity": "sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==", + "dev": true + }, + "@babel/plugin-syntax-jsx": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.22.5.tgz", + "integrity": "sha512-gvyP4hZrgrs/wWMaocvxZ44Hw0b3W8Pe+cMxc8V1ULQ07oh8VNbIRaoD1LRZVTvD+0nieDKjfgKg89sD7rrKrg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.22.15.tgz", + "integrity": "sha512-oKckg2eZFa8771O/5vi7XeTvmM6+O9cxZu+kanTU7tD4sin5nO/G8jGJhq8Hvt2Z0kUoEDRayuZLaUlYl8QuGA==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-jsx": "^7.22.5", + "@babel/types": "^7.22.15" + } + }, + "@babel/plugin-transform-react-jsx-development": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz", + "integrity": "sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A==", + "dev": true, + "requires": { + "@babel/plugin-transform-react-jsx": "^7.22.5" + } + }, + "@babel/plugin-transform-react-jsx-self": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.22.5.tgz", + "integrity": "sha512-nTh2ogNUtxbiSbxaT4Ds6aXnXEipHweN9YRgOX/oNXdf0cCrGn/+2LozFa3lnPV5D90MkjhgckCPBrsoSc1a7g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-react-jsx-source": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.22.5.tgz", + "integrity": "sha512-yIiRO6yobeEIaI0RTbIr8iAK9FcBHLtZq0S89ZPjDLQXBA4xvghaKqI0etp/tF3htTM0sazJKKLz9oEiGRtu7w==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/runtime": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", + "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", + "requires": { + "regenerator-runtime": "^0.14.0" + } + }, + "@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + } + }, + "@babel/traverse": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.3.tgz", + "integrity": "sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.3", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.3", + "@babel/types": "^7.23.3", + "debug": "^4.1.0", + "globals": "^11.1.0" + } + }, + "@babel/types": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz", + "integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + } + }, + "@esbuild/android-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.18.tgz", + "integrity": "sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.18.tgz", + "integrity": "sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==", + "dev": true, + "optional": true + }, + "@floating-ui/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz", + "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==", + "requires": { + "@floating-ui/utils": "^0.1.3" + } + }, + "@floating-ui/dom": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", + "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", + "requires": { + "@floating-ui/core": "^1.4.2", + "@floating-ui/utils": "^0.1.3" + } + }, + "@floating-ui/react-dom": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.2.tgz", + "integrity": "sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ==", + "requires": { + "@floating-ui/dom": "^1.5.1" + } + }, + "@floating-ui/utils": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", + "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==" + }, + "@hookform/resolvers": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.3.2.tgz", + "integrity": "sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==", + "requires": {} + }, + "@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==" + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==" + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "@jridgewell/trace-mapping": { + "version": "0.3.19", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", + "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@radix-ui/number": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", + "integrity": "sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-accordion": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz", + "integrity": "sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collapsible": "1.0.3", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + } + }, + "@radix-ui/react-arrow": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", + "integrity": "sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + } + }, + "@radix-ui/react-avatar": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.0.4.tgz", + "integrity": "sha512-kVK2K7ZD3wwj3qhle0ElXhOjbezIgyl2hVvgwfIdexL3rN6zJmy5AqqIf+D31lxVppdzV8CjAfZ6PklkmInZLw==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-checkbox": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.0.4.tgz", + "integrity": "sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + } + }, + "@radix-ui/react-collapsible": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", + "integrity": "sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-collection": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", + "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + } + }, + "@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", + "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + } + }, + "@radix-ui/react-direction": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", + "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + } + }, + "@radix-ui/react-dropdown-menu": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz", + "integrity": "sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-menu": "2.0.6", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + } + }, + "@radix-ui/react-focus-guards": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", + "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-focus-scope": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", + "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1" + } + }, + "@radix-ui/react-icons": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.0.tgz", + "integrity": "sha512-jQxj/0LKgp+j9BiTXz3O3sgs26RNet2iLWmsPyRz2SIcR4q/4SbazXfnYwbAr+vLYKSfc7qxzyGQA1HLlYiuNw==", + "requires": {} + }, + "@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-label": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.0.2.tgz", + "integrity": "sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + } + }, + "@radix-ui/react-menu": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.0.6.tgz", + "integrity": "sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-callback-ref": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + } + }, + "@radix-ui/react-navigation-menu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.1.4.tgz", + "integrity": "sha512-Cc+seCS3PmWmjI51ufGG7zp1cAAIRqHVw7C9LOA2TZ+R4hG6rDvHcTqIsEEFLmZO3zNVH72jOOE7kKNy8W+RtA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + } + }, + "@radix-ui/react-popper": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.3.tgz", + "integrity": "sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==", + "requires": { + "@babel/runtime": "^7.13.10", + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-rect": "1.0.1", + "@radix-ui/react-use-size": "1.0.1", + "@radix-ui/rect": "1.0.1" + } + }, + "@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + } + }, + "@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + } + }, + "@radix-ui/react-progress": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.0.3.tgz", + "integrity": "sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3" + } + }, + "@radix-ui/react-radio-group": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.1.3.tgz", + "integrity": "sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==", + "dev": true, + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + } + }, + "@radix-ui/react-roving-focus": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", + "integrity": "sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1" + } + }, + "@radix-ui/react-scroll-area": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.0.5.tgz", + "integrity": "sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-select": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.0.0.tgz", + "integrity": "sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + } + }, + "@radix-ui/react-separator": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.0.3.tgz", + "integrity": "sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + } + }, + "@radix-ui/react-slider": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.1.2.tgz", + "integrity": "sha512-NKs15MJylfzVsCagVSWKhGGLNR1W9qWs+HtgbmjjVUB3B9+lb3PYoXxVju3kOrpf0VKyVCtZp+iTwVoqpa1Chw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/number": "1.0.1", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1", + "@radix-ui/react-use-previous": "1.0.1", + "@radix-ui/react-use-size": "1.0.1" + } + }, + "@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + } + }, + "@radix-ui/react-tabs": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz", + "integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1" + } + }, + "@radix-ui/react-tooltip": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", + "integrity": "sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + } + }, + "@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + } + }, + "@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + } + }, + "@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-use-previous": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.0.1.tgz", + "integrity": "sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-use-rect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.0.1.tgz", + "integrity": "sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/rect": "1.0.1" + } + }, + "@radix-ui/react-use-size": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.0.1.tgz", + "integrity": "sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-visually-hidden": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.0.3.tgz", + "integrity": "sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + } + }, + "@radix-ui/rect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.0.1.tgz", + "integrity": "sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@remix-run/router": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.9.0.tgz", + "integrity": "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA==" + }, + "@tanstack/react-table": { + "version": "8.10.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.10.7.tgz", + "integrity": "sha512-bXhjA7xsTcsW8JPTTYlUg/FuBpn8MNjiEPhkNhIGCUR6iRQM2+WEco4OBpvDeVcR9SE+bmWLzdfiY7bCbCSVuA==", + "requires": { + "@tanstack/table-core": "8.10.7" + } + }, + "@tanstack/table-core": { + "version": "8.10.7", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.10.7.tgz", + "integrity": "sha512-KQk5OMg5OH6rmbHZxuNROvdI+hKDIUxANaHlV+dPlNN7ED3qYQ/WkpY2qlXww1SIdeMlkIhpN/2L00rof0fXFw==" + }, + "@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true + }, + "@types/node": { + "version": "20.8.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.4.tgz", + "integrity": "sha512-ZVPnqU58giiCjSxjVUESDtdPk4QR5WQhhINbc9UBrKLU68MX5BF6kbQzTrkwbolyr0X8ChBpXfavr5mZFKZQ5A==", + "dev": true, + "requires": { + "undici-types": "~5.25.1" + } + }, + "@types/prop-types": { + "version": "15.7.8", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.8.tgz", + "integrity": "sha512-kMpQpfZKSCBqltAJwskgePRaYRFukDkm1oItcAbC3gNELR20XIBcN9VRgg4+m8DKsTfkWeA4m4Imp4DDuWy7FQ==", + "devOptional": true + }, + "@types/react": { + "version": "18.2.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.28.tgz", + "integrity": "sha512-ad4aa/RaaJS3hyGz0BGegdnSRXQBkd1CCYDCdNjBPg90UUpLgo+WlJqb9fMYUxtehmzF3PJaTWqRZjko6BRzBg==", + "devOptional": true, + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-dom": { + "version": "18.2.13", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.13.tgz", + "integrity": "sha512-eJIUv7rPP+EC45uNYp/ThhSpE16k22VJUknt5OLoH9tbXoi8bMhwLf5xRuWMywamNbWzhrSmU7IBJfPup1+3fw==", + "devOptional": true, + "requires": { + "@types/react": "*" + } + }, + "@types/scheduler": { + "version": "0.16.4", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.4.tgz", + "integrity": "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==", + "devOptional": true + }, + "@vitejs/plugin-react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-2.2.0.tgz", + "integrity": "sha512-FFpefhvExd1toVRlokZgxgy2JtnBOdp4ZDsq7ldCWaqGSGn9UhWMAVm/1lxPL14JfNS5yGz+s9yFrQY6shoStA==", + "dev": true, + "requires": { + "@babel/core": "^7.19.6", + "@babel/plugin-transform-react-jsx": "^7.19.0", + "@babel/plugin-transform-react-jsx-development": "^7.18.6", + "@babel/plugin-transform-react-jsx-self": "^7.18.6", + "@babel/plugin-transform-react-jsx-source": "^7.19.6", + "magic-string": "^0.26.7", + "react-refresh": "^0.14.0" + } + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "aria-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.3.tgz", + "integrity": "sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==", + "requires": { + "tslib": "^2.0.0" + } + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "autoprefixer": { + "version": "10.4.16", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", + "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==", + "dev": true, + "requires": { + "browserslist": "^4.21.10", + "caniuse-lite": "^1.0.30001538", + "fraction.js": "^4.3.6", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + } + }, + "axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "requires": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==" + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "requires": { + "fill-range": "^7.1.1" + } + }, + "browserslist": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.13" + } + }, + "camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" + }, + "caniuse-lite": { + "version": "1.0.30001547", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001547.tgz", + "integrity": "sha512-W7CrtIModMAxobGhz8iXmDfuJiiKg1WADMO/9x7/CLNin5cpSbuBjooyoIUVB5eyCc36QuTVlkVa1iB2S5+/eA==", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, + "class-variance-authority": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", + "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "requires": { + "clsx": "2.0.0" + } + }, + "clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==" + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==" + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dev": true, + "requires": { + "node-fetch": "^2.6.12" + } + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==" + }, + "csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", + "devOptional": true + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, + "didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "electron-to-chromium": { + "version": "1.4.549", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.549.tgz", + "integrity": "sha512-gpXfJslSi4hYDkA0mTLEpYKRv9siAgSUgZ+UWyk+J5Cttpd1ThCVwdclzIwQSclz3hYn049+M2fgrP1WpvF8xg==", + "dev": true + }, + "esbuild": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", + "integrity": "sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==", + "dev": true, + "requires": { + "@esbuild/android-arm": "0.15.18", + "@esbuild/linux-loong64": "0.15.18", + "esbuild-android-64": "0.15.18", + "esbuild-android-arm64": "0.15.18", + "esbuild-darwin-64": "0.15.18", + "esbuild-darwin-arm64": "0.15.18", + "esbuild-freebsd-64": "0.15.18", + "esbuild-freebsd-arm64": "0.15.18", + "esbuild-linux-32": "0.15.18", + "esbuild-linux-64": "0.15.18", + "esbuild-linux-arm": "0.15.18", + "esbuild-linux-arm64": "0.15.18", + "esbuild-linux-mips64le": "0.15.18", + "esbuild-linux-ppc64le": "0.15.18", + "esbuild-linux-riscv64": "0.15.18", + "esbuild-linux-s390x": "0.15.18", + "esbuild-netbsd-64": "0.15.18", + "esbuild-openbsd-64": "0.15.18", + "esbuild-sunos-64": "0.15.18", + "esbuild-windows-32": "0.15.18", + "esbuild-windows-64": "0.15.18", + "esbuild-windows-arm64": "0.15.18" + } + }, + "esbuild-android-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.18.tgz", + "integrity": "sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==", + "dev": true, + "optional": true + }, + "esbuild-android-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.18.tgz", + "integrity": "sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==", + "dev": true, + "optional": true + }, + "esbuild-darwin-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.18.tgz", + "integrity": "sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==", + "dev": true, + "optional": true + }, + "esbuild-darwin-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.18.tgz", + "integrity": "sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==", + "dev": true, + "optional": true + }, + "esbuild-freebsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.18.tgz", + "integrity": "sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==", + "dev": true, + "optional": true + }, + "esbuild-freebsd-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.18.tgz", + "integrity": "sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==", + "dev": true, + "optional": true + }, + "esbuild-linux-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.18.tgz", + "integrity": "sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==", + "dev": true, + "optional": true + }, + "esbuild-linux-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.18.tgz", + "integrity": "sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==", + "dev": true, + "optional": true + }, + "esbuild-linux-arm": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.18.tgz", + "integrity": "sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==", + "dev": true, + "optional": true + }, + "esbuild-linux-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.18.tgz", + "integrity": "sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==", + "dev": true, + "optional": true + }, + "esbuild-linux-mips64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.18.tgz", + "integrity": "sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==", + "dev": true, + "optional": true + }, + "esbuild-linux-ppc64le": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.18.tgz", + "integrity": "sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==", + "dev": true, + "optional": true + }, + "esbuild-linux-riscv64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.18.tgz", + "integrity": "sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==", + "dev": true, + "optional": true + }, + "esbuild-linux-s390x": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.18.tgz", + "integrity": "sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==", + "dev": true, + "optional": true + }, + "esbuild-netbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.18.tgz", + "integrity": "sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==", + "dev": true, + "optional": true + }, + "esbuild-openbsd-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.18.tgz", + "integrity": "sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==", + "dev": true, + "optional": true + }, + "esbuild-sunos-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.18.tgz", + "integrity": "sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==", + "dev": true, + "optional": true + }, + "esbuild-windows-32": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.18.tgz", + "integrity": "sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==", + "dev": true, + "optional": true + }, + "esbuild-windows-64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.18.tgz", + "integrity": "sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==", + "dev": true, + "optional": true + }, + "esbuild-windows-arm64": { + "version": "0.15.18", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.18.tgz", + "integrity": "sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==", + "dev": true, + "optional": true + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "requires": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "dependencies": { + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "requires": { + "is-glob": "^4.0.1" + } + } + } + }, + "fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "requires": { + "reusify": "^1.0.4" + } + }, + "fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "fraction.js": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.6.tgz", + "integrity": "sha512-n2aZ9tNfYDwaHhvFTkhFErqOMIb8uyzSQ+vGJBjZyanAKZVbGUQ1sngfk9FdkBw7G26O7AgNjLcecLffD1c7eg==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "optional": true + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==" + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "requires": { + "is-glob": "^4.0.3" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "goober": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", + "integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", + "dev": true, + "requires": {} + }, + "has": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.4.tgz", + "integrity": "sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ==" + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "requires": { + "void-elements": "3.1.0" + } + }, + "i18next": { + "version": "23.11.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.11.4.tgz", + "integrity": "sha512-CCUjtd5TfaCl+mLUzAA0uPSN+AVn4fP/kWCYt/hocPUwusTpMVczdrRyOBUwk6N05iH40qiKx6q1DoNJtBIwdg==", + "requires": { + "@babel/runtime": "^7.23.2" + } + }, + "i18next-http-backend": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.5.1.tgz", + "integrity": "sha512-+rNX1tghdVxdfjfPt0bI1sNg5ahGW9kA7OboG7b4t03Fp69NdDlRIze6yXhIbN8rbHxJ8IP4dzRm/okZ15lkQg==", + "dev": true, + "requires": { + "cross-fetch": "4.0.0" + } + }, + "ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-core-module": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", + "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", + "requires": { + "has": "^1.0.3" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "javascript-time-ago": { + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/javascript-time-ago/-/javascript-time-ago-2.5.10.tgz", + "integrity": "sha512-EUxp4BP74QH8xiYHyeSHopx1XhMMJ9qEX4rcBdFtpVWmKRdzpxbNzz2GSbuekZr5wt0rmLehuyp0PE34EAJT9g==", + "requires": { + "relative-time-format": "^1.1.6" + } + }, + "jiti": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.20.0.tgz", + "integrity": "sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==" + }, + "js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==" + }, + "lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "lucide-react": { + "version": "0.292.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.292.0.tgz", + "integrity": "sha512-rRgUkpEHWpa5VCT66YscInCQmQuPCB1RFRzkkxMxg4b+jaL0V12E3riWWR2Sh5OIiUhCwGW/ZExuEO4Az32E6Q==", + "requires": {} + }, + "magic-string": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.7.tgz", + "integrity": "sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow==", + "dev": true, + "requires": { + "sourcemap-codec": "^1.4.8" + } + }, + "merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" + }, + "micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "requires": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + } + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "requires": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==" + }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "node-releases": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", + "dev": true + }, + "nodemon": { + "version": "2.0.22", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz", + "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==", + "dev": true, + "requires": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^5.7.1", + "simple-update-notifier": "^1.0.7", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true + } + } + }, + "nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true + }, + "npm-watch": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/npm-watch/-/npm-watch-0.11.0.tgz", + "integrity": "sha512-wAOd0moNX2kSA2FNvt8+7ORwYaJpQ1ZoWjUYdb1bBCxq4nkWuU0IiJa9VpVxrj5Ks+FGXQd62OC/Bjk0aSr+dg==", + "dev": true, + "requires": { + "nodemon": "^2.0.7", + "through2": "^4.0.2" + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, + "object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "requires": { + "wrappy": "1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==" + }, + "pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==" + }, + "postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "requires": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + }, + "postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "requires": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + } + }, + "postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "requires": { + "camelcase-css": "^2.0.1" + } + }, + "postcss-load-config": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.1.tgz", + "integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==", + "requires": { + "lilconfig": "^2.0.5", + "yaml": "^2.1.1" + } + }, + "postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "requires": { + "postcss-selector-parser": "^6.0.11" + } + }, + "postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, + "postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" + }, + "react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, + "react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "requires": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + } + }, + "react-hook-form": { + "version": "7.48.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.48.2.tgz", + "integrity": "sha512-H0T2InFQb1hX7qKtDIZmvpU1Xfn/bdahWBN1fH19gSe4bBEqTfmlr7H3XWTaVtiK4/tpPaI1F3355GPMZYge+A==", + "requires": {} + }, + "react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "dev": true, + "requires": { + "goober": "^2.1.10" + } + }, + "react-i18next": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.1.tgz", + "integrity": "sha512-QSiKw+ihzJ/CIeIYWrarCmXJUySHDwQr5y8uaNIkbxoGRm/5DukkxZs+RPla79IKyyDPzC/DRlgQCABHtrQuQQ==", + "requires": { + "@babel/runtime": "^7.23.9", + "html-parse-stringify": "^3.0.1" + } + }, + "react-refresh": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", + "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", + "dev": true + }, + "react-remove-scroll": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", + "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", + "requires": { + "react-remove-scroll-bar": "^2.3.3", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + } + }, + "react-remove-scroll-bar": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.4.tgz", + "integrity": "sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==", + "requires": { + "react-style-singleton": "^2.2.1", + "tslib": "^2.0.0" + } + }, + "react-resizable-panels": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.0.16.tgz", + "integrity": "sha512-UrnxmTZaTnbCl/xIOX38ig35RicqGfLuqt2x5fytpNlQvCRuxyXZwIBEhmF+pmrEGxfajyXFBoCplNxLvhF0CQ==", + "requires": {} + }, + "react-router": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.16.0.tgz", + "integrity": "sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==", + "requires": { + "@remix-run/router": "1.9.0" + } + }, + "react-router-dom": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.16.0.tgz", + "integrity": "sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==", + "requires": { + "@remix-run/router": "1.9.0", + "react-router": "6.16.0" + } + }, + "react-style-singleton": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", + "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", + "requires": { + "get-nonce": "^1.0.0", + "invariant": "^2.2.4", + "tslib": "^2.0.0" + } + }, + "read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "requires": { + "pify": "^2.3.0" + } + }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "requires": { + "picomatch": "^2.2.1" + } + }, + "regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, + "relative-time-format": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/relative-time-format/-/relative-time-format-1.1.6.tgz", + "integrity": "sha512-aCv3juQw4hT1/P/OrVltKWLlp15eW1GRcwP1XdxHrPdZE9MtgqFpegjnTjLhi2m2WI9MT/hQQtE+tjEWG1hgkQ==" + }, + "resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "requires": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" + }, + "rollup": { + "version": "2.79.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", + "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "requires": { + "queue-microtask": "^1.2.2" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + }, + "scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "requires": { + "loose-envify": "^1.1.0" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "simple-update-notifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", + "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "dev": true, + "requires": { + "semver": "~7.0.0" + }, + "dependencies": { + "semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "dev": true + } + } + }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" + }, + "sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "sucrase": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", + "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", + "requires": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "7.1.6", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" + }, + "tailwind-merge": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", + "integrity": "sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==" + }, + "tailwindcss": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz", + "integrity": "sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==", + "requires": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.12", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.18.2", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + } + }, + "tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "requires": {} + }, + "thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "requires": { + "any-promise": "^1.0.0" + } + }, + "thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "requires": { + "thenify": ">= 3.1.0 < 4" + } + }, + "through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "dev": true, + "requires": { + "readable-stream": "3" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "requires": { + "is-number": "^7.0.0" + } + }, + "touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "requires": { + "nopt": "~1.0.10" + } + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, + "ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true + }, + "undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "undici-types": { + "version": "5.25.3", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.25.3.tgz", + "integrity": "sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA==", + "dev": true + }, + "update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "requires": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + } + }, + "use-callback-ref": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.0.tgz", + "integrity": "sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==", + "requires": { + "tslib": "^2.0.0" + } + }, + "use-sidecar": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", + "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", + "requires": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "vite": { + "version": "3.2.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.10.tgz", + "integrity": "sha512-Dx3olBo/ODNiMVk/cA5Yft9Ws+snLOXrhLtrI3F4XLt4syz2Yg8fayZMWScPKoz12v5BUv7VEmQHnsfpY80fYw==", + "dev": true, + "requires": { + "esbuild": "^0.15.9", + "fsevents": "~2.3.2", + "postcss": "^8.4.18", + "resolve": "^1.22.1", + "rollup": "^2.79.1" + } + }, + "void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "yaml": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", + "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==" + }, + "zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==" + } + } +} diff --git a/src/tribler/ui/package.json b/src/tribler/ui/package.json new file mode 100644 index 0000000000..55a5784f88 --- /dev/null +++ b/src/tribler/ui/package.json @@ -0,0 +1,63 @@ +{ + "name": "tribler-webui", + "private": true, + "version": "0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^3.3.2", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.7", + "@tanstack/react-table": "^8.10.7", + "axios": "^1.6.8", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "i18next": "^23.11.4", + "javascript-time-ago": "^2.5.10", + "js-cookie": "^3.0.5", + "lucide-react": "^0.292.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.48.2", + "react-i18next": "^14.1.1", + "react-resizable-panels": "^2.0.16", + "react-router-dom": "^6.16.0", + "tailwind-merge": "^1.14.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.22.4" + }, + "devDependencies": { + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-slider": "^1.1.2", + "@types/js-cookie": "^3.0.6", + "@types/node": "^20.8.4", + "@types/react": "^18.0.17", + "@types/react-dom": "^18.0.6", + "@vitejs/plugin-react": "^2.1.0", + "autoprefixer": "^10.4.16", + "i18next-http-backend": "^2.5.1", + "npm-watch": "^0.11.0", + "postcss": "^8.4.31", + "react-hot-toast": "^2.4.1", + "tailwindcss": "^3.3.3", + "typescript": "^4.6.4", + "vite": "^3.1.0" + } +} diff --git a/src/tribler/ui/postcss.config.js b/src/tribler/ui/postcss.config.js new file mode 100644 index 0000000000..2e7af2b7f1 --- /dev/null +++ b/src/tribler/ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/src/tribler/ui/public/favicon.ico b/src/tribler/ui/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..aaea20ee79ffd3be2643be5eb710de444014fa21 GIT binary patch literal 24190 zcmeHPd0bW1_FpI};sgqc3Npw%Pcq3Y${;E@&x!+}si>h48D@i`m1*@V%3heJ1+Gnq| z_F52GQC|uTwIh)VdlNm5tDBqr{9_LyhB7-l`PtWs=xSd=d#O3?8%Q*;pJ=Z<+YlWY zF51h_g}sOtdZ^la5q;>RYTut|%_LQOYoeiZiF^SrUa=V2icr`|a)~}cv02YhX!<)e zdG2Ghw)6lwMW3Z!K@Aj`(@GinS}MEyMOwS9jsEm<8@>IWmfAFoIOm=8+HsX_L5V84 z85+(~m6nujG?W_^my{&yD@@9j5hFtRrfuz&>hg;}S}fnhSv!EL{j;361E|VtIoBCF zQk9>>kbI=CP?eXXTy*rsS^QCz-ywHYTFZ_am6wQ)o+#NQy++l(WGBl1UV`p`ub~{M zqa|-Cd6sjns-KE&C8c@#l2$;e&pGocwrsc6IFRJ{2xIYxd? z9*Lh*uVCykQ<^BVppDAzJ4_oM(9o9chw0C+Yv}0v8ftCpaG$ZVSCV)?QocSdksf>L zAM=+>&%N^tK9q}=DbH(|`q?`F9RFG>kn64UFLJHXGd{ap^UFWVm+O1wui^kW$F}O9 z^J{qv@`v(spN;tkxweqAjmmS$#&XV9ah(F+r=;XA&L84!?ayVLf5)5KpG(CcU+v)8 zrlLfu|5~ZHsi-i0t`P95`ict|zWwZ#jq-S)z4(eDZIH&J4<2{y+ws{p{c~x>j#_pR zJBLzVQYtn9-e!0%IVeA`li^F`=Wi+u&uIViClwW2l<_VptJR5@>YocrO1Fu1u7A!i z05amEBem}r5fLjsBPUKA!n1f2BigJbN^1G<=gor8?U3D&^?ZIe?)6F3i7e8R^)+M{ zdI7wBlOl7@(#X^{3Y*$QiHkX97hI!t+gj;`DlHv5t)a$d!QVuuPMtEAr+P*pCF3Ku z16Ma(&%G#LpKTty7tg)sX%;n!>m2^k+NXeX=?Z3$t|gU~%SCzV@mC+u zLAyISKe8BbEBU8Si0jM)B_($gS*K;#+uKRF{JRg)@6E`$R@#b5Bvi)Kio+{S=K%X(E^81{#vg$T5|X(>NUs zAJ51+ozbWaMx)bilGlWrJklD;b3zk&XEagpq#NX$)kOZ;&E%eicG;Y~CNT<} z+(N!nIECc2kY5g-r!oqj&M0!)4T_rCNbz%SQtX@til5s^Nedb&EVqRsrnl0V8LbpH zlTpkp+|OZX(?;?0G?Xx3LoxFh#m#3FzmS1n8KusAk1`j0Kq(8GD0yKUvX;^oH&WK} z2AZ<6fl~5xl(s}m;Ya40q?Ts{J zt%hdb&M2?6kqS1q(vrIv72+y%jMeux(XuU^R%~gbRrhMBXsd?Gb~e-c?QPf_T50_b zMw=hg(9UO@>7K{6bl;P$bnjCfR~>DCs)e@i(bB`ux6(t;G1`UeqkG%vvAwOd`$a~T z`!)2;t1YzWbxzM6Y@-(rwbDy(w$gJ|oc14Sqy5#KUaQv9TkmP;@OxZZpYMOFq0^sf z=!4I7^wD`vpM0sI&%V;or(bF5i?4O`2O{3++7;vQ}bcjmn9POk!dWpFGVd7!$63_jJ z_`37NcYjN~>I(7mb%yUmAcfFG%ApmsmbRgXz3AZs^zc1;sI%vEBaBl+E~ln-oSJuY zYOUg=InNF7BLN=-_$0tj1^iOLZv=b=;4AGpy&T5rtz1s;uj6!XH>Yo_I9)s64sQea zfq-`e{7AsN1Ku0(fq+l6=QKNv)4E(v53WOByE)ZVar)=^cKFV|4Y#cBeutsMkw%E* zqNGtsetwRYzE1Wd-P}ftr~Vz5+B><9eE#(5)9=hylOY56n74O! za&jt1^V6q~k2fM*f-rCI;XcyoEvfzKDt{vYfmBa3{QbQ>oCP`}RZ2rOdbC%Nem2b8 z2L||iM;(>hpPnlZ?sM-xl^{cyw~ver@b_Q$z6@~4T>xai2kIY`hSt3Om@xw41?2^I ziP?BUdU!=3W!^qI3J`(g-<1L080qTjaYDL(FAGcpV~|NojENSn_>=O2Sx&BN<=dyk z{F%2;2LK=@C@ zAOun}nF>~?{|q(+r<=FWTckidq;D!+A8;Ni0L1*e<0U%zI#3{hd!zDV$`hqHP6;@cI7?MChAMMeq;^Y$Az0Als>WqCV{ z+RvBy*4k|RA?*VKNEzd^O5)o{7%ENDMi}D6E7oi}T5U+j-prPm%0m0%4SEP5r3}@i zY>7p{=@2YU()!zrH$duF$j}4GED{q9L#atBSOEyULXffHcJYeU4?cXS%p&QD=Ixi{ z7p_<@kSg7C_??rl?cV(ONzB>H3uOpflip!3U9zM=K$N_0G@j20Hq6R2Z$D$^;wAY7 zh3lV{nj83`I!^#hf??Mr(xyz^ds;x8xL2MHWy0gk+J}V$B1J%K+jqF? z#f=zS0im$SShMyap<&^%vB?S<`q|L4NW5A5;9x)qq+~MW*#J|?e7n*@``{4i9SVdp zf0%JF?#A29+W*!9E~T^#!}V@qx56_z=&4+-{|)M z@>6n&{fvf;IZva;ekIwv283K9@~)M2QvX`A396+5(Y0h3ewiG`T&Cf%SI8ydD%r)= z($J*qXj_NF99dWG16`G%t5RUIq^A<}RDzz`I~00r=uJsawF=eX;73Qjqd4`A;WS_@ zhknW>{d7poP3WjM$RVx)dg=|8p32EP<#X~+`+|bf&r#UKztET|f1#M%^AtbhOL87p zOQTX6p|f)6tDIcYIrLRdZYlpEue2Y@J-wEECf1T)#!nQS{WAqksip9oYZN}cmZGNr zLb0=crKEYkQsVqtavRr3?grhqk^Hmjq1!e~x~*Flmv!68yj{1=ZItxefLxSK<sn}TaT_fxX`%eP znrP`J4J|I`v~kN}D!uoxWXs%i|6$r(fxP_)t+`)IB@Z^!x(W>y?`Wmshd7n))KU2( zt+eS;F4;WquVnPVo)+5mTnqhauZA9ZiPIDNTIreBp{rK4(O&4Pue`0Hy@$25Pw1;h zwDjsRt)#OaJgLLEoQ95{(a?uq$olDLf7Q}?=%;`ErhBlw9k zQe&cLRHNpo(=+({D$r2(pFL+LTD*#ynvJjH&vu}p7QH6t%|(kRMg41nj@gcGZtD7+ zg1m)uXH|%Lkxn={Iy$QBb5|9j#eH&ZP0ich9zz|~qUO}nHLD8qx9aPw!ULQ<)S}#m zyF`O+`uaB_!UF=-qS76AZ$=A4{XvXIxY~5EVat}y<#%r0f8fCWeXqRo(w{IIk?Q(s z<#%sE7k91^Xh^iC#;8^C+;y8oi~FB=e}maVMR*VfcLvZ^3ILB*e$6Xq{k`naL~ z;GMuTQB@CFY~K8}Vt#85KmF{}TgvY&U6Z7$ca0sFHh$g%M`~&g-Lt-YGe+aCJCjxQ zqe4Q_V#0!Z?q0F7NTzl3?W+3WLBSzmq6N@c0b0dq6sziY+t}DVi1g4yJ9j?(@WZ=y z?Rx4drCNv!|9{RMI+s(#xr1%M1+opiKn{@?$YtzBvJL)Tk~ez7{%sp}g~&&iC)#C+ zChCndI3Y_646ldmprt_xoCZh9_H4(5CK?`JPfqc$NvC{F@v}arq&a6{$8Mxy;~)z_ z4)99*oQXM*oU;lv zI6GvN3A^*0Vn&NfTdAgf0fZS>(e4V^j1 z=!?Gz+b{OkiyV8au=DCjqh*pkH(?hM#6_BOj;H`bUdruh2M?n?JeS_!>*y5UO<(dV zy3AqQRs3kYLm-dl5Y1gnRJ@C5=OLo~fPcS^=se)R0sMD>zXbT70ACCECKY^$H&IRs z(b{=LyTIRvb`qW2PgHjr=VxEq6aOZR_=Q~Jm)8;hc{lO8D&kG&4et+e0K5m_LjjNV zc?f<*=g?Q(3c%lCPkcuh@n>?0A6!TLz1`@uiujfD?eH*%<-p{y7JJ(+d>;>C4?BnN zU>)`f?lzcsVi@uHxx~xX5r1Mg@gr5l|9ZY1Ua}?+gPh?GIU@kpD->)uit=y1MTbv4~9FLNVaiP2T3K$)@8G}C`U9Is65-J z7cGtLH*5M(MKp18Tv#-B*pj8b%Cm=acF~e?Mf3V8R!^_dV~dLNiYE2xA@?5?5L1Md z(O;=g2u>8wnGT8-Dmf`(V%GRHKWD|t9TJ~1b6W0#)X}aoym#Pi(SDAMIro1hlXW`Y6NW5K zRuneEUB^jJM@KCL?!%~m&i&-}-X-$6+D1M4mAydrgSJYn9`x9LC`WdzX}Lr_v$othQAdJ zn6?t~5hI^^;4iYC4*gt+tLa>HK!-FJ%6zEjB3M5c;;Nbpw}amT^GM{*)K}l+&?>rve;I6JOwR3e z^b5Pea0U<41YoMtb5P6Ruc1fV=ql!6bUMb} z_ZF>{&0{4$L2RzH04H!ZUTZ%OOi>^o(Nr#e|1Zb!R4{yhdS zE9)0p&-w<}F+qp5e=U2l%9wZ5_=|h^xTwBUpv8m^_%tz_Fu_ljcuKW4ZUO#1f&ZY0 z8w~zZ%r>N+*#y-w_(QQDuIfzDHazQ46*mWCEq$xZzqjzyVK>2o@Z(|dpXz`PtchEI zzx9yiWzc(>UE~c0U6}R9T-XNJGx#|%ZO0@q_yVz>{@5>6-&?^`zF7?naT10k*8Tb& zt*mE|YA(b&7i--0NVoZ?m&5Wh_@J>N(KneX9q@B9;9=k8T{8eOz_E5@ULaNU$9JT3s`Cd`ZoPe z(Wtk&J^nVsmX~>^H!+X#O-xOPnx|ekgvsw9!EcYjPR6VtgM12pvXtN_i0`dRHq?J_ zZwdbSWw4zw_elm}=Cgz&j$n-oV=*YRP$r4(IGWd#Oz0^tHOhcjS2L71xcgpB{ z3%+yd{`mKG$}bC?)WZBHHZz~hX6A*t7!5jb-lx#ffO%=?a9s*C;s43tx2nRp{T4ox z%zljc&bG^jhPiM#+-?4`9iCqnn$yC9CbzJFNzKepO^1r7;J4k5l*t|ZMcHF7S>}EC zjT+X6{yQb@8DcJO0sj3)2( zoeRp67HW9>d<}w#=%n@JjJm#IMxP-Z@DSz(z6AWjf&v+F3BQ7 zzBQH8tz5d#|ASl#$|fw+aro2o)Wx7(ADV?Gd5f-;2;Wo;OE%6M&=vIzFS&SVk2 zoIcR6`}p@CSx^?Yfb+yfoU7@`RP&U{+DKonmA+TX_ZGT*8^gZAk{7kH+qY{NVs#ke ze+;sSpNTA@m(%S6y05?V8&OaeHHY(Y6) z7>5rOM{Ev9d=E!F5kqVeLktl^nIQ=q1>%ArlRy@MY=}54BbiFbw}S(_kH78kf-?BB z@yOYnN6qCNzH2-lbR>a}RL~*ThFIH(L$a6z!kw5Y7~-FdWh#|?>k!<1{QCm`fT@fj z28|)Uj7837EE;qOo=Q-73b9umCc}VtaD(p^L(H1AHVpEuBBu`x?Y{lQ#;Kspdop8) zrDK897z>%fSokc?;j19=l%V0tPaUGqJ3!k|Ec6$|(8=y!-g~8CFo{K9Moc zNsReSVaz|5v7l+3htA+U0(7L{yQI_oe*6*N#vmKQ{@%un<@8oIGP3*j&t5|d$`A|5 zTr(N-03C>NWQcF%0aH1L-zSg9_#QoVUR@_ zWD$n%tuzB^aYfSg;|2=^}VpADnQ<+m5Vj?&96n1054U2YkI4;)|vE5cEJ!mw3uI>6YMs2Zb}% zBZ9GBpu+}q^o!+uKs@JmiJT8f1|6xKJAtQ0f)1m#0lt#IxB7iUrUUWR5*>)8W+~vQ zG!;(?eiD2o)`ow|@$R0VTG8ObVhY1qxRQHD=s<^#BUYXFi`DUgV|Cm^@Fn(>~7orhiLGa{7AQ0k#22Sp{Uww`H z7ovXz8sTU3mL|(@5wFlFuNYmU@1^6b=MTd3*axqu_sMKPM2iSQk_i#C7~TRxWLgY_ zXviBruw5nekEkl|I)|8ggt%aDN5G2{{tpLeF(0l#lUmR!P#KMwrA{D+Z%E5d)oq_n?91bdkbm*?kQ_CFxxGOT?# z%tq{#M$<0)Kw=Y7Q1aCK-3-ZXL?F=fRT`IZ(u~Lq=J{Ed{o6-efh7wEwf)2D5yE5` zBHVnAC?sX-Z@gHgSYC0AICFiA!o#CK?XrKz=&Lvt#$hUKmk3?E@{^GoBZ9Yl>IO#C z19`)%_Z@{(pR`hbL;oYj{s*UQFwWu37GZLT)H3vg8xeTrU5oH9L!nm7yfP&2!g}=h zq)YrqCH;s~NgU?EUNbzt!6U7qv8f$Z7$Fb`h_Ek1|KeX?=_iHUKkl;s(c^x?DGv_Z zaAt_(+mT5PZ=EtSQX<|BVPS^;MaY_dQpo*mm;L*s|BREEfb3?R>)~h;77qlj8G6GF z;?8iaZYU99eOQn3E8ITnvj4z|*I=E;fe+3XV6wvrC<5yYy_wvAf#F&LFC}ToBc`zH z{==sH0<9H?88}yh=>R862;?*A*6@hEYgW|8cW^h1LXPI`lrMuF$>_e5meIeGh*Wd8NRK zrC&&WZ_lpBCvo0&XarEspbJ1PgN8hBLp!&qJI6h~docfsl@fguHh5d#uK6c!b}giK z{2;GGb%GX~v0VFFwGLs6Z=Ar57IBORj%xQE(IUVR@saXMffEJ%Kn4VK-GAitT3E+2 z3t%4;Y7V4QD9g~z#Bk{+1xG(J4`ZdkO#nPBR!U-neMr~qD|kw6!XjZzgk2YAMDdnF zHg$~-A)G~9wFn%A9*z)DL}nszGk#tROoZ}Efqm04yzB3S@5EYInd0UPn5x6e zI}xUelS_G}h!Ezkl5=V;G&uz6hRomy#YIGG=V|qQTC5ZtkYZBY6T05Nh9=fR^M&FI z{T-?zqGvlvUwkdhB;a983Ra32LYuovcCoe4vZ2&LFZGXo)j4AFexxygNI?XPFy6+)f4i?;RjaAm?`JG?4BHo z$gvndtD(g`orc5=@HbPGkK@@JtUBr|0L6e@yy~#FtHVvFs+@7Zw(;$7G zf%~cJsi#9I+KP}G0i%b-%a+cCq{tzSa%cw}G9m)zn>oa31mLwH7*E3?u5e@;E02hcpC9^ z3N8)YDo~FPaPhlj5#Gl6-g0bL9I<*5y!hB5-j55K5P!!JkH-;<$jRq>i|_;7?kmT3 z!LeU(#N`!}DJj;XNWM%3;fs&e{ahM!&lN@oB95Iy~agrQynbO#} zB>dYVyuD9FIbubnvlhf!nkB?uO8y%@2UL{*A;#E0fLOEhXr87jtno4KTus!m)nO9BWoRP~uran)x$6RXeor&gZ{O|L!^ki7pSR*v{IDC>7mN^+0imx=bI F{15Pbq-6jA literal 0 HcmV?d00001 diff --git a/src/tribler/ui/public/locales/en_US.json b/src/tribler/ui/public/locales/en_US.json new file mode 100644 index 0000000000..ccb389ae9e --- /dev/null +++ b/src/tribler/ui/public/locales/en_US.json @@ -0,0 +1,127 @@ +{ + "SearchPlaceholder": "Search for your favorite content", + "Infohash": "Infohash", + "Downloads": "Downloads", + "AddTorrent": "Add torrent", + "All": "All", + "Downloading": "Downloading", + "Completed": "Completed", + "Active": "Active", + "Inactive": "Inactive", + "Popular": "Popular", + "Settings": "Settings", + "General": "General", + "Connection": "Connection", + "Bandwidth": "Bandwidth", + "Seeding": "Seeding", + "Anonymity": "Anonymity", + "Debug": "Debug", + "ImportTorrentFile": "Import torrent from file(s)", + "ImportTorrentURL": "Import torrent from magnet/URL", + "CreateTorrent": "Create torrent from file(s)", + "DefaultDownloadSettings": "Default download settings", + "SaveFilesTo": "Save files to:", + "AlwaysAsk": "Always ask download settings", + "DownloadAnon": "Download anonymously using proxies", + "SeedAnon": "Encrypted anonymous seeding using proxies", + "TorrentWatchFolder": "Torrent watch folder", + "WatchFolder": "Watch Folder", + "FamilyFilter": "Family filter", + "EnableFamilyFilter": "Family filter enabled", + "HideTags": "Hide tags from content items", + "Save": "Save", + "Tags": "Tags", + "ProxySettings": "Torrent proxy settings", + "Type": "Type", + "Server": "Server", + "Port": "Port", + "Username": "Username", + "BittorrentFeatures": "BitTorrent features", + "EnableUTP": "Enabled bandwidth management (uTP)", + "MaxConnections": "Max connections per download", + "ZeroIsUnlimited": "0 = unlimited", + "Password": "Password", + "UploadRate": "Upload rate limit", + "DownloadRate": "Download rate limit", + "RateLimitNote": "Note that these settings apply to anonymous and plain downloads.", + "RateUnit": "KB/s (0 = unlimited)", + "SeedRatio": "Seed until up/down ratio is bigger than", + "SeedForever": "Unlimited seeding", + "SeedTime": "Seeding for (minutes)", + "NoSeeding": "No seeding", + "SeedingNote": "Note that these settings also apply to existing downloads.", + "MinHops": "High speed\nMinimum anonymity", + "MaxHops": "Low speed\nHigh anonymity", + "EnableDevMode": "Enable developer mode", + "EnableStats": "Enable network statistics (requires restart)", + "LogDir": "Log directory", + "Name": "Name", + "Size": "Size", + "Created": "Created", + "Status": "Status", + "Seeds": "Seeds", + "Peers": "Peers", + "SpeedDown": "Speed down", + "SpeedUp": "Speed up", + "Ratio": "Ratio", + "Anonymous": "Anonymous?", + "Hops": "Hops", + "ETA": "ETA", + "AddedOn": "Added on", + "ForceRecheck": "Force recheck", + "ExportTorrent": "Export torrent file", + "MoveStorage": "Move file storage", + "ChangeAnonymity": "Change Anonymity", + "ZeroHops": "No anonymity", + "OneHop": "One hop", + "TwoHops": "Two hops", + "ThreeHops": "Three hops", + "Details": "Details", + "Progress": "Progress", + "Filesize": "Filesize", + "Health": "Health", + "Infohash": "Infohash", + "Destination": "Destination", + "Ratio": "Ratio", + "Availability": "Availability", + "Files": "Files", + "Trackers": "Trackers", + "PeerIpPort": "Peer (IP/port)", + "Completed": "Completed", + "Flags": "Flags", + "Client": "Client", + "SeedersLeechers": "{{seeders, number}} seeders, {{leechers, number}} leechers", + "NoResults": "No results.", + "GotoFirst": "Go to first page", + "GotoPrev": "Go to previous page", + "GotoNext": "Go to next page", + "GotoLast": "Go to last page", + "DownloadTorrent": "Download torrent", + "DownloadExists": "Note: this torrent already exists in your Downloads", + "Download": "Download", + "Cancel": "Cancel", + "LoadingTorrent": "Loading torrent files {{method, string}}...", + "anonymously": "anonymously", + "directly": "directly", + "MagnetDialogInputLabel": "Please enter the URL/magnet link in the field below:", + "MagnetDialogHeader": "Add torrent from URL/magnet link", + "MagnetDialogError": "Could not process URL/magnet link", + "Add": "Add", + "FilterByName": "Filter by name", + "WithSelected": "With selected:", + "RemoveDownload": "Remove download", + "RemoveDownloadData": "Remove download + data", + "RemoveDownloadConfirm": "Are you sure you want to remove the selected {{downloads, number}} download(s)?", + "ChangeStorage": "Change storage location", + "ChangeStorageDescription": "Change your storage location below. All files will be moved to the new location.", + "ChangeStorageLocation": "Location", + "ChangeStorageButton": "Move storage", + "Actions": "Actions", + "CreateTorrentButton": "Create torrent", + "None": "None", + "Socks4": "Socks4", + "Socks5": "Socks5", + "Socks5Auth": "Socks5 with authentication", + "HTTP": "HTTP", + "HTTPAuth": "HTTP with authentication" +} diff --git a/src/tribler/ui/public/locales/es_ES.json b/src/tribler/ui/public/locales/es_ES.json new file mode 100644 index 0000000000..05c49a3621 --- /dev/null +++ b/src/tribler/ui/public/locales/es_ES.json @@ -0,0 +1,128 @@ +{ + "SearchPlaceholder": "Busque sus contenidos favoritos", + "Infohash": "Información del hash", + "Downloads": "Descargas", + "AddTorrent": "Añadir torrent", + "All": "TODO", + "Downloading": "DESCARGANDO", + "Completed": "COMPLETADO", + "Active": "ACTIVO", + "Inactive": "INACTIVO", + "Popular": "Populares", + "Settings": "Configuración", + "General": "GENERAL", + "Connection": "CONEXIÓN", + "Bandwidth": "ANCHO DE BANDA", + "Seeding": "SEMBRADO", + "Anonymity": "ANONIMATO", + "Debug": "Depurar", + "ImportTorrentFile": "Importar torrent desde archivo", + "ImportTorrentURL": "Importar un torrent desde un magnet/URL", + "CreateTorrent": "Crear torrent a partir de archivo(s)", + "DefaultDownloadSettings": "Configuración de descargas por defecto", + "SaveFilesTo": "Guardar archivos en:", + "AlwaysAsk": "¿Preguntar siempre donde guardar las descargas?", + "DownloadAnon": "Descarga anónima mediante proxies", + "SeedAnon": "Siembra anónima cifrada mediante proxies", + "TorrentWatchFolder": "Carpeta de seguimiento de torrents", + "WatchFolder": "Carpeta de seguimiento", + "FamilyFilter": "Filtro parental", + "EnableFamilyFilter": "¿Quiere activar el flitro parental?", + "HideTags": "Ocultar etiquetas de los contenidos", + "Save": "Guardar", + "Tags": "Etiquetas", + "ProxySettings": "Configuración del proxy torrent", + "Type": "Tipo", + "Server": "Servidor", + "Port": "Puerto", + "Username": "Nombre de usuario", + "BittorrentFeatures": "Características de BitTorrent", + "EnableUTP": "Ancho de banda activado gestión (uTP)", + "MaxConnections": "Conexiones máximas por descarga", + "ZeroIsUnlimited": "0 = ilimitadas", + "Password": "Contraseña", + "UploadRate": "Límite de velocidad de subida", + "DownloadRate": "Límite de velocidad de descarga", + "RateLimitNote": "Tenga en cuenta que esta configuración se aplicará a las descargas anónimas y simples.", + "RateUnit": "KB/s (0 = ilimitada)", + "SeedRatio": "Sembrar hasta que la relación de subida/bajada sea mayor que", + "SeedForever": "Siembra sin límite", + "SeedTime": "Siembra durante (minutos)", + "NoSeeding": "Sin siembra", + "SeedingNote": "Tenga en cuenta que estos ajustes también se aplican a las descargas existentes.", + "MinHops": "Alta velocidad\nMínimo anonimato", + "MaxHops": "Baja velocidad\nAlto anonimato", + "EnableDevMode": "Activar el modo desarrollador", + "EnableStats": "Activar las estadísticas de red (requiere reinicio)", + "LogDir": "Carpeta de registros", + "Name": "Nombre", + "Size": "Tamaño", + "Created": "Creado", + "Status": "Estado", + "Status": "ESTADO", + "Seeds": "SEMILLAS", + "Peers": "PARES", + "SpeedDown": "VELOCIDAD (BAJADA)", + "SpeedUp": "VELOCIDAD (SUBIDA)", + "Ratio": "RATIO", + "Anonymous": "¿ANÓNIMOS?", + "Hops": "SALTOS", + "ETA": "TIEMPO", + "AddedOn": "AÑADIDO EL", + "ForceRecheck": "Forzar nueva verificación", + "ExportTorrent": "Exportar archivo torrent", + "MoveStorage": "Establecer destino", + "ChangeAnonymity": "Cambiar la anonimidad", + "ZeroHops": "Sin anonimato", + "OneHop": "Un salto", + "TwoHops": "Dos saltos", + "ThreeHops": "Tres saltos", + "Details": "Detalles", + "Progress": "Progreso", + "Filesize": "Tamaño", + "Health": "Enlaces", + "Infohash": "Información del hash", + "Destination": "Destino", + "Ratio": "Ratio", + "Availability": "Disponibilidad", + "Files": "Archivos", + "Trackers": "Rastreadores", + "PeerIpPort": "PARES (IP/PUERTO)", + "Completed": "COMPLETADO", + "Flags": "BANDERAS", + "Client": "CLIENTE", + "SeedersLeechers": "{{seeders, number}} sembradores, {{leechers, number}} recolectores", + "NoResults": "No hay resultados.", + "GotoFirst": "Ir a la primera página", + "GotoPrev": "Regresar a la pagina anterior", + "GotoNext": "Ir a la página siguiente", + "GotoLast": "Ir a la última página", + "DownloadTorrent": "Descargar torrent", + "DownloadExists": "Nota: este torrent ya existe en Descargas", + "Download": "Descarga", + "Cancel": "Cancelar", + "LoadingTorrent": "Cargando archivos torrent {{method, string}}...", + "anonymously": "anónimamente", + "directly": "directamente", + "MagnetDialogInputLabel": "Introduzca el enlace URL/magnet en el siguiente recuadro:", + "MagnetDialogHeader": "Añadir torrent desde URL/enlace magnet", + "MagnetDialogError": "No se pudo procesar la URL/enlace magnético", + "Add": "AÑADIR", + "FilterByName": "Filtrar por nombre", + "WithSelected": "Con seleccionado:", + "RemoveDownload": "Eliminar descarga", + "RemoveDownloadData": "eliminar descarga + datos", + "RemoveDownloadConfirm": "¿Está seguro de que desea eliminar las {{descargas, número}} descargas seleccionadas?", + "ChangeStorage": "Cambiar ubicación de almacenamiento", + "ChangeStorageDescription": "Cambie su ubicación de almacenamiento a continuación. Todos los archivos se moverán a la nueva ubicación.", + "ChangeStorageLocation": "Ubicación", + "ChangeStorageButton": "Mover almacenamiento", + "Actions": "Comportamiento", + "CreateTorrentButton": "CREAR TORRENT", + "None": "Ninguno", + "Socks4": "Socks4", + "Socks5": "Socks5", + "Socks5Auth": "Socks5 con autenticación", + "HTTP": "HTTP", + "HTTPAuth": "HTTP con autenticación" +} diff --git a/src/tribler/ui/public/locales/pt_BR.json b/src/tribler/ui/public/locales/pt_BR.json new file mode 100644 index 0000000000..115e74168f --- /dev/null +++ b/src/tribler/ui/public/locales/pt_BR.json @@ -0,0 +1,120 @@ +{ + "SearchPlaceholder": "Procure por seu conteúdo favorito", + "Infohash": "Infohash", + "Downloads": "Downloads", + "AddTorrent": "Adicionar torrent", + "All": "TUDO", + "Downloading": "BAIXANDO", + "Completed": "COMPLETO", + "Active": "ATIVO", + "Inactive": "INATIVO", + "Popular": "Popular", + "Settings": "Configurações", + "General": "GERAL", + "Connection": "CONNEXÃO", + "Bandwidth": "BANDA", + "Seeding": "SEEDING", + "Anonymity": "ANONIMIDADE", + "Debug": "DEPURAR", + "ImportTorrentFile": "Importar torrent do um arquivo", + "ImportTorrentURL": "Importar torrent de magnet/URL", + "CreateTorrent": "Criar torrent de um arquivo(s)", + "DefaultDownloadSettings": "Configuração de download padrão", + "SaveFilesTo": "Salvar arquivos em:", + "AlwaysAsk": "Sempre peça configurações de download", + "DownloadAnon": "Baixe anonimamente usando proxies", + "SeedAnon": "Semear anonimamente usando proxies", + "TorrentWatchFolder": "Observar pasta torrent", + "WatchFolder": "Monitoramento de pasta", + "FamilyFilter": "Filtro Familiar", + "EnableFamilyFilter": "Filtro de família habilitado", + "Save": "Salvar", + "ProxySettings": "Configurações de proxy torrent", + "Type": "Tipo", + "Server": "Servidor", + "Port": "Porta", + "Username": "Usuário", + "BittorrentFeatures": "Recursos BitTorrent", + "MaxConnections": "Máximo de conexões por download", + "ZeroIsUnlimited": "0 = sem limites", + "Password": "Senha", + "UploadRate": "Limite de upload", + "DownloadRate": "Limite de upload", + "RateLimitNote": "Essa configuração se aplica à downloads anônimos e não anônimos.", + "RateUnit": "KB/s (0 = sem limites)", + "SeedRatio": "Habilitar Seeding até a proporção de upload/download for maior que", + "SeedForever": "Habilitar Seeding ilimitado", + "SeedTime": "Habilitar Seeding por (minutos)", + "NoSeeding": "Desabilitar Seeding", + "SeedingNote": "Essas configurações também se aplicam à downloads já existentes", + "MinHops": "Alta Velocidade\nBaixa anonimidade", + "MaxHops": "Baixa Velocidade\nAlta anonimidade", + "EnableDevMode": "Ativar o modo de desenvolvedor", + "EnableStats": "Ativar estatísticas de rede (requer reinicialização)", + "LogDir": "Diretório de registro", + "Name": "Nome", + "Size": "Tamanho", + "Created": "Criado", + "Status": "Status", + "Status": "STATUS", + "Seeds": "SEEDS", + "Peers": "PEERS", + "SpeedDown": "VELOCIDADE (BAIXANDO)", + "SpeedUp": "VELOCIDADE (SUBINDO)", + "Ratio": "PROPORÇÃO", + "Anonymous": "ANÔNIMO?", + "Hops": "PULOS", + "ETA": "TEMPO ESTIMADO", + "AddedOn": "ADICIONADO EM", + "ForceRecheck": "Forçar nova verificação", + "ExportTorrent": "Exportar arquivo torrent", + "MoveStorage": "Mover armazenamento de arquivo", + "ChangeAnonymity": "Mudar anonimidade", + "ZeroHops": "Sem anonimidade", + "OneHop": "Um pulo", + "TwoHops": "Dois pulos", + "ThreeHops": "Três pulos", + "Details": "Detalhes", + "Progress": "Progresso", + "Filesize": "Tamanho", + "Health": "Saúde", + "Infohash": "Infohash", + "Destination": "Destino", + "Ratio": "Razão", + "Availability": "Disponibilidade", + "Files": "Arquivos", + "Trackers": "Trackers", + "PeerIpPort": "PEER (IP/PORT)", + "Completed": "COMPLETO", + "Flags": "FLAGS", + "Client": "CLIENTE", + "NoResults": "Nenhum resultado.", + "GotoFirst": "Ir para a primeira página", + "GotoPrev": "Ir para a página anterior", + "GotoNext": "Ir para a próxima página", + "GotoLast": "Ir para a última página", + "DownloadTorrent": "Baixar torrent", + "Download": "Download", + "Cancel": "Cancelar", + "MagnetDialogInputLabel": "Por favor adicione o link URL/magnet no campo abaixo:", + "MagnetDialogHeader": "Adicionar torrent de um link URL/magnet", + "MagnetDialogError": "Não foi possível processar URL/link magnético", + "Add": "ADICIONAR", + "FilterByName": "Filtrar por nome", + "WithSelected": "Com selecionado:", + "RemoveDownload": "Remover download", + "RemoveDownloadData": "remover download + dados", + "RemoveDownloadConfirm": "Tem certeza de que deseja remover os {{downloads, número}} downloads selecionados?", + "ChangeStorage": "Alterar local de armazenamento", + "ChangeStorageDescription": "Altere seu local de armazenamento abaixo. Todos os arquivos serão movidos para o novo local.", + "ChangeStorageLocation": "Localização", + "ChangeStorageButton": "Mover armazenamento", + "Actions": "Ações", + "CreateTorrentButton": "CRIAR TORRENT", + "None": "Nenhum", + "Socks4": "Socks4", + "Socks5": "Socks5", + "Socks5Auth": "Socks5 com autenticação", + "HTTP": "HTTP", + "HTTPAuth": "HTTP com autenticação" +} diff --git a/src/tribler/ui/public/locales/ru_RU.json b/src/tribler/ui/public/locales/ru_RU.json new file mode 100644 index 0000000000..698bfe8b06 --- /dev/null +++ b/src/tribler/ui/public/locales/ru_RU.json @@ -0,0 +1,128 @@ +{ + "SearchPlaceholder": "Искать торрент по названию", + "Infohash": "Инфохэш", + "Downloads": "Загрузки", + "AddTorrent": "Скачать", + "All": "ВСЁ", + "Downloading": "ЗАГРУЖАЕМЫЕ", + "Completed": "ЗАВЕРШЕННЫЕ", + "Active": "АКТИВНЫЕ", + "Inactive": "НЕАКТИВНЫЕ", + "Popular": "Популярное", + "Settings": "Настройки", + "General": "ОБЩЕЕ", + "Connection": "СЕТЬ", + "Bandwidth": "ТРАФИК", + "Seeding": "РАЗДАЧА", + "Anonymity": "АНОНИМНОСТЬ", + "Debug": "Отладка", + "ImportTorrentFile": "Загрузить торрент из файла", + "ImportTorrentURL": "Загрузить торрент по URL/magnet-ссылке", + "CreateTorrent": "Создать торрент", + "DefaultDownloadSettings": "Настройки закачки по умолчанию", + "SaveFilesTo": "Сохранять файлы в:", + "AlwaysAsk": "Всегда спрашивать настройки закачки", + "DownloadAnon": "Загружать торренты анонимно (через других участников)", + "SeedAnon": "Раздавать торренты анонимно (через других участников)", + "TorrentWatchFolder": "Папка автоскачивания:", + "WatchFolder": "Автоскачивание торрентов из папки", + "FamilyFilter": "Семейный фильтр", + "EnableFamilyFilter": "Включить семейный фильтр", + "HideTags": "Скрыть тэги контента", + "Save": "Сохранить", + "Tags": "Тэги", + "ProxySettings": "Подключение через внешний прокси", + "Type": "Тип", + "Server": "Сервер", + "Port": "Порт", + "Username": "Имя пользователя", + "BittorrentFeatures": "Настройки BitTorrent", + "EnableUTP": "Использовать uTP", + "MaxConnections": "Макс. число соединений", + "ZeroIsUnlimited": "0 = без ограничения", + "Password": "Пароль", + "UploadRate": "Исходящий трафик", + "DownloadRate": "Входящий трафик", + "RateLimitNote": "Внимание:", + "RateUnit": "КБайт/сек (0 = без ограничения)", + "SeedRatio": "Раздавать до тех пор, пока соотношение раздача/загрузка не превысит", + "SeedForever": "Неограниченная раздача", + "SeedTime": "Раздавать в течение (минут)", + "NoSeeding": "Запретить раздачу", + "SeedingNote": "Обратите внимание, что эти настройки применяются", + "MinHops": "Высокая скорость\nМинимальная анонимность", + "MaxHops": "Низкая скорость\nМаксимальная анонимность", + "EnableDevMode": "Включить "режим разработчика"", + "EnableStats": "Включить сбор сетевой статистики", + "LogDir": "Папка журнала", + "Name": "Название", + "Size": "Размер", + "Created": "Создан", + "Status": "Состояние", + "Status": "СОСТОЯНИЕ", + "Seeds": "РАЗДАЮЩИЕ", + "Peers": "УЗЛЫ", + "SpeedDown": "ЗАГРУЗКА", + "SpeedUp": "ОТДАЧА", + "Ratio": "СООТН.", + "Anonymous": "АНОНИМНО?", + "Hops": "ПРОКСИ", + "ETA": "ОСТ. ВРЕМЯ", + "AddedOn": "ДОБАВЛЕН", + "ForceRecheck": "Перепроверить доступность", + "ExportTorrent": "Экспортировать торрент-файл", + "MoveStorage": "Переместить файлы", + "ChangeAnonymity": "Изменить уровень анонимности", + "ZeroHops": "Без анонимности", + "OneHop": "Один прокси", + "TwoHops": "Два прокси", + "ThreeHops": "Три прокси", + "Details": "Подробности", + "Progress": "Прогресс", + "Filesize": "Размер", + "Health": "Состояние", + "Infohash": "Инфохэш", + "Destination": "Папка назначения", + "Ratio": "Соотн. раздачи", + "Availability": "Доступность", + "Files": "Файлы", + "Trackers": "Трекеры", + "PeerIpPort": "УЗЕЛ (IP/ПОРТ)", + "Completed": "ЗАВЕРШЕННЫЕ", + "Flags": "ФЛАГИ", + "Client": "КЛИЕНТ", + "SeedersLeechers": "{{seeders, number}} сидов, {{leechers, number}} личей", + "NoResults": "Без результатов.", + "GotoFirst": "Перейти на первую страницу", + "GotoPrev": "Перейти на предыдущую страницу", + "GotoNext": "Перейти на следующую страницу", + "GotoLast": "Перейти на последнюю страницу", + "DownloadTorrent": "Скачать торрент", + "DownloadExists": "Внимание: этот торрент уже присутствует в списке загрузок", + "Download": "Входящий", + "Cancel": "Отмена", + "LoadingTorrent": "Загружаем список файлов торрента {{method, string}}...", + "anonymously": "анонимно", + "directly": "напрямую", + "MagnetDialogInputLabel": "Пожалуйста, введите URL/magnet-ссылку в поле ниже:", + "MagnetDialogHeader": "Добавить торренты по URL/magnet-ссылке", + "MagnetDialogError": "Не удалось обработать URL/магнитную ссылку", + "Add": "ДОБАВИТЬ", + "FilterByName": "Фильтровать по имени", + "WithSelected": "С выбранным:", + "RemoveDownload": "Удалить загрузку", + "RemoveDownloadData": "удалить торрент вместе с файлами", + "RemoveDownloadConfirm": "Вы уверены, что хотите удалить выбранные загрузки ({{downloads, number}})?", + "ChangeStorage": "Изменить место хранения", + "ChangeStorageDescription": "Измените место хранения ниже. Все файлы будут перемещены в новое место.", + "ChangeStorageLocation": "Расположение", + "ChangeStorageButton": "Переместить хранилище", + "Actions": "Действия", + "CreateTorrentButton": "Создать торрент", + "None": "Нет прокси", + "Socks4": "Socks4", + "Socks5": "Socks5", + "Socks5Auth": "Socks5 с аутентификацией", + "HTTP": "HTTP", + "HTTPAuth": "HTTP с аутентификацией" +} diff --git a/src/tribler/ui/public/locales/zh_CN.json b/src/tribler/ui/public/locales/zh_CN.json new file mode 100644 index 0000000000..e832165dd1 --- /dev/null +++ b/src/tribler/ui/public/locales/zh_CN.json @@ -0,0 +1,126 @@ +{ + "SearchPlaceholder": "搜索你喜欢的内容", + "Infohash": "散列值", + "Downloads": "下载", + "AddTorrent": "添加种子", + "All": "全部", + "Downloading": "下载中", + "Completed": "已完成", + "Active": "活动", + "Inactive": "无活动", + "Popular": "流行", + "Settings": "设置", + "General": "常规", + "Connection": "连接", + "Bandwidth": "带宽", + "Seeding": "做种", + "Anonymity": "匿名", + "Debug": "调试", + "ImportTorrentFile": "从文件导入种子", + "ImportTorrentURL": "从磁力/URL 导入种子", + "CreateTorrent": "从文件创建种子", + "DefaultDownloadSettings": "默认下载设置", + "SaveFilesTo": "保存文件到:", + "AlwaysAsk": "总是询问下载设置", + "DownloadAnon": "使用代理匿名下载", + "SeedAnon": "使用代理加密地匿名做种", + "TorrentWatchFolder": "种子监视文件夹", + "WatchFolder": "监视文件夹", + "FamilyFilter": "家庭过滤", + "EnableFamilyFilter": "启用家庭过滤器", + "Save": "保存", + "ProxySettings": "种子代理设置", + "Type": "类型", + "Server": "服务器", + "Port": "端口", + "Username": "用户名", + "BittorrentFeatures": "BitTorrent 功能", + "EnableUTP": "启用带宽管理(uTP)", + "MaxConnections": "每个下载任务的最大连接数", + "ZeroIsUnlimited": "0 = 无限制", + "Password": "密码", + "UploadRate": "上传速率限制", + "DownloadRate": "下载速率限制", + "RateLimitNote": "注意这些设置会应用于普通下载和匿名下载。", + "RateUnit": "KB/s(0 = 无限制)", + "SeedRatio": "做种直到分享率到达", + "SeedForever": "无做种限制", + "SeedTime": "做种时长(分)", + "NoSeeding": "不做种", + "SeedingNote": "注意这些设置也会应用到现有的下载任务。", + "MinHops": "高速\n低匿名性", + "MaxHops": "低速\n高匿名性", + "EnableDevMode": "启用开发者模式", + "EnableStats": "启用网络统计(需要重启)", + "LogDir": "日志目录", + "Name": "名称", + "Size": "大小", + "Created": "创建时间", + "Status": "状态", + "Status": "状态", + "Seeds": "种子数", + "Peers": "对等机数", + "SpeedDown": "下载速度", + "SpeedUp": "上传速度", + "Ratio": "分享率", + "Anonymous": "匿名?", + "Hops": "跃点数", + "ETA": "剩余时间", + "AddedOn": "添加时间", + "ForceRecheck": "强制重新检查", + "ExportTorrent": "导出种子文件", + "MoveStorage": "移动文件存储", + "ChangeAnonymity": "更改匿名性", + "ZeroHops": "无匿名", + "OneHop": "一跳", + "TwoHops": "二跳", + "ThreeHops": "三跳", + "Details": "详情", + "Progress": "进度", + "Filesize": "文件大小", + "Health": "健康度", + "Infohash": "散列值", + "Destination": "下载位置", + "Ratio": "分享率", + "Availability": "可用性", + "Files": "文件", + "Trackers": "追踪器", + "PeerIpPort": "对等机(IP/端口)", + "Completed": "已完成", + "Flags": "旗帜", + "Client": "客户端", + "SeedersLeechers": "{{seeders, number}} 个做种者,{{leechers, number}} 个下载者", + "NoResults": "没有结果", + "GotoFirst": "转到第一页", + "GotoPrev": "转到上一页", + "GotoNext": "转到下一页", + "GotoLast": "转到最后一页", + "DownloadTorrent": "下载种子", + "DownloadExists": "注意:此种子应在下载列表中存在", + "Download": "下载", + "Cancel": "取消", + "LoadingTorrent": "正在载入种子文件 {{method, string}}……", + "anonymously": "匿名", + "directly": "直接", + "MagnetDialogInputLabel": "请在下面字段输入 URL/磁力链接:", + "MagnetDialogHeader": "从 URL/磁力链接添加种子文件", + "MagnetDialogError": "无法处理 URL/磁力链接", + "Add": "添加", + "FilterByName": "按名称过滤", + "WithSelected": "已选择:", + "RemoveDownload": "移除下载", + "RemoveDownloadData": "移除下载和数据", + "RemoveDownloadConfirm": "您确定要删除所选的 {{downloads, number}} 个下载吗?", + "ChangeStorage": "更改存储位置", + "ChangeStorageDescription": "更改下面的存储位置。 所有文件都将移动到新位置。", + "ChangeStorageLocation": "地点", + "ChangeStorageButton": "移动", + "Actions": "行动", + "CreateTorrentButton": "创建种子", + "None": "无", + "Socks4": "Socks4", + "Socks5": "Socks5", + "Socks5Auth": "带身份验证的 Socks5", + "HTTP": "HTTP", + "HTTPAuth": "带身份验证的 HTTP和" +} diff --git a/src/tribler/ui/public/tribler.png b/src/tribler/ui/public/tribler.png new file mode 100644 index 0000000000000000000000000000000000000000..5f3c257022ceb941fe1f3584a6fa5e8d59b0b683 GIT binary patch literal 14157 zcmeIZWmKHY(kMIw4DK3Sf(0Ag-61#xg1fsr4DRm1C4mrJf|J1^5S-u=+}(m+vd=#6 zcfPgG{ds@fyLPWtQ#JimcRk%*U8}mD>L?W@88l=fWB>qwCMPSY_JUde03!U$uNKVj zh!=!tY^tH~f{|VT5UO8f{DOe4YBJ)0s!@`?7h2m|Wa|l0w<`)iT zS8oS5V=rb0*Vq5*DSoA(cMjmit3M`|0(}^Pd96e|4)*G z>)*qA86f!26EGVKEBJrlp|1)jE$O_$b+AlLnWbv<6_%o}2Ezuw1uVMzf3j_#}Ze6@A(K|UwF%2tF zrH*Z^{yRK_ozA-t)^DiP={UL2K1jzB6`R}|tI$W;Ph#V(Ut}nc)2qTr|2F<@99xv8 zA?MdCNdu{tMN~`GA@4hnWTsbwAfXv?alU(#t7Lh+w7f7#}O5&@GEP8BQyCz@)J*0BC`}F*X;X9>MFetvx;w%5vTR*#I;CMz?<_A zcDhgSjZ%U^xt;MiXc|aQZuxT>$v1h70uGDRhBx+Hkg^%@0x^S5f~Oksyh1 zDOzp#)5z5BZ6+;QXP8UP9&ALRouLAO#p(`ivx9g2F%IL*j@aG<9!ZYN^e&gO36fS3 zuBZu&PT^=`w(;74BnBkJ0cse#_XRrAx%Fy}S$_8sQdP~ZC^|d|1pcW$r76)JU;4_QC0$V{`+7 zq-F)Xq~TruppXa_pVKpB*0&y-RhGOC$15Tn3bPhJ@}ZH6m|K%j#%yapZ-p;2DnSoH zEk=#RPt>v}6kTG_%dqG3Y;gl8ZRq%6H|+>l=qnwsX2gt?ngi-ZVI#oeI9p-poJ_|) zYAhk?56ZbjxM6+N%T?3iAji8Lk+}LIO3)!@yDqGSz6MGmFYqh>cs!yj-Nw`7+R7FV zuMl0I+M-<{wQ&@ER((Cn47K$dt3$&nt0OJ4F!jZKo7B^jQ>f%T7ku;0GP6{veTSn5 z^=LUZA7e~rO|!nsi3f`3sYbZ`Sx9x1-UCb0+bSqGpS}36!4x&YI>YFBI%@An-ID9h z1Y=@-$H?`y?>CP+b@_p-yLXQwya=Pnz~C)SJ}*#>8S;v**F!0<_p@LAAo+=uLI~51 z$9F(*o1F7u*(SoZeD{UXkI>C}4mClZ#cyHQH3l@ONaRZh zOjK~KVV(`+&Ibr5yRp~IzbQt3qXdC(-hf1gzvYd*;uT~2dSJK|*Mm$~Nj^(ZQSr?n z=Qg3Cx(BbswUx)W!F)jF9Gnk;Ox^Fl!C`O!q!5F@GaAwiU4AP3 zg zIt(ZHs~M4sss}0L0lFbk&lG-svS)C8bSeE?TIj`f$)hI$A8kPniHBz)udj$Qj#BTY z8>0LnnumB@Q${&}QcGVL-QEW5)Y zK+~ngzt7gPG`;@K8RnrU@~U0CeNLQi_Ir8uwNYwvRr7Gew>v%@3w&N@ERC~6^~tSP zX!=6OMcd?xbCO$NyDqlxg+`HBKCjyp|29JxA(W_Q7Jem6bNLiajyu(mbaL@V_GwP+ zL~2Jdc`dcX@ON_wkLl5xFQ)mORWY!nufU;X>qRo4$`-;WNAc;v(M$Jp9jFK5@)^iLn4Dn66n0j#gAT=Bj4S&c4Qr(pHspcxvbd=M!*-bCUiLB@@xH2jFNj_b}0xo3DHQ z(Ik1JM?MT!ANCd+qgNl4VF@+ox1CDByWM$L~#f@?o7v0`a6q2z3kUz>*MKDrty-GA|pe!#DdtK)eyrKsuysa{Sn;F04Jtu zM@>a^LBU$1Fb~k<*l!(QB&a3ySma2U zRmPbr(EVcMQ)fAHoVTEzCuxkLLWnRr?KKG^V*+}kAY~GC^(XvDhryG$L5UL$s!nZU zPlvQHLeJ{0pLTIKcRU9l7oD!$o8#9JJ}G?uJaYauRaB0Bv5h?3*7_mX_X4|Wc~r@d z1QYj7tqjGmCt!y{715g82weka&KZLv(F9kanF6~T0XLi z9WJNqQ@ciRj-(60aOv6RU0G zDp#HE-uP}0Ph4F)uj0IB5jd|^1~AAXePG3jcr3gwaJ?uuT->L2X@6{L77n_A^AkFR zD&=1kcpYJ_&-h|yPz3HG28X=61U~hjQq%^?eF^Ix0d3wWI_<(utfOT*<|heiZ)?=? z7z%si3E7o1En3~^x5acHO65mq;8TKMOq-iGQFthyQffMzT}U;wi|tT(M+dDdN?URk z>3bgEhsKr(~s+r?r zVmtW2pv+sMoc;cBp-7We#MS1?MYOpUf#ozFPUdmspMl$CW0|3CC z{jF+n?~yvRY$M;j!+pB$a6RGFVNVj;6)QHjNWu@3LNYdzf}>RX7L6E>)$n<#5kVQWx4wk%+V$_CP_05v2hi#?*b*Jyxp+D*UM6m0% zZ>3|P?UDPnDu7ccs3I3*T4VlWnc8Q7Ub%K^TGjb$7UJClyNQozEBmz6!8lzjjkh zmWi#0B+X$P7CyQ-F33Zv3TV|t*uUh3muH<_g{fEsX?j z10V*w0F!DG=m9el{k9#_*2>oH`LV(sSi9Ma6OCSkr_%?}Qr#dvOEG0d@soRet6b`0 zxB||A1ys>j6y^T#RV{p+4#s-z0Iyp@>!^@G60x)MlyU919vCp)4zfsm3{>esL_uX2 z5_V2}D5`I(KrC&^4|#-`aFXjZwcPtKtN?C$)Kr#RYrQf&RwVPUy6P0ju!ry#1#dC? z35^JxP!VCbj)|WEC+*0~nROnqV;J#b2TBYRBIMF+byG*3Mp|ypT@{3hrd;i?K8FIk zLBoaFXuBcB<8;|7C>f&5PK=jlrh_8$4A9tjuT|ipt+k{m*IrSCwO`_G_)a_> zcDRsk0Sy*u;EPbL(1q2GlOsW_&oCN}x*H}GnnPgy(8FR+iM0k{8fQRgv(?mDZTLrO z0b&0UV^pA|pL-Ux7;vtOFSct<8j;J4NMZKYa*XKSwx#=9o)6@VwkX9Yr%Egg_`gGm1G7}Q*UHSdlIUKQ znzIiu{+761AeS~g{1N$y1A1|pYYFpZX95F5Y*UWOV?fQbdbHU*GNM^n&hUh}A**^s zYN0&o*ZD_gz_*LQ%4d`#)|@xm=hBlehVb7e67)jYbz$miBZd$E)FoVSU^MZbF2 zv77|H9q-kP_7iVQ{9AH@jhYvo^;`-_bb+3_4t_WETNoBmc2Vc5m{PlBrV;@Jl95i{Yw$rdx4~ns86OBc@q5y?AERf^Sau`d@?se5N1Dml zL~8*-%n;aMe5qH)s9mKKCENTTd%7F7==Dts6{tCj*f#?d$O~T2Ci)GE>e*u(8+Cs#>fx$dBs;^GhnE%CtP=X@cTpG zA@OtPwuXCoitZ&(4ghzIKdrTJj0uN$4V>6Aaq*VikKp%m=zom!s^#bO?5V4y4eJdh zqty38oY-#(p^w=)_qv=CFdn_*7-=B@ycNj}?a8g~QnCy2x1|eKSC-g_KIn?vEV=Iu z*|qiJLqr-_IfDF_M;%Yw>&UeqQdd#0OfXW)Ia$7*RcG-HhDOu!t86Q#pz^qrY;t`M=35^m{d4Gs!#cvnwXhMPs+sPP z>tvWF`I`6l9lirm8Rh&mKLXVZ^j--Cc_euE-fysiSn6xPYcf4m6#MrpG)Xw7lRYEg zyfz{Ahfy$GeP2W+TO+Yq@^*sWn&NBQRL$~?#Ky|b;So)&qvoq6^Xr@tYwMpeBnEkM zQaZYK1)SFN*=?Ph%9yWNyDM%oAY^!Y;T5E4YA=&M|FSwSQtOm-x2-Wl!Y(~(@=?hb zJ7s`o#1!?BIx&1YyHkJrMRjx%hslu5=&A*>GreLry|u&gW2Q`uAIXdC*$zW1PYOvM>U4U&B&MGK8^!5c@fw$~&JE4VxyNC~WPc^9yyO!J zsU$GokC{@7O7FFiwS&mOOaP5yG}L#&f$g&K?7REnrpUzozpy1q?F-v(ALxal|BE?CZZiheI_l_qEV7-tpW;jEp}^y3S}BE~vi=~U3m>jB*S z>yvh6;As_k*QUz{(d7E;*Egv!AH0v{dfP;%g#(;-xEz%Zcg5GvVkOCjw23V9!a=J# zhRES@9a;vGmIoe3V-bDF#o0ks?a&hJlV{8$Viu|Uwu~OP&3A6-zovftS_V$i3{Pai zGTZ$c%on5}FDb`3gKg4d+^bQk1eD!hBkbC>Xi3;uPLXEbo$rkQ-r7JPHcp$NpWHj2 zr^2b4&~=okdFcJ#hyL{7Az9ri1X$s-y8nb`<=Bjtsl;Sp;SMfa+Fz-?4D$Q1csn`Y zGH~x$Jo|NHBThs@7?FQ%UiOTbeq8K3+q*jnu=DWFGo6y?MLd`erastu!RQ2K89#3)XyfW(JVSr^i(X< zVKe$#MWA97cVm1aDIOhbRV=1xeK>|_#;77Lq!Jxln;OK{C}!&q`8Ve)*;&4iqq+I* z8J={Mj2KH<_s{-2ORl3G=uGGHF%jA>3N1|ljiWD?Zmx?u2Z?ihw>>$ZBxOa_W*R>(EqIOm4{+o@S8Cgc-t^- zueKA8o|ZaQ*(l$9UuFYvx#8|#m^$&)23iAcOi-%~B}7$Z_U2%SsUdnMKy0kIlFMbZ zCUimLU&l*mo=$U|ayU4jLG?&(`W^j+V4x>_D;|9dp2n1 zSw>Yf+PW7EwE3n=E*cNvI|EJ_#P4Bbrz;S9_rK3~EZPXkFyqqOWQSQIF8j11!z>fyNfO$Mz7F{!v>&JlBNCTq^u zYr1c(%Ph&HnoS8VT?lRN1RF39mO(e2{^MHPYYBcx`c;7SN0WeH#_cesTI@vLpMB!$ zg4s3Ynn0h{NIpyFI@cXYy=C!5d=Sk8oZegN=AR^H*EQHGoE_ges-zG_J-Nw_>c+h9 zFr%c1qQdhs4WaZOqUdr+ByMMBv1%wKur+b(;eJv^#L~pPshd@xEo7s?d230aLoR|_ zIdXf~(Nw_i61}bE)Et2>r*bYI{$8fnj#46WzBiMPt#R2@>qsHSY8wF546q|~ERK?0 z*W|d|^rOk!Qf4nC8St50=>IJ)o|C9)G~u?(RI39B0uMMqw)_;?lU&n_-^(n;E0viu zh^|>xKEz~wsenrjjgN2O&K^v{6rBB;-lEFB=Fmb$%gFkKV@x)TdrWvPa6kDMz zzk8E|?Lyhz54jY|6yQ+mah(3AH_f(2*?fd>h)OCzbnRI6v!|Q86Gf-Vz`Y~NTam#0 zuOHP8+ZaLV*?!DJ)G8QE=}x<{zY)+m4?k{$d-5M(pKdYG9{Xg_zfVDN31tnO&{EdJ z(?pZSLGR^QEZ=+I-|*-U%p~E6wuCR-CZPEepbG{qjXF%xIeuvD<+n>#Mzwo@H0dt> z>iXoLuIy)`K^ADeUe{sQvF^pz4&%?>Ua?Om zcR6aH8E|~*hRa*{Ql(EvXj)H86Cc4IO$UJjJbUx^j#K5WBgCFkxUQF55y#URRV*du z4$9S-ai@Ynu5iWU{H8wc)q^uuy0lk-Lv*8Nr5aOKJH5eu- z2#%A0Y(ug8NV^erf~%7EDNSggV)p#6X+kEU->-0mz5z3fk!lnaAU}%Sg*9R7Xork! zzJ~UNU=+F7y+Xk_32}8^L1wYtZ%p?{2`h0;d+%@bwF7N-vK|MCWu)M|+en^aZ;XG( z=U`B%W)54#Avw%F@C)cTD^Cv9XoiaI#wN~ub!IIZmG@UdO`nj+0_KJvQ_2F zM+t4q>>0yf!*}_MH*0Cd1GBwyz^8=ofi1LUf?ilmEiVT5-$%zkSq=X!ked$o5?upc z{cVbod9mV>6|HboF$bO<>)Yb2OVo53I*>r1M%je@KD99&%o2)7gZ9WsFD9?kd;+@w zA@u6HvJ=B>=_lRwhg-)VEM*~_XJ0pcKEvIb?2CZhZ-g-i))G))1|dR0GeK(1b~KR| z!0~K62*%YeVU7TXB8Bo2TpCZ8E64b^z@URORY(rN2SUb2Tr_GJjtZ(e6eY-$h`58A zE479Ak(~;voH1u|@}i3Q;_;elVdAYqY}{4EBVc)F89xFRmf?lLA#N~A*t?E~g|j~k z%mXH|$7wYYQms^80b5Aw!e;G_MX&aRLi=vUtyrM9%#bQjfh@}#ee;OKxiW^+STv`F z4OEJ-l6%KUmTF=}H1r_YphcVlSQQ>uegbe7wUWt0c7UDR^S;*|$v9ik8CT%qYuJaI zL+khezh4@`$|qi~W{Gd&Gb5W_j!3Few<^gh1Z2zE>eN$CSz^V`vD%qL?KLp{Gc&XZ zgL%IHFwsxD>cq*Zcv`A{Du?RQ`LFC))H&`MIBqzbXK<%0_8)43@k{9OP5 z_WW-(L4AoZyce?45W~!C(^Kk@$JD5alEbaALjz)o1ycq0(nc0x%9)!MM&iGrDyrmO z4>H0;X}pM_h@|flXJYE5g13W9qoAj3(MuLsy^K}dTxk=4LpWYJJ{&pZX{)an zk$mPg*Sih0f1{z#ZZ&qYz=CC)IAp*4Q$>q$tn;rs$&Ui@Pf=KE6@D4cMhd2nk&Jg4Yxyvw#LgpXxnkJ}F#kDr2Jbi>bdyCeKhBN6T2LHzq8~WKSMckY(egTHnTqt-eezX90 zRreN>7*YJKj{-a!m~ZzfH$~Nl^()83g=;JMqt3(W+taBE-qR_d<{czg(}&uHYZ!62 zm=OyAg&`4;2!|oDaTHq>!*py zTA(eF`&B<>FmUY=pv1OXvYO6mvrqCs4Q=jB-(ckKE2|W|$St#skSPDjgie{%$W=(~ z_1OW{PqF#@K0%h53*kJ$a8|(WWOxEq%C^Ao$VOiVWN3+uY@j`8hEW>_+P~=eg>e7_ zmFR+1oUn*hQmr?pcA-GZ30hMf#uv6Z{4z#YkpRBri8IvH zHZU)RC4rIqQZ*rSt?EwjNu(b`M^2#36kib>$nTxD<0guyIb1a{AjBtUBm3F@Y#Gp0 z13-2GWcA?Rzw(duO2$;P;THEk>$d(N!tntYZ#IMkdNhoC@4T(*pu>P2ifaL6pIgGe zJ}I2^_xDh{vNJ!kPK`6_%7RM317_wdg`QaA4e1^xsZI!;!jwL&Q7icfs0cfY+Rozh z>x89duAmz^(_&Zx61={KDam`yW}SHe@G)s0$QI;Iq1c-M+@w3+6gQL2AmCSTec&hmAmcp(*}`7xYs8^6JmN$j!cd3f%kK zMxg)sP$Oi8D~6lb;q`Lb6IW$3DSd_5r4;$@63(9K5Erhpfvq4dZ0%iiaBu-8@oONS zYAtI)TyPmZHGhB7#}*s}@s)P(i%!SR8xM~$_3mc> zJ2M%s?W43J$NT2eS5681F26rm@@bK*cQ4(!$SjOExr@3G8#)^m*S4tU`7oMPt_Mx# zz}gmw)Eg=`UlSs*kl+VyZ1Wd%LKX$De-ne#q7bOabtxB=m-Y0Z* z=1<>UvhGIsd61VT4g}`{=`~DcftlBljE=N0iR6>w#}56UR?&98O2Pm>EywAoW2-(& z4DCUxANN7-Gz+6-1yFui4J)!G968i{hvx0!QdOMprpsH(Cv!3?AX1LSH1tz{Glomf zhiz=6aexCsd_Fsi>t4WTVsbJm{ouZ=dGqJ+MvGkH7NmmV6nL_pz9HYU#IUg2oDuBo z#9nis5-UGVfp_8WHarx(6q|UKBd^^Qh8E9n`qfLMtuk`4G0?E3Rg}FMCyM79w!$=Y}Z)N$uHIiI+>SNhQn1tx&Ilz5Dkj#-{YHV>I zS+2Aq*XwNeD02L5_2G#!{3nmk%Le$jmxb{{{}(T7OV8u%#mkERduw3wVwswW!n9Rz zJ-v1&2Y)^nAt&7=C>q=?omSa1dq*7b!}1;)>O4 zM?EwsL=_35GE~lk#PqzPhlg;6Ko90qUP%UVZXE0wJp~p71>V>J>$O(`0aL6neSpk@ z%>*#2NFkAtNg(8F2bWQ&l}OCb%}*|xoaS3WGr(+o$_fl$3sDINe@tU_J)1n$1F3=P(5h_;Hnq9< z;G2qhR4%b5L$t^M4kAeviaxp`|D^T)NeKQwp4R(M?({#C{IAUOzY^X5BPaa-wgmNm xH2EJ*{zsGlxzF;~<(EGfUH)~;<$rgd + + + + + ) +} diff --git a/src/tribler/ui/src/Router.tsx b/src/tribler/ui/src/Router.tsx new file mode 100644 index 0000000000..a7324fafbb --- /dev/null +++ b/src/tribler/ui/src/Router.tsx @@ -0,0 +1,114 @@ +import { createHashRouter } from "react-router-dom"; +import { SideLayout } from "./components/layouts/SideLayout"; +import { filterActive, filterAll, filterCompleted, filterDownloading, filterInactive } from "./pages/Downloads"; +import NoMatch from "./pages/NoMatch"; +import Dashboard from "./pages/Dashboard"; +import Downloads from "./pages/Downloads"; +import Search from "./pages/Search"; +import Popular from "./pages/Popular"; +import GeneralSettings from "./pages/Settings/General"; +import Connection from "./pages/Settings/Connection"; +import Bandwidth from "./pages/Settings/Bandwidth"; +import Seeding from "./pages/Settings/Seeding"; +import Anonymity from "./pages/Settings/Anonymity"; +import Debugging from "./pages/Settings/Debugging"; +import GeneralDebug from "./pages/Debug/General"; +import IPv8 from "./pages/Debug/IPv8"; +import Tunnels from "./pages/Debug/Tunnels"; +import DHT from "./pages/Debug/DHT"; +import Libtorrent from "./pages/Debug/Libtorrent"; +import Asyncio from "./pages/Debug/Asyncio"; + + +export const router = createHashRouter([ + { + path: "/", + element: , + children: [ + { + path: "", + element: , + }, + { + path: "popular", + element: , + }, + { + path: "search", + element: , + }, + { + path: "downloads/all", + element: , + }, + { + path: "downloads/downloading", + element: , + }, + { + path: "downloads/completed", + element: , + }, + { + path: "downloads/active", + element: , + }, + { + path: "downloads/inactive", + element: , + }, + { + path: "settings/general", + element: , + }, + { + path: "settings/connection", + element: , + }, + { + path: "settings/bandwidth", + element: , + }, + { + path: "settings/seeding", + element: , + }, + { + path: "settings/anonymity", + element: , + }, + { + path: "settings/debugging", + element: , + }, + { + path: "debug/general", + element: , + }, + { + path: "debug/asyncio", + element: , + }, + { + path: "debug/ipv8", + element: , + }, + { + path: "debug/tunnels", + element: , + }, + { + path: "debug/dht", + element: , + }, + { + path: "debug/libtorrent", + element: , + }, + ], + }, + { + path: "*", + element: , + }, +]) diff --git a/src/tribler/ui/src/components/add-torrent.tsx b/src/tribler/ui/src/components/add-torrent.tsx new file mode 100644 index 0000000000..7609d83ddd --- /dev/null +++ b/src/tribler/ui/src/components/add-torrent.tsx @@ -0,0 +1,142 @@ +import { useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Button } from "./ui/button"; +import { PlusIcon, Cloud, File as FileIcon } from "lucide-react"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; +import { triblerService } from "@/services/tribler.service"; +import { Input } from "./ui/input"; +import SaveAs from "@/dialogs/SaveAs"; +import CreateTorrent from "@/dialogs/CreateTorrent"; +import { useTranslation } from "react-i18next"; + + +export function AddTorrent() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const inputRef = useRef(null); + + const [urlDialogOpen, setUrlDialogOpen] = useState(false); + const [uriInput, setUriInput] = useState(''); + + const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false); + + const [createDialogOpen, setCreateDialogOpen] = useState(false); + + const [torrent, setTorrent] = useState(); + + return ( + <> + + + + + + { + setUriInput(''); + setUrlDialogOpen(true); + }}> + + {t('ImportTorrentURL')} + + { + if (inputRef && inputRef.current) { + inputRef.current.click(); + } + }}> + + {t('ImportTorrentFile')} + + + { + setCreateDialogOpen(true); + }}> + + {t('CreateTorrent')} + + + + + + + + {t('MagnetDialogHeader')} + +
+ {t('MagnetDialogInputLabel')} +
+ setUriInput(event.target.value)} + /> +
+
+ + + + + + +
+
+ + + + + + { + if (!event.target.files || event.target.files.length === 0) { + return; + } + const files = Array.from(event.target.files as ArrayLike); + event.target.value = ''; + + if (files.length === 1 && triblerService.getGuiSettings().ask_download_settings !== false) { + setSaveAsDialogOpen(true); + setTorrent(files[0]); + } + else { + for (let file of files) { + (async () => { await triblerService.startDownloadFromFile(file) })(); + } + } + navigate("/downloads/all"); + }} + multiple + /> + + ) +} diff --git a/src/tribler/ui/src/components/icons.tsx b/src/tribler/ui/src/components/icons.tsx new file mode 100644 index 0000000000..a309d1b71a --- /dev/null +++ b/src/tribler/ui/src/components/icons.tsx @@ -0,0 +1,22 @@ +type IconProps = React.HTMLAttributes + +export const Icons = { + logo: (props: IconProps) => ( + + + + + ), + gitHub: (props: IconProps) => ( + + + + ), +} diff --git a/src/tribler/ui/src/components/language-select.tsx b/src/tribler/ui/src/components/language-select.tsx new file mode 100644 index 0000000000..6b1f04ea4b --- /dev/null +++ b/src/tribler/ui/src/components/language-select.tsx @@ -0,0 +1,46 @@ +import { useLanguage } from "@/contexts/LanguageContext"; +import { Button } from "./ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import { useTranslation } from "react-i18next"; +import Cookies from "js-cookie"; + + +const LanguageSelect = () => { + const { language, setLanguage } = useLanguage(); + const { t, i18n } = useTranslation(); + + const changeLanguage = (lng: string) => { + setLanguage(lng); + i18n.changeLanguage(lng); + Cookies.set('lang', lng); + }; + + return ( + + + + + + changeLanguage('en_US')}> + en + + changeLanguage('es_ES')}> + es + + changeLanguage('pt_BR')}> + pt + + changeLanguage('ru_RU')}> + ru + + changeLanguage('zh_CN')}> + zh + + + + ); +}; + +export default LanguageSelect; diff --git a/src/tribler/ui/src/components/layouts/AppLayout.tsx b/src/tribler/ui/src/components/layouts/AppLayout.tsx new file mode 100644 index 0000000000..1f7e0e98ff --- /dev/null +++ b/src/tribler/ui/src/components/layouts/AppLayout.tsx @@ -0,0 +1,15 @@ +import { Outlet } from "react-router-dom"; +import { Header } from "./Header"; + +export function Applayout() { + return ( + <> +
+
+
+ +
+
+ + ) +} diff --git a/src/tribler/ui/src/components/layouts/Header.tsx b/src/tribler/ui/src/components/layouts/Header.tsx new file mode 100644 index 0000000000..48e8f431b7 --- /dev/null +++ b/src/tribler/ui/src/components/layouts/Header.tsx @@ -0,0 +1,92 @@ +import { NavLink, useSearchParams } from "react-router-dom"; +import { Icons } from "@/components/icons"; +import { appConfig } from "@/config/app"; +import { Button } from "@/components/ui/button"; +import { ExitIcon } from "@radix-ui/react-icons"; +import { ModeToggle } from "../mode-toggle"; +import { Search } from "./Search"; +import LanguageSelect from "../language-select"; +import { triblerService } from "@/services/tribler.service"; +import { useInterval } from "@/hooks/useInterval"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog"; +import { useEffect, useState } from "react"; +import Cookies from "js-cookie"; +import { DialogDescription } from "@radix-ui/react-dialog"; +import { Ban } from "lucide-react"; + +export function Header() { + const [online, setOnline] = useState(true); + const [searchParams, setSearchParams] = useSearchParams(); + + useEffect(() => { + const key = searchParams.get("key"); + if (key) { + const oldKey = Cookies.get("api_key"); + Cookies.set("api_key", key); + searchParams.delete("key"); + setSearchParams(searchParams); + if (key !== oldKey) { + window.location.reload(); + } + } + }, [searchParams]); + + useInterval(() => { + if (online !== triblerService.isOnline()) + setOnline(triblerService.isOnline()); + }, 1000); + + return ( + <> + + { + e.preventDefault(); + }} + > + + Failed to connect to Tribler + Tribler may not be running or your browser is missing a cookie.
+ In latter case please re-open Tribler from the system tray
+
+
+
+ +
+
+
+ + + {appConfig.name} + +
+ + + {appConfig.name} + +
+ +
+ {/* right */} +
+
+ {/* */} +
+ +
+
+
+ + ) +} diff --git a/src/tribler/ui/src/components/layouts/Search.tsx b/src/tribler/ui/src/components/layouts/Search.tsx new file mode 100644 index 0000000000..32be765d4c --- /dev/null +++ b/src/tribler/ui/src/components/layouts/Search.tsx @@ -0,0 +1,20 @@ +'use client'; +import { triblerService } from '@/services/tribler.service'; +import { Autocomplete } from '../ui/autocomplete'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + + +export function Search() { + const navigate = useNavigate(); + const { t } = useTranslation(); + return ( + { + return await triblerService.getCompletions(value); + }} + onChange={(query) => navigate('/search?query=' + query)} + /> + ); +}; diff --git a/src/tribler/ui/src/components/layouts/SideLayout.tsx b/src/tribler/ui/src/components/layouts/SideLayout.tsx new file mode 100644 index 0000000000..186cfe1cb8 --- /dev/null +++ b/src/tribler/ui/src/components/layouts/SideLayout.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import { NavLink, Outlet, useLocation } from "react-router-dom"; +import { Header } from "./Header"; +import { Accordion } from "@radix-ui/react-accordion"; +import { sideMenu } from "@/config/menu"; +import { AccordionContent, AccordionItem, AccordionTrigger } from "../ui/accordion"; +import { Button, buttonVariants } from "../ui/button"; +import { Label } from "../ui/label"; +import { cn } from "@/lib/utils"; +import { Separator } from "../ui/separator"; +import { HamburgerMenuIcon } from "@radix-ui/react-icons"; +import { AddTorrent } from "../add-torrent"; +import { useTranslation } from "react-i18next"; + +export function SideLayout() { + const { t } = useTranslation(); + const location = useLocation(); + const [accordionValue, setAccordionValue] = useState(() => { + return "item-" + sideMenu.findIndex(item => item.items !== undefined ? item.items.filter(subitem => subitem.to !== undefined).map(subitem => subitem.to).includes(location.pathname) : false) + }); + const [showNav, setShowNav] = useState(false) + + return ( + <> +
+
+
+
+ +
+ +
+
+
+ +
+
+
+ + ) +} diff --git a/src/tribler/ui/src/components/mode-toggle.tsx b/src/tribler/ui/src/components/mode-toggle.tsx new file mode 100644 index 0000000000..6455817903 --- /dev/null +++ b/src/tribler/ui/src/components/mode-toggle.tsx @@ -0,0 +1,38 @@ +import { MoonIcon, SunIcon } from "@radix-ui/react-icons" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { useTheme } from "@/hooks/useTheme" + +export function ModeToggle() { + const { setTheme } = useTheme() + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ) +} diff --git a/src/tribler/ui/src/components/path-input.tsx b/src/tribler/ui/src/components/path-input.tsx new file mode 100644 index 0000000000..41ed5cecb6 --- /dev/null +++ b/src/tribler/ui/src/components/path-input.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { useTranslation } from "react-i18next"; +import SelectRemotePath from "@/dialogs/SelectRemotePath"; + + +interface PathInputProps { + path?: string; + onPathChange?: (value: string) => void; + directory?: boolean; + className?: string; +} + +export function PathInput(props: PathInputProps & JSX.IntrinsicAttributes) { + const { t } = useTranslation(); + const [openPathDialog, setOpenPathDialog] = useState(false); + + return ( +
+ { + if (props.onPathChange) + props.onPathChange(path) + }} + /> + { + if (props.onPathChange) + props.onPathChange(event.target.value) + }} + /> + +
+ ) +} diff --git a/src/tribler/ui/src/components/swarm-health.tsx b/src/tribler/ui/src/components/swarm-health.tsx new file mode 100644 index 0000000000..d19a8df1cb --- /dev/null +++ b/src/tribler/ui/src/components/swarm-health.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import { Torrent } from "@/models/torrent.model"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; +import { formatTimeAgo } from "@/lib/utils"; +import { triblerService } from "@/services/tribler.service"; + + +export function SwarmHealth({ torrent }: { torrent: Torrent }) { + const [checking, setChecking] = useState(false) + + useEffect(() => { + setChecking(false); + }, [torrent]); + + const bgColor = (t: Torrent) => { + return t.last_tracker_check === 0 ? + `bg-gray-400` : (t.num_seeders > 0 ? + `bg-green-400` : (t.num_leechers > 0 ? + `bg-yellow-400` : `bg-red-500`)) + } + + return ( + + + +
{ + triblerService.getTorrentHealth(torrent.infohash) + setChecking(true); + }} + > + {checking ? + + + + + : +
+ } + S{torrent.num_seeders} L{torrent.num_leechers} +
+ + + + {torrent.last_tracker_check === 0 ? 'Not checked' : `Checked ${formatTimeAgo(torrent.last_tracker_check)}`} + + + + + ) +} diff --git a/src/tribler/ui/src/components/ui/accordion.tsx b/src/tribler/ui/src/components/ui/accordion.tsx new file mode 100644 index 0000000000..bf49bdf090 --- /dev/null +++ b/src/tribler/ui/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/tribler/ui/src/components/ui/autocomplete.tsx b/src/tribler/ui/src/components/ui/autocomplete.tsx new file mode 100644 index 0000000000..11a23c1560 --- /dev/null +++ b/src/tribler/ui/src/components/ui/autocomplete.tsx @@ -0,0 +1,86 @@ +import { useRef, useState } from "react"; + + +export function Autocomplete({ placeholder, completions, onChange }: { placeholder: string, completions: (filter: string) => Promise, onChange: (query: string) => void }) { + const [inputValue, setInputValue] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [selectedSuggestion, setSelectedSuggestion] = useState(0); + const [focus, setFocus] = useState(false); + + const inputRef = useRef(null) + + const handleInputChange = (event: React.ChangeEvent) => { + const value = event.target.value; + setInputValue(value); + if (value.length === 0) { + setSuggestions([]); + return; + } + + // TODO: bebounce + (async () => { + setSuggestions(await completions(value)); + })(); + + }; + + const handleSuggestionClick = (value: string) => { + setInputValue(value); + setSuggestions([]); + setSelectedSuggestion(0); + onChange(value); + }; + + + return ( +
+
+
+
+ setFocus(true)} + onBlur={() => setFocus(false)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const query = (selectedSuggestion > 0) ? suggestions[selectedSuggestion - 1] : inputValue; + handleSuggestionClick(query); + inputRef.current?.blur(); + } + else if ((e.key === 'ArrowDown')) { + setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.length)); + e.preventDefault() + } + else if ((e.key === 'ArrowUp')) { + setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0)); + e.preventDefault() + } + }} + value={inputValue} + ref={inputRef} + /> +
+
+
+ {focus && ( +
+ {suggestions.length > 0 && (suggestions.map((suggestion, index) => ( +
+
handleSuggestionClick(suggestion)} + onMouseDown={(event) => event.preventDefault()} + key={index + 'b'}> + {suggestion} +
+
+ )))} +
+ )} +
+
+
+ + ); +} diff --git a/src/tribler/ui/src/components/ui/avatar.tsx b/src/tribler/ui/src/components/ui/avatar.tsx new file mode 100644 index 0000000000..7c0eb5ffea --- /dev/null +++ b/src/tribler/ui/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/tribler/ui/src/components/ui/badge.tsx b/src/tribler/ui/src/components/ui/badge.tsx new file mode 100644 index 0000000000..44ca81a7b7 --- /dev/null +++ b/src/tribler/ui/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +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 { } + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/tribler/ui/src/components/ui/button.tsx b/src/tribler/ui/src/components/ui/button.tsx new file mode 100644 index 0000000000..134f15562d --- /dev/null +++ b/src/tribler/ui/src/components/ui/button.tsx @@ -0,0 +1,57 @@ +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 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/tribler/ui/src/components/ui/card.tsx b/src/tribler/ui/src/components/ui/card.tsx new file mode 100644 index 0000000000..06a6b1a089 --- /dev/null +++ b/src/tribler/ui/src/components/ui/card.tsx @@ -0,0 +1,76 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/tribler/ui/src/components/ui/checkbox.tsx b/src/tribler/ui/src/components/ui/checkbox.tsx new file mode 100644 index 0000000000..ab9df489cd --- /dev/null +++ b/src/tribler/ui/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/tribler/ui/src/components/ui/dialog.tsx b/src/tribler/ui/src/components/ui/dialog.tsx new file mode 100644 index 0000000000..1970b10bd3 --- /dev/null +++ b/src/tribler/ui/src/components/ui/dialog.tsx @@ -0,0 +1,130 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +interface ExtendedDialogContentProps + extends React.ComponentPropsWithoutRef { + closable?: boolean; +} + +const DialogContent = React.forwardRef< + React.ElementRef, + ExtendedDialogContentProps + // React.ComponentPropsWithoutRef +>(({ className, children, closable, ...props }, ref) => ( + + + + {children} + {closable !== false && + + + Close + + } + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/tribler/ui/src/components/ui/dropdown-menu.tsx b/src/tribler/ui/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000000..0e4dccfdbf --- /dev/null +++ b/src/tribler/ui/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,203 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/src/tribler/ui/src/components/ui/form.tsx b/src/tribler/ui/src/components/ui/form.tsx new file mode 100644 index 0000000000..9d5ae91505 --- /dev/null +++ b/src/tribler/ui/src/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +