mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-18 00:33:59 +00:00
Compare commits
108 Commits
feat/custo
...
feat/separ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df6d9df31d | ||
|
|
b21c97ea66 | ||
|
|
9bada771df | ||
|
|
6146a1fbf1 | ||
|
|
91fd5932da | ||
|
|
a9b67ad1e6 | ||
|
|
47ac8e63ac | ||
|
|
3d71dded3d | ||
|
|
055be6b10a | ||
|
|
063e97e0ec | ||
|
|
6667e00c91 | ||
|
|
8653e62dce | ||
|
|
71f391c8e8 | ||
|
|
1f7947f50f | ||
|
|
72562b13ef | ||
|
|
1f05dc8f78 | ||
|
|
52d3750acc | ||
|
|
b91306e70e | ||
|
|
e3fb325b7b | ||
|
|
2e68018059 | ||
|
|
f11296f3a9 | ||
|
|
a92563509b | ||
|
|
899f68318f | ||
|
|
1b5f70a075 | ||
|
|
fab02c4d16 | ||
|
|
8d5b169166 | ||
|
|
3160ee68f1 | ||
|
|
80275dc08f | ||
|
|
958e66d795 | ||
|
|
4116459577 | ||
|
|
449ea92268 | ||
|
|
f1a1270230 | ||
|
|
63fe7acd0d | ||
|
|
2beb9c469a | ||
|
|
19cf24ef48 | ||
|
|
461da55070 | ||
|
|
f08ad361ed | ||
|
|
abe2314c38 | ||
|
|
cb9b120093 | ||
|
|
3058a05ca8 | ||
|
|
aacf9abc6a | ||
|
|
79498abdb5 | ||
|
|
2aebbb8fa2 | ||
|
|
c3ce92a48e | ||
|
|
8eb15900fe | ||
|
|
9812f455c9 | ||
|
|
74983520ed | ||
|
|
e3cd596fb2 | ||
|
|
37db88f48f | ||
|
|
0e999496e3 | ||
|
|
9c87964e16 | ||
|
|
45903d778e | ||
|
|
937a3d189e | ||
|
|
406b455960 | ||
|
|
c39f8c703b | ||
|
|
01b8f0c3af | ||
|
|
5930132de4 | ||
|
|
776859c58e | ||
|
|
cb0fc82644 | ||
|
|
3826294337 | ||
|
|
a625541125 | ||
|
|
300cff2be6 | ||
|
|
3c502679a6 | ||
|
|
701226d25d | ||
|
|
4f5c345c42 | ||
|
|
d513377f1c | ||
|
|
46491af539 | ||
|
|
f9f110bd1c | ||
|
|
e14c125a43 | ||
|
|
d8947e5ab8 | ||
|
|
5648e393bf | ||
|
|
d7e47323e5 | ||
|
|
30e4d694cd | ||
|
|
0dbf904bb8 | ||
|
|
e678cc3801 | ||
|
|
d7d920a7b7 | ||
|
|
3afd30765f | ||
|
|
5bf5ca3504 | ||
|
|
6e2d7f5c8a | ||
|
|
a003153239 | ||
|
|
40aaa5d3ca | ||
|
|
0e2cc2b25c | ||
|
|
6f258aecee | ||
|
|
35ce07c5f4 | ||
|
|
7514177423 | ||
|
|
4f3113002b | ||
|
|
e43f80b830 | ||
|
|
6c34e5516f | ||
|
|
81f001ade4 | ||
|
|
688ae215d7 | ||
|
|
6f5baf0df5 | ||
|
|
1d5be940f9 | ||
|
|
cd25fa715e | ||
|
|
13a2a8161c | ||
|
|
7ba65b3357 | ||
|
|
bd56c48cb3 | ||
|
|
1525ccc438 | ||
|
|
08702d3157 | ||
|
|
8dba05eafe | ||
|
|
5e217bf086 | ||
|
|
349da9536a | ||
|
|
dad493f558 | ||
|
|
9939b68fec | ||
|
|
d00fe8dedc | ||
|
|
f9673b1a5a | ||
|
|
3ff8ed735e | ||
|
|
ca6331e45b | ||
|
|
0d19328798 |
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
os: [windows-2022, ubuntu-latest]
|
||||
fail-fast: false
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
if: matrix.os == 'windows-2022'
|
||||
run: yarn build:win
|
||||
env:
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
|
||||
|
||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
os: [windows-2022, ubuntu-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
RENDERER_VITE_TORBOX_REFERRAL_CODE: ${{ vars.RENDERER_VITE_TORBOX_REFERRAL_CODE }}
|
||||
|
||||
- name: Build Windows
|
||||
if: matrix.os == 'windows-latest'
|
||||
if: matrix.os == 'windows-2022'
|
||||
run: yarn build:win
|
||||
env:
|
||||
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
|
||||
|
||||
@@ -40,6 +40,12 @@
|
||||
"@primer/octicons-react": "^19.9.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@reduxjs/toolkit": "^2.2.3",
|
||||
"@tiptap/extension-bold": "^3.6.2",
|
||||
"@tiptap/extension-italic": "^3.6.2",
|
||||
"@tiptap/extension-link": "^3.6.2",
|
||||
"@tiptap/extension-underline": "^3.6.2",
|
||||
"@tiptap/react": "^3.6.2",
|
||||
"@tiptap/starter-kit": "^3.6.2",
|
||||
"auto-launch": "^5.0.6",
|
||||
"axios": "^1.7.9",
|
||||
"axios-cookiejar-support": "^5.0.5",
|
||||
@@ -63,6 +69,7 @@
|
||||
"jsdom": "^24.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.544.0",
|
||||
"parse-torrent": "^11.0.18",
|
||||
"rc-virtual-list": "^3.18.3",
|
||||
"react-dnd": "^16.0.1",
|
||||
|
||||
151
python_rpc/http_multi_link_downloader.py
Normal file
151
python_rpc/http_multi_link_downloader.py
Normal file
@@ -0,0 +1,151 @@
|
||||
import aria2p
|
||||
from aria2p.client import ClientException as DownloadNotFound
|
||||
|
||||
class HttpMultiLinkDownloader:
|
||||
def __init__(self):
|
||||
self.downloads = []
|
||||
self.completed_downloads = []
|
||||
self.total_size = None
|
||||
self.aria2 = aria2p.API(
|
||||
aria2p.Client(
|
||||
host="http://localhost",
|
||||
port=6800,
|
||||
secret=""
|
||||
)
|
||||
)
|
||||
|
||||
def start_download(self, urls: list[str], save_path: str, header: str = None, out: str = None, total_size: int = None):
|
||||
"""Add multiple URLs to download queue with same options"""
|
||||
options = {"dir": save_path}
|
||||
if header:
|
||||
options["header"] = header
|
||||
if out:
|
||||
options["out"] = out
|
||||
|
||||
# Clear any existing downloads first
|
||||
self.cancel_download()
|
||||
self.completed_downloads = []
|
||||
self.total_size = total_size
|
||||
|
||||
for url in urls:
|
||||
try:
|
||||
added_downloads = self.aria2.add(url, options=options)
|
||||
self.downloads.extend(added_downloads)
|
||||
except Exception as e:
|
||||
print(f"Error adding download for URL {url}: {str(e)}")
|
||||
|
||||
def pause_download(self):
|
||||
"""Pause all active downloads"""
|
||||
if self.downloads:
|
||||
try:
|
||||
self.aria2.pause(self.downloads)
|
||||
except Exception as e:
|
||||
print(f"Error pausing downloads: {str(e)}")
|
||||
|
||||
def cancel_download(self):
|
||||
"""Cancel and remove all downloads"""
|
||||
if self.downloads:
|
||||
try:
|
||||
# First try to stop the downloads
|
||||
self.aria2.remove(self.downloads)
|
||||
except Exception as e:
|
||||
print(f"Error removing downloads: {str(e)}")
|
||||
finally:
|
||||
# Clear the downloads list regardless of success/failure
|
||||
self.downloads = []
|
||||
self.completed_downloads = []
|
||||
|
||||
def get_download_status(self):
|
||||
"""Get status for all tracked downloads, auto-remove completed/failed ones"""
|
||||
if not self.downloads and not self.completed_downloads:
|
||||
return []
|
||||
|
||||
total_completed = 0
|
||||
current_download_speed = 0
|
||||
active_downloads = []
|
||||
to_remove = []
|
||||
|
||||
# First calculate sizes from completed downloads
|
||||
for completed in self.completed_downloads:
|
||||
total_completed += completed['size']
|
||||
|
||||
# Then check active downloads
|
||||
for download in self.downloads:
|
||||
try:
|
||||
current_download = self.aria2.get_download(download.gid)
|
||||
|
||||
# Skip downloads that are not properly initialized
|
||||
if not current_download or not current_download.files:
|
||||
to_remove.append(download)
|
||||
continue
|
||||
|
||||
# Add to completed size and speed calculations
|
||||
total_completed += current_download.completed_length
|
||||
current_download_speed += current_download.download_speed
|
||||
|
||||
# If download is complete, move it to completed_downloads
|
||||
if current_download.status == 'complete':
|
||||
self.completed_downloads.append({
|
||||
'name': current_download.name,
|
||||
'size': current_download.total_length
|
||||
})
|
||||
to_remove.append(download)
|
||||
else:
|
||||
active_downloads.append({
|
||||
'name': current_download.name,
|
||||
'size': current_download.total_length,
|
||||
'completed': current_download.completed_length,
|
||||
'speed': current_download.download_speed
|
||||
})
|
||||
|
||||
except DownloadNotFound:
|
||||
to_remove.append(download)
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Error getting download status: {str(e)}")
|
||||
continue
|
||||
|
||||
# Clean up completed/removed downloads from active list
|
||||
for download in to_remove:
|
||||
try:
|
||||
if download in self.downloads:
|
||||
self.downloads.remove(download)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Return aggregate status
|
||||
if self.total_size or active_downloads or self.completed_downloads:
|
||||
# Use the first active download's name as the folder name, or completed if none active
|
||||
folder_name = None
|
||||
if active_downloads:
|
||||
folder_name = active_downloads[0]['name']
|
||||
elif self.completed_downloads:
|
||||
folder_name = self.completed_downloads[0]['name']
|
||||
|
||||
if folder_name and '/' in folder_name:
|
||||
folder_name = folder_name.split('/')[0]
|
||||
|
||||
# Use provided total size if available, otherwise sum from downloads
|
||||
total_size = self.total_size
|
||||
if not total_size:
|
||||
total_size = sum(d['size'] for d in active_downloads) + sum(d['size'] for d in self.completed_downloads)
|
||||
|
||||
# Calculate completion status based on total downloaded vs total size
|
||||
is_complete = len(active_downloads) == 0 and total_completed >= (total_size * 0.99) # Allow 1% margin for size differences
|
||||
|
||||
# If all downloads are complete, clear the completed_downloads list to prevent status updates
|
||||
if is_complete:
|
||||
self.completed_downloads = []
|
||||
|
||||
return [{
|
||||
'folderName': folder_name,
|
||||
'fileSize': total_size,
|
||||
'progress': total_completed / total_size if total_size > 0 else 0,
|
||||
'downloadSpeed': current_download_speed,
|
||||
'numPeers': 0,
|
||||
'numSeeds': 0,
|
||||
'status': 'complete' if is_complete else 'active',
|
||||
'bytesDownloaded': total_completed,
|
||||
}]
|
||||
|
||||
return []
|
||||
@@ -3,6 +3,7 @@ import sys, json, urllib.parse, psutil
|
||||
from torrent_downloader import TorrentDownloader
|
||||
from http_downloader import HttpDownloader
|
||||
from profile_image_processor import ProfileImageProcessor
|
||||
from http_multi_link_downloader import HttpMultiLinkDownloader
|
||||
import libtorrent as lt
|
||||
|
||||
app = Flask(__name__)
|
||||
@@ -24,7 +25,15 @@ if start_download_payload:
|
||||
initial_download = json.loads(urllib.parse.unquote(start_download_payload))
|
||||
downloading_game_id = initial_download['game_id']
|
||||
|
||||
if initial_download['url'].startswith('magnet'):
|
||||
if isinstance(initial_download['url'], list):
|
||||
# Handle multiple URLs using HttpMultiLinkDownloader
|
||||
http_multi_downloader = HttpMultiLinkDownloader()
|
||||
downloads[initial_download['game_id']] = http_multi_downloader
|
||||
try:
|
||||
http_multi_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('header'), initial_download.get("out"))
|
||||
except Exception as e:
|
||||
print("Error starting multi-link download", e)
|
||||
elif initial_download['url'].startswith('magnet'):
|
||||
torrent_downloader = TorrentDownloader(torrent_session)
|
||||
downloads[initial_download['game_id']] = torrent_downloader
|
||||
try:
|
||||
@@ -62,12 +71,23 @@ def status():
|
||||
return auth_error
|
||||
|
||||
downloader = downloads.get(downloading_game_id)
|
||||
if downloader:
|
||||
status = downloads.get(downloading_game_id).get_download_status()
|
||||
return jsonify(status), 200
|
||||
else:
|
||||
if not downloader:
|
||||
return jsonify(None)
|
||||
|
||||
status = downloader.get_download_status()
|
||||
if not status:
|
||||
return jsonify(None)
|
||||
|
||||
if isinstance(status, list):
|
||||
if not status: # Empty list
|
||||
return jsonify(None)
|
||||
|
||||
# For multi-link downloader, use the aggregated status
|
||||
# The status will already be aggregated by the HttpMultiLinkDownloader
|
||||
return jsonify(status[0]), 200
|
||||
|
||||
return jsonify(status), 200
|
||||
|
||||
@app.route("/seed-status", methods=["GET"])
|
||||
def seed_status():
|
||||
auth_error = validate_rpc_password()
|
||||
@@ -81,10 +101,24 @@ def seed_status():
|
||||
continue
|
||||
|
||||
response = downloader.get_download_status()
|
||||
if response is None:
|
||||
if not response:
|
||||
continue
|
||||
|
||||
if response.get('status') == 5:
|
||||
if isinstance(response, list):
|
||||
# For multi-link downloader, check if all files are complete
|
||||
if response and all(item['status'] == 'complete' for item in response):
|
||||
seed_status.append({
|
||||
'gameId': game_id,
|
||||
'status': 'complete',
|
||||
'folderName': response[0]['folderName'],
|
||||
'fileSize': sum(item['fileSize'] for item in response),
|
||||
'bytesDownloaded': sum(item['bytesDownloaded'] for item in response),
|
||||
'downloadSpeed': 0,
|
||||
'numPeers': 0,
|
||||
'numSeeds': 0,
|
||||
'progress': 1.0
|
||||
})
|
||||
elif response.get('status') == 5: # Original torrent seeding check
|
||||
seed_status.append({
|
||||
'gameId': game_id,
|
||||
**response,
|
||||
@@ -143,7 +177,15 @@ def action():
|
||||
|
||||
existing_downloader = downloads.get(game_id)
|
||||
|
||||
if url.startswith('magnet'):
|
||||
if isinstance(url, list):
|
||||
# Handle multiple URLs using HttpMultiLinkDownloader
|
||||
if existing_downloader and isinstance(existing_downloader, HttpMultiLinkDownloader):
|
||||
existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
|
||||
else:
|
||||
http_multi_downloader = HttpMultiLinkDownloader()
|
||||
downloads[game_id] = http_multi_downloader
|
||||
http_multi_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
|
||||
elif url.startswith('magnet'):
|
||||
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
|
||||
existing_downloader.start_download(url, data['save_path'])
|
||||
else:
|
||||
|
||||
@@ -70,7 +70,13 @@
|
||||
"edit_game_modal_icon_resolution": "Recommended resolution: 256x256px",
|
||||
"edit_game_modal_logo_resolution": "Recommended resolution: 640x360px",
|
||||
"edit_game_modal_hero_resolution": "Recommended resolution: 1920x620px",
|
||||
"edit_game_modal_assets": "Assets"
|
||||
"edit_game_modal_assets": "Assets",
|
||||
"edit_game_modal_drop_icon_image_here": "Drop icon image here",
|
||||
"edit_game_modal_drop_logo_image_here": "Drop logo image here",
|
||||
"edit_game_modal_drop_hero_image_here": "Drop hero image here",
|
||||
"edit_game_modal_drop_to_replace_icon": "Drop to replace icon",
|
||||
"edit_game_modal_drop_to_replace_logo": "Drop to replace logo",
|
||||
"edit_game_modal_drop_to_replace_hero": "Drop to replace hero"
|
||||
},
|
||||
"header": {
|
||||
"search": "Search games",
|
||||
@@ -160,11 +166,13 @@
|
||||
"open_folder": "Open folder",
|
||||
"open_download_location": "See downloaded files",
|
||||
"create_shortcut": "Create desktop shortcut",
|
||||
"create_shortcut_simple": "Create shortcut",
|
||||
"clear": "Clear",
|
||||
"remove_files": "Remove files",
|
||||
"remove_from_library_title": "Are you sure?",
|
||||
"remove_from_library_description": "This will remove {{game}} from your library",
|
||||
"options": "Options",
|
||||
"properties": "Properties",
|
||||
"executable_section_title": "Executable",
|
||||
"executable_section_description": "Path of the file that will be executed when \"Play\" is clicked",
|
||||
"downloads_section_title": "Downloads",
|
||||
@@ -178,6 +186,13 @@
|
||||
"create_shortcut_success": "Shortcut created successfully",
|
||||
"you_might_need_to_restart_steam": "You might need to restart Steam to see the changes",
|
||||
"create_shortcut_error": "Error creating shortcut",
|
||||
"add_to_favorites": "Add to favorites",
|
||||
"remove_from_favorites": "Remove from favorites",
|
||||
"failed_update_favorites": "Failed to update favorites",
|
||||
"game_removed_from_library": "Game removed from library",
|
||||
"failed_remove_from_library": "Failed to remove from library",
|
||||
"files_removed_success": "Files removed successfully",
|
||||
"failed_remove_files": "Failed to remove files",
|
||||
"nsfw_content_title": "This game contains inappropriate content",
|
||||
"nsfw_content_description": "{{title}} contains content that may not be suitable for all ages. Are you sure you want to continue?",
|
||||
"allow_nsfw_content": "Continue",
|
||||
@@ -185,6 +200,7 @@
|
||||
"stats": "Stats",
|
||||
"download_count": "Downloads",
|
||||
"player_count": "Active players",
|
||||
"rating_count": "Rating",
|
||||
"download_error": "This download option is not available",
|
||||
"download": "Download",
|
||||
"executable_path_in_use": "Executable already in use by \"{{game}}\"",
|
||||
@@ -192,6 +208,39 @@
|
||||
"hydra_needs_to_remain_open": "for this download, Hydra needs to remain open util it's completed. If Hydra closes before completing, you will lose your progress.",
|
||||
"achievements": "Achievements",
|
||||
"achievements_count": "Achievements {{unlockedCount}}/{{achievementsCount}}",
|
||||
"show_more": "Show more",
|
||||
"show_less": "Show less",
|
||||
"reviews": "Reviews",
|
||||
"leave_a_review": "Leave a Review",
|
||||
"write_review_placeholder": "Share your thoughts about this game...",
|
||||
"sort_newest": "Newest",
|
||||
"no_reviews_yet": "No reviews yet",
|
||||
"be_first_to_review": "Be the first to share your thoughts about this game!",
|
||||
"sort_oldest": "Oldest",
|
||||
"sort_highest_score": "Highest Score",
|
||||
"sort_lowest_score": "Lowest Score",
|
||||
"sort_most_voted": "Most Voted",
|
||||
"rating": "Rating",
|
||||
"rating_stats": "Rating",
|
||||
"rating_very_negative": "Very Negative",
|
||||
"rating_negative": "Negative",
|
||||
"rating_neutral": "Neutral",
|
||||
"rating_positive": "Positive",
|
||||
"rating_very_positive": "Very Positive",
|
||||
"submit_review": "Submit Review",
|
||||
"submitting": "Submitting...",
|
||||
"review_submitted_successfully": "Review submitted successfully!",
|
||||
"review_submission_failed": "Failed to submit review. Please try again.",
|
||||
"review_cannot_be_empty": "Review text field cannot be empty.",
|
||||
"review_deleted_successfully": "Review deleted successfully.",
|
||||
"review_deletion_failed": "Failed to delete review. Please try again.",
|
||||
"loading_reviews": "Loading reviews...",
|
||||
"loading_more_reviews": "Loading more reviews...",
|
||||
"load_more_reviews": "Load More Reviews",
|
||||
"you_seemed_to_enjoy_this_game": "You've seemed to enjoy this game",
|
||||
"would_you_recommend_this_game": "Would you like to leave a review to this game?",
|
||||
"yes": "Yes",
|
||||
"maybe_later": "Maybe Later",
|
||||
"cloud_save": "Cloud save",
|
||||
"cloud_save_description": "Save your progress in the cloud and continue playing on any device",
|
||||
"backups": "Backups",
|
||||
@@ -204,6 +253,7 @@
|
||||
"uploading_backup": "Uploading backup…",
|
||||
"no_backups": "You haven't created any backups for this game yet",
|
||||
"backup_uploaded": "Backup uploaded",
|
||||
"backup_failed": "Backup failed",
|
||||
"backup_deleted": "Backup deleted",
|
||||
"backup_restored": "Backup restored",
|
||||
"see_all_achievements": "See all achievements",
|
||||
@@ -286,7 +336,15 @@
|
||||
"historical_keyshop": "Historical keyshop",
|
||||
"language": "Language",
|
||||
"caption": "Caption",
|
||||
"audio": "Audio"
|
||||
"audio": "Audio",
|
||||
"filter_by_source": "Filter by source",
|
||||
"no_repacks_found": "No sources found for this game",
|
||||
"delete_review": "Delete review",
|
||||
"remove_review": "Remove Review",
|
||||
"delete_review_modal_title": "Are you sure you want to delete your review?",
|
||||
"delete_review_modal_description": "This action cannot be undone.",
|
||||
"delete_review_modal_delete_button": "Delete",
|
||||
"delete_review_modal_cancel_button": "Cancel"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activate Hydra",
|
||||
@@ -324,6 +382,7 @@
|
||||
"stop_seeding": "Stop seeding",
|
||||
"resume_seeding": "Resume seeding",
|
||||
"options": "Manage",
|
||||
"alldebrid_size_not_supported": "Download info for AllDebrid is not supported yet",
|
||||
"extract": "Extract files",
|
||||
"extracting": "Extracting files…"
|
||||
},
|
||||
@@ -433,6 +492,17 @@
|
||||
"create_real_debrid_account": "Click here if you don't have a Real-Debrid account yet",
|
||||
"create_torbox_account": "Click here if you don't have a TorBox account yet",
|
||||
"real_debrid_account_linked": "Real-Debrid account linked",
|
||||
"enable_all_debrid": "Enable All-Debrid",
|
||||
"all_debrid_description": "All-Debrid is an unrestricted downloader that allows you to quickly download files from various sources.",
|
||||
"all_debrid_free_account_error": "The account \"{{username}}\" is a free account. Please subscribe to All-Debrid",
|
||||
"all_debrid_account_linked": "All-Debrid account linked successfully",
|
||||
"alldebrid_missing_key": "Please provide an API key",
|
||||
"alldebrid_invalid_key": "Invalid API key",
|
||||
"alldebrid_blocked": "Your API key is geo-blocked or IP-blocked",
|
||||
"alldebrid_banned": "This account has been banned",
|
||||
"alldebrid_unknown_error": "An unknown error occurred",
|
||||
"alldebrid_invalid_response": "Invalid response from All-Debrid",
|
||||
"alldebrid_network_error": "Network error. Please check your connection",
|
||||
"name_min_length": "Theme name must be at least 3 characters long",
|
||||
"import_theme": "Import theme",
|
||||
"import_theme_description": "You will import {{theme}} from the theme store",
|
||||
@@ -490,7 +560,8 @@
|
||||
"game_card": {
|
||||
"available_one": "Available",
|
||||
"available_other": "Available",
|
||||
"no_downloads": "No downloads available"
|
||||
"no_downloads": "No downloads available",
|
||||
"calculating": "Calculating"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "Programs not installed",
|
||||
@@ -593,7 +664,10 @@
|
||||
"error_adding_friend": "Could not send friend request. Please check friend code",
|
||||
"friend_code_length_error": "Friend code must have 8 characters",
|
||||
"game_removed_from_pinned": "Game removed from pinned",
|
||||
"game_added_to_pinned": "Game added to pinned"
|
||||
"game_added_to_pinned": "Game added to pinned",
|
||||
"karma": "Karma",
|
||||
"karma_count": "karma",
|
||||
"karma_description": "Earned from positive likes on reviews"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Achievement unlocked",
|
||||
|
||||
@@ -204,6 +204,7 @@
|
||||
"uploading_backup": "Subiendo copia de seguridad…",
|
||||
"no_backups": "No has creado ninguna copia de seguridad para este juego todavía",
|
||||
"backup_uploaded": "Copia de seguridad subida",
|
||||
"backup_failed": "Copia de seguridad fallida",
|
||||
"backup_deleted": "Copia de seguridad eliminada",
|
||||
"backup_restored": "Copia de seguridad restaurada",
|
||||
"see_all_achievements": "Ver todos los logros",
|
||||
|
||||
@@ -1,40 +1,113 @@
|
||||
{
|
||||
"language_name": "Magyar",
|
||||
"app": {
|
||||
"successfully_signed_in": "Sikeresen bejelentkeztél"
|
||||
},
|
||||
"home": {
|
||||
"surprise_me": "Lepj meg",
|
||||
"no_results": "Nem található"
|
||||
"no_results": "Nincs találat",
|
||||
"start_typing": "Kereséshez gépelj...",
|
||||
"hot": "Most felkapott",
|
||||
"weekly": "📅 A hét felkapott játékai",
|
||||
"achievements": "🏆 Achievement támogatott"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Katalógus",
|
||||
"downloads": "Letöltések",
|
||||
"settings": "Beállítások",
|
||||
"my_library": "Könyvtáram",
|
||||
"downloading_metadata": "{{title}} (Metadata letöltése…)",
|
||||
"paused": "{{title}} (Szünet)",
|
||||
"downloading_metadata": "{{title}} (metaadatai letöltése…)",
|
||||
"paused": "{{title}} (Szüneteltetve)",
|
||||
"downloading": "{{title}} ({{percentage}} - Letöltés…)",
|
||||
"filter": "Könyvtár szűrése",
|
||||
"home": "Főoldal",
|
||||
"favorites": "Kedvenc játékok"
|
||||
"queued": "A(z) {{title}} (Várakozósorban van)",
|
||||
"game_has_no_executable": "A játékhoz nincs tallózva futtatható fájl",
|
||||
"sign_in": "Bejelentkezés",
|
||||
"friends": "Barátok",
|
||||
"need_help": "Elakadtál?",
|
||||
"favorites": "Kedvenc játékok",
|
||||
"playable_button_title": "Csak az azonnal játszható játékokat mutasd",
|
||||
"add_custom_game_tooltip": "Saját játék hozzáadása",
|
||||
"show_playable_only_tooltip": "Csak játszható játék mutatása",
|
||||
"custom_game_modal": "Saját játék hozzáadása:",
|
||||
"custom_game_modal_description": "Adj meg egy futtatható fájlt",
|
||||
"custom_game_modal_executable_path": "A fájl útvonala",
|
||||
"custom_game_modal_select_executable": "Az útvonal",
|
||||
"custom_game_modal_title": "Játékcím",
|
||||
"custom_game_modal_enter_title": "Játék elnevezése",
|
||||
"custom_game_modal_browse": "Tallózás",
|
||||
"custom_game_modal_cancel": "Mégse",
|
||||
"custom_game_modal_add": "Játék hozzáadása",
|
||||
"custom_game_modal_adding": "Játék hozzáadása...",
|
||||
"custom_game_modal_success": "Saját játék sikeresen hozzáadva",
|
||||
"custom_game_modal_failed": "Saját játék hozzáadása sikertelen",
|
||||
"custom_game_modal_executable": "Futtatható fájl",
|
||||
"edit_game_modal": "Játékmegjelenés",
|
||||
"edit_game_modal_description": "Játékcím és vizuális elemek módosítása",
|
||||
"edit_game_modal_title": "Játékcím",
|
||||
"edit_game_modal_enter_title": "Játék elnevezése",
|
||||
"edit_game_modal_image": "Kép",
|
||||
"edit_game_modal_select_image": "Kép útvonala",
|
||||
"edit_game_modal_browse": "Tallózás",
|
||||
"edit_game_modal_image_preview": "Kép előnézete",
|
||||
"edit_game_modal_icon": "Ikon",
|
||||
"edit_game_modal_select_icon": "Ikon útvonala",
|
||||
"edit_game_modal_icon_preview": "Ikon előnézete",
|
||||
"edit_game_modal_logo": "Logó",
|
||||
"edit_game_modal_select_logo": "Logó útvonala",
|
||||
"edit_game_modal_logo_preview": "Logó előnézete",
|
||||
"edit_game_modal_hero": "Borítókép",
|
||||
"edit_game_modal_select_hero": "Borítókép útvonala",
|
||||
"edit_game_modal_hero_preview": "Borítókép előnézete",
|
||||
"edit_game_modal_cancel": "Mégse",
|
||||
"edit_game_modal_update": "Frissít",
|
||||
"edit_game_modal_updating": "Frissítés...",
|
||||
"edit_game_modal_fill_required": "Kérlek töltsd ki az összes kötelező mezőt",
|
||||
"edit_game_modal_success": "Játék megjelenés frissítése sikeres",
|
||||
"edit_game_modal_failed": "Játék megjelenés frissítése sikertelen",
|
||||
"edit_game_modal_image_filter": "Kép",
|
||||
"edit_game_modal_icon_resolution": "Ajánlott felbontás: 256x256px",
|
||||
"edit_game_modal_logo_resolution": "Ajánlott felbontás: 640x360px",
|
||||
"edit_game_modal_hero_resolution": "Ajánlott felbontás: 1920x620px",
|
||||
"edit_game_modal_assets": "Vizuális elemek:"
|
||||
},
|
||||
"header": {
|
||||
"search": "Keresés",
|
||||
"home": "Főoldal",
|
||||
"catalogue": "Katalógus",
|
||||
"downloads": "Letöltések",
|
||||
"search_results": "Keresési eredmények",
|
||||
"settings": "Beállítások"
|
||||
"search_results": "Keresési találatok",
|
||||
"settings": "Beállítások",
|
||||
"version_available_install": "A(z) {{version}} verzió elérhető. Kattints ide az újraindításhoz és telepítéshez.",
|
||||
"version_available_download": "A(z) {{version}} verzió elérhető. A letöltéshez kattints ide."
|
||||
},
|
||||
"bottom_panel": {
|
||||
"no_downloads_in_progress": "Nincsenek folyamatban lévő letöltések",
|
||||
"no_downloads_in_progress": "Nincs folyamatban lévő letöltés",
|
||||
"downloading_metadata": "{{title}} metaadatainak letöltése…",
|
||||
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}"
|
||||
"downloading": "{{title}} letöltése… ({{percentage}} kész) - Befejezés {{eta}} - {{speed}}",
|
||||
"calculating_eta": "{{title}} letöltése… ({{percentage}} kész) - Hátralévő idő…",
|
||||
"checking_files": "A(z) {{title}} fájljaiból… ({{percentage}} kész)",
|
||||
"installing_common_redist": "{{log}}…",
|
||||
"installation_complete": "Telepítés befejezve",
|
||||
"installation_complete_message": "A(z) Alapvető segédprogramok sikeresen telepítve"
|
||||
},
|
||||
"catalogue": {
|
||||
"search": "Szűrés…",
|
||||
"developers": "Fejlesztők",
|
||||
"genres": "Műfajok",
|
||||
"tags": "Címkék",
|
||||
"publishers": "Kiadók",
|
||||
"download_sources": "Letöltési források",
|
||||
"result_count": "{{resultCount}} találatok",
|
||||
"filter_count": "{{filterCount}} elérhető",
|
||||
"clear_filters": "{{filterCount}} kiválaszott szűrő törlése"
|
||||
},
|
||||
"catalogue": {},
|
||||
"game_details": {
|
||||
"open_download_options": "Letöltési lehetőségek",
|
||||
"download_options_zero": "Nincs letöltési lehetőség",
|
||||
"download_options_one": "{{count}} letöltési lehetőség",
|
||||
"download_options_other": "{{count}} letöltési lehetőség",
|
||||
"open_download_options": "Letöltési opciók megnyitása",
|
||||
"download_options_zero": "Nincs letöltési opció",
|
||||
"download_options_one": "{{count}} letöltési opció",
|
||||
"download_options_other": "{{count}} letöltési opció",
|
||||
"updated_at": "Frissítve: {{updated_at}}",
|
||||
"install": "Letöltés",
|
||||
"resume": "Folytatás",
|
||||
@@ -43,11 +116,13 @@
|
||||
"remove": "Eltávolítás",
|
||||
"space_left_on_disk": "{{space}} szabad hely a lemezen",
|
||||
"eta": "Befejezés {{eta}}",
|
||||
"downloading_metadata": "Metaadatok letöltése…",
|
||||
"calculating_eta": "Hátralevő idő kiszámítása…",
|
||||
"downloading_metadata": "Metaadat letöltése",
|
||||
"filter": "Repackek szűrése",
|
||||
"requirements": "Rendszerkövetelmények",
|
||||
"minimum": "Minimális",
|
||||
"minimum": "Minimum",
|
||||
"recommended": "Ajánlott",
|
||||
"paused": "Szüneteltetve",
|
||||
"release_date": "Megjelenés: {{date}}",
|
||||
"publisher": "Kiadta: {{publisher}}",
|
||||
"hours": "óra",
|
||||
@@ -55,29 +130,171 @@
|
||||
"amount_hours": "{{amount}} óra",
|
||||
"amount_minutes": "{{amount}} perc",
|
||||
"accuracy": "{{accuracy}}% pontosság",
|
||||
"add_to_library": "Hozzáadás a könyvtárhoz",
|
||||
"add_to_library": "Könyvtárba helyezés",
|
||||
"already_in_library": "Már könyvtárban",
|
||||
"remove_from_library": "Eltávolítás a könyvtárból",
|
||||
"no_downloads": "Nincs elérhető letöltés",
|
||||
"play_time": "Játszva: {{amount}}",
|
||||
"last_time_played": "Utoljára játszva {{period}}",
|
||||
"not_played_yet": "{{title}} még nem játszottál",
|
||||
"last_time_played": "Utoljára játszva: {{period}}",
|
||||
"not_played_yet": "Ezzel a játékkal még nem játszottál: {{title}}",
|
||||
"next_suggestion": "Következő javaslat",
|
||||
"play": "Játék",
|
||||
"deleting": "Telepítő törlése…",
|
||||
"close": "Bezárás",
|
||||
"playing_now": "Jelenleg játszva",
|
||||
"playing_now": "Játékban: ",
|
||||
"change": "Változtatás",
|
||||
"repacks_modal_description": "Choose the repack you want to download",
|
||||
"select_folder_hint": "Ahhoz, hogy megváltoztasd a helyet, hozzákell férned a",
|
||||
"download_now": "Töltsd le most"
|
||||
"repacks_modal_description": "Válaszd ki a repacket amit leszeretnél tölteni",
|
||||
"select_folder_hint": "Hogy megváltoztasd a letöltési mappát, menj a <0>Beállítások</0> menüjébe",
|
||||
"download_now": "Letöltés",
|
||||
"no_shop_details": "A bolt adatai nem érhetőek el.",
|
||||
"download_options": "Letöltési opciók",
|
||||
"download_path": "Letöltis hely",
|
||||
"previous_screenshot": "Előző screenshot",
|
||||
"next_screenshot": "Következő screenshot",
|
||||
"screenshot": "Screenshot {{number}}",
|
||||
"open_screenshot": "Screenshot megnyitása {{number}}",
|
||||
"download_settings": "Letöltési beállítások",
|
||||
"downloader": "Letöltési mód",
|
||||
"select_executable": "Tallózás",
|
||||
"no_executable_selected": "Nincs futtatható fájl tallózva",
|
||||
"open_folder": "Mappa megnyitása",
|
||||
"open_download_location": "Letöltött fájlok megtekintése",
|
||||
"create_shortcut": "Asztali parancsikon létrehozása",
|
||||
"clear": "Visszavon",
|
||||
"remove_files": "Fájlok eltávolítása",
|
||||
"remove_from_library_title": "Biztos vagy ebben?",
|
||||
"remove_from_library_description": "Ezzel eltávolítod a játékot {{game}} a könyvtáradból",
|
||||
"options": "Beállítások",
|
||||
"executable_section_title": "Futtatható fájl",
|
||||
"executable_section_description": "A fájl amely futtatásra fog kerülni amikor a \"Játék\" lenyomásra kerül",
|
||||
"downloads_section_title": "Letöltések",
|
||||
"downloads_section_description": "Csekkold le a játék frissítéseit vagy más verzióit",
|
||||
"danger_zone_section_title": "Veszélyzóna",
|
||||
"danger_zone_section_description": "Távolítsd el a játékot könyvtáradból, vagy a fájlokat amit a Hydra töltött le",
|
||||
"download_in_progress": "Letöltés folyamatban",
|
||||
"download_paused": "Letöltés szüneteltetve",
|
||||
"last_downloaded_option": "Utoljára letöltött",
|
||||
"create_steam_shortcut": "Steam parancsikon létrehozása",
|
||||
"create_shortcut_success": "A parancsikon létrehozása sikeres",
|
||||
"you_might_need_to_restart_steam": "Lehetséges hogy újrakell indítsd a Steamet hogy lásd a változást.",
|
||||
"create_shortcut_error": "Hiba lépett fel létrehozás közben",
|
||||
"nsfw_content_title": "Ez a játék nem megfelelő tartalmat tartalmaz.",
|
||||
"nsfw_content_description": "{{title}} tartalmaz tartalmat amely nem megfelelő minden korosztálynak. Biztosan folytatni szeretnéd?",
|
||||
"allow_nsfw_content": "Folytatás",
|
||||
"refuse_nsfw_content": "Vissza",
|
||||
"stats": "Statisztikák",
|
||||
"download_count": "Letöltések",
|
||||
"player_count": "Aktív játékosok",
|
||||
"download_error": "Ez a letöltési opció nem elérhető",
|
||||
"download": "Letöltés",
|
||||
"executable_path_in_use": "Ez a futtatható fájl már használatban van a(z) \"{{game}}\" által",
|
||||
"warning": "Figyelmeztetés:",
|
||||
"hydra_needs_to_remain_open": "ehhez a letöltéshez, a Hydrának muszáj nyitva maradnia hogy letöltődjön. Ha a Hydra bezáródik letöltés előtt, a letöltés elveszik.",
|
||||
"achievements": "Achievementek",
|
||||
"achievements_count": "Achievementek {{unlockedCount}}/{{achievementsCount}}",
|
||||
"cloud_save": "Mentés felhőben",
|
||||
"cloud_save_description": "Mentsd el az előrehaladásod a felhőben, majd folytasd egy másik eszközön",
|
||||
"backups": "Biztonsági másolatok",
|
||||
"install_backup": "Telepít",
|
||||
"delete_backup": "Töröl",
|
||||
"create_backup": "Biztonsági másolat létrehozása",
|
||||
"last_backup_date": "Utolsó biztonsági mentés {{date}}",
|
||||
"no_backup_preview": "Ehhez a címhez nem található mentett játék",
|
||||
"restoring_backup": "Biztonsági mentés helyreállítás: ({{progress}} kész)…",
|
||||
"uploading_backup": "Biztonsági mentés feltöltése…",
|
||||
"no_backups": "Még nem hoztál létre biztonsági másolatot ehhez a játékhoz",
|
||||
"backup_uploaded": "Biztonsági mentés feltöltve",
|
||||
"backup_deleted": "Biztonsági mentés törölve",
|
||||
"backup_restored": "Biztonsági mentés helyreállítva",
|
||||
"see_all_achievements": "Achievementlista megtekintése",
|
||||
"sign_in_to_see_achievements": "Jelentkezz be hogy lásd az achievementjeid",
|
||||
"mapping_method_automatic": "Automatikus",
|
||||
"mapping_method_manual": "Kézi",
|
||||
"mapping_method_label": "Térképezési módszer",
|
||||
"files_automatically_mapped": "Fájlok automatikusan térképezve",
|
||||
"no_backups_created": "Ehhez a játékhoz nincs biztonsági másolat létrehozva",
|
||||
"manage_files": "Fájlok kezelése",
|
||||
"loading_save_preview": "Mentett játék keresése…",
|
||||
"wine_prefix": "Wine Prefix",
|
||||
"wine_prefix_description": "A Wine környezet, amiben a játék fut",
|
||||
"launch_options": "Indítási opciók",
|
||||
"launch_options_description": "Indítási opciók testreszabása haladó felhasználóknak (kísérleti funkció)",
|
||||
"launch_options_placeholder": "Nincs paraméter megadva",
|
||||
"no_download_option_info": "Nincs elérhető információ",
|
||||
"backup_deletion_failed": "Biztonsági mentés törlése sikertelen",
|
||||
"max_number_of_artifacts_reached": "A játék biztonsági mentéseinek száma elérte a határt",
|
||||
"achievements_not_sync": "Tekintsd meg hogyan kell szinkronizálni az achievementjeid",
|
||||
"manage_files_description": "Kezeld mely fájlokról készül biztonsági másolat, és melyek állíthatók vissza",
|
||||
"select_folder": "Mappa tallózása",
|
||||
"backup_from": "Biztonsági másolat: {{date}}",
|
||||
"automatic_backup_from": "Automatikus másolat: {{date}}",
|
||||
"enable_automatic_cloud_sync": "Automatikus felhőalapú szinkronizálás engedélyezése",
|
||||
"custom_backup_location_set": "Egyéni biztonsági mentési hely",
|
||||
"no_directory_selected": "Nincs mappa tallózva",
|
||||
"no_write_permission": "Nem lehet a mappába letölteni. Kattints ide további információért.",
|
||||
"reset_achievements": "Achievementek nullázása",
|
||||
"reset_achievements_description": "Ez az összes achievementet nullázza a {{game}} játékhoz",
|
||||
"reset_achievements_title": "Biztos vagy ebben?",
|
||||
"reset_achievements_success": "Achievementek sikeresen nullázva",
|
||||
"reset_achievements_error": "Achievementek nullázása sikertelen",
|
||||
"download_error_gofile_quota_exceeded": "Túllépted a Gofile havi kvótáját. Kérlek, várd meg amíg a kvóta lejár.",
|
||||
"download_error_real_debrid_account_not_authorized": "A Real-Debrid fiókod nem jogosult új letöltésekre. Kérlek, ellenőrízd a fiókbeállításaidat, majd próbáld újra.",
|
||||
"download_error_not_cached_on_real_debrid": "Ez a letöltés nem érhető el a Real-Debridnél, és lekérdezni letöltési állapotot még nem lehet vele.",
|
||||
"update_playtime_title": "Játékidő frissítése",
|
||||
"update_playtime_description": "Manuálisan frissíteni a Játékidőt a {{game}} játékhoz",
|
||||
"update_playtime": "Játékidő frissítése",
|
||||
"update_playtime_success": "Játékidő sikeresen frissítve",
|
||||
"update_playtime_error": "A Játékidőnek nem sikerült frissülnie",
|
||||
"update_game_playtime": "Játékidő frissítése",
|
||||
"manual_playtime_warning": "Az óráid 'manuálisan frissítve' lesznek megjelölve, és ez nem visszavonható.",
|
||||
"manual_playtime_tooltip": "Ez a játékidő manuálisan lett frissítve",
|
||||
"download_error_not_cached_on_torbox": "This download is not available on TorBox and polling download status from TorBox is not yet available.",
|
||||
"download_error_not_cached_on_hydra": "This download is not available on Nimbus.",
|
||||
"game_removed_from_favorites": "Játék eltávolítva a kedvencek közül",
|
||||
"game_added_to_favorites": "Játék hozzáadva a kedvencekhez",
|
||||
"game_removed_from_pinned": "Játék eltávolítva a kitűzöttek közül",
|
||||
"game_added_to_pinned": "Játék sikeresen kitűzve",
|
||||
"automatically_extract_downloaded_files": "Automatikus kibontása a letöltött fájloknak",
|
||||
"create_start_menu_shortcut": "Start menü parancsikon létrehozása",
|
||||
"invalid_wine_prefix_path": "Érvénytelen Wine prefix elérési útvonal",
|
||||
"invalid_wine_prefix_path_description": "Az út a Wine prefixhez érvénytelen. Ellenőrízd az elérési utat, majd próbáld újra.",
|
||||
"missing_wine_prefix": "Wine prefix szükséges a biztonsági másolat létrehozásához Linux rendszeren.",
|
||||
"artifact_renamed": "Biztonsági mentés sikeresen átnevezve",
|
||||
"rename_artifact": "Biztonsági mentés átnevezése",
|
||||
"rename_artifact_description": "Nevezd át a biztonsági másolatot egy leíróbb névre",
|
||||
"artifact_name_label": "Biztonsági másolat neve",
|
||||
"artifact_name_placeholder": "Adj egy nevet a biztonsági mentésnek",
|
||||
"save_changes": "Változtatások mentése",
|
||||
"required_field": "Ez a mező kötelező",
|
||||
"max_length_field": "Ez a mező kevesebb karakter kell legyen mint {{length}}",
|
||||
"freeze_backup": "Rögzítsd, hogy az automatikus biztonsági mentések ne írják felül",
|
||||
"unfreeze_backup": "Leválaszt",
|
||||
"backup_frozen": "Biztonsági mentés rögzítve",
|
||||
"backup_unfrozen": "Biztonsági mentés leválasztva",
|
||||
"backup_freeze_failed": "Biztonsági mentés rögzítése sikertelen",
|
||||
"backup_freeze_failed_description": "Legalább egy szabad helyet kell hagyni az automatikus biztonsági mentéseknek.",
|
||||
"edit_game_modal_button": "Játékadatok testreszabása",
|
||||
"game_details": "Játék leírása",
|
||||
"currency_symbol": "Ft",
|
||||
"currency_country": "hu",
|
||||
"prices": "Árak",
|
||||
"no_prices_found": "Nincsenek található árak",
|
||||
"view_all_prices": "Összes ár megtekintése",
|
||||
"retail_price": "Bolti ár",
|
||||
"keyshop_price": "Nem hivatalos ár",
|
||||
"historical_retail": "Korábbi bolti ár",
|
||||
"historical_keyshop": "Korábbi nem hivatalos ár",
|
||||
"language": "Nyelv",
|
||||
"caption": "Felirat",
|
||||
"audio": "Hang"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Hydra Aktiválása",
|
||||
"installation_id": "Telepítési ID:",
|
||||
"enter_activation_code": "Add meg az aktiválási kódodat",
|
||||
"message": "Ha nem tudod, hol kérdezd meg ezt, akkor nem is kellene, hogy legyen ilyened.",
|
||||
"title": "Hydra aktiválása",
|
||||
"installation_id": "Telepítési azonosító:",
|
||||
"enter_activation_code": "Írd be az aktiválási kódod",
|
||||
"message": "Ha nem tudod kit kérdezz efelől, akkor nem kéne nálad legyen.",
|
||||
"activate": "Aktiválás",
|
||||
"loading": "Betöltés…"
|
||||
"loading": "Töltés…"
|
||||
},
|
||||
"downloads": {
|
||||
"resume": "Folytatás",
|
||||
@@ -86,46 +303,325 @@
|
||||
"paused": "Szüneteltetve",
|
||||
"verifying": "Ellenőrzés…",
|
||||
"completed": "Befejezve",
|
||||
"removed": "Nincs letöltve",
|
||||
"cancel": "Mégse",
|
||||
"filter": "Letöltött játékok szűrése",
|
||||
"remove": "Eltávolítás",
|
||||
"remove": "Eltávolít",
|
||||
"downloading_metadata": "Metaadatok letöltése…",
|
||||
"deleting": "Telepítő törlése…",
|
||||
"delete": "Telepítő eltávolítása",
|
||||
"delete_modal_title": "Biztos vagy benne?",
|
||||
"delete_modal_description": "Ez eltávolít minden telepítési fájlt a számítógépedről",
|
||||
"install": "Telepítés"
|
||||
"delete_modal_title": "Biztos vagy ebben?",
|
||||
"delete_modal_description": "Ez eltávolítja a telepítési fájlokat a számítógépedről",
|
||||
"install": "Telepít",
|
||||
"download_in_progress": "Folyamatban lévő",
|
||||
"queued_downloads": "Várakozósoron lévő letöltések",
|
||||
"downloads_completed": "Befejezett",
|
||||
"queued": "Várakozási sorban",
|
||||
"no_downloads_title": "Oly üres..",
|
||||
"no_downloads_description": "Még nem töltöttél le semmit a Hydra segítségével, de soha nem késő elkezdeni.",
|
||||
"checking_files": "Fájlok ellenőrzése…",
|
||||
"seeding": "Seedelés",
|
||||
"stop_seeding": "Seedelés leállítása",
|
||||
"resume_seeding": "Seedelés folytatása",
|
||||
"options": "Kezelés",
|
||||
"extract": "Fájlok kibontása",
|
||||
"extracting": "Fájlok kibontása…"
|
||||
},
|
||||
"settings": {
|
||||
"downloads_path": "Letöltések helye",
|
||||
"downloads_path": "Letöltési útvonalak",
|
||||
"change": "Frissítés",
|
||||
"notifications": "Értesítések",
|
||||
"enable_download_notifications": "Amikor egy letöltés befejeződik",
|
||||
"enable_repack_list_notifications": "Amikor új repack kerül feltöltésre",
|
||||
"real_debrid_api_token_label": "Real-Debrid API token",
|
||||
"quit_app_instead_hiding": "Hydra elrejtésének tiltása bezáráskor",
|
||||
"launch_with_system": "Hydra automatikus indítása rendszer indításakor",
|
||||
"general": "Általános",
|
||||
"behavior": "Működés",
|
||||
"download_sources": "Letöltési források",
|
||||
"language": "Nyelv",
|
||||
"api_token": "API Token",
|
||||
"enable_real_debrid": "Real-Debrid Bekapcsolása",
|
||||
"real_debrid_description": "A Real-Debrid egy korlátozásmentes letöltőprogram, lehetővé teszi a fájlok gyors letöltését, és csak az internetkapcsolat sebessége szab határt.",
|
||||
"debrid_invalid_token": "Érvénytelen API token",
|
||||
"debrid_api_token_hint": "Az API tokened <0>itt</0> található",
|
||||
"real_debrid_free_account_error": "Ez a fiók: \"{{username}}\" egy ingyenes fiók. Kérlek iratkozz fel a Real-Debrid-re",
|
||||
"debrid_linked_message": "Fiók összekapcsolva: \"{{username}}\" ",
|
||||
"save_changes": "Változtatások mentése",
|
||||
"changes_saved": "Változtatások sikeresen mentve",
|
||||
"download_sources_description": "A Hydra lefogja tölteni a letöltési linkeket a forrásokból. Az URL forrásnak közvetlen linknek kell lennie egy .json fájlhoz, ami tartalmazza a linkeket.",
|
||||
"validate_download_source": "Érvényesítés",
|
||||
"remove_download_source": "Eltávolítás",
|
||||
"add_download_source": "Forrás hozáadása",
|
||||
"download_count_zero": "Nincs letöltési opció",
|
||||
"download_count_one": "{{countFormatted}} letöltési opció",
|
||||
"download_count_other": "{{countFormatted}} letöltési opció",
|
||||
"download_source_url": "URL forrás:",
|
||||
"add_download_source_description": "Helyezd be a .json fájl URL-jét",
|
||||
"download_source_up_to_date": "Naprakész",
|
||||
"download_source_errored": "Hiba történt",
|
||||
"sync_download_sources": "Források szinkronizálása",
|
||||
"removed_download_source": "Letöltési forrás eltávolítva",
|
||||
"removed_download_sources": "Letöltési források eltávolítva",
|
||||
"cancel_button_confirmation_delete_all_sources": "Nem",
|
||||
"confirm_button_confirmation_delete_all_sources": "Igen, törölj mindent",
|
||||
"description_confirmation_delete_all_sources": "Törölni fog minden letöltési forrást",
|
||||
"title_confirmation_delete_all_sources": "Törölje az összes letöltési forrást",
|
||||
"removed_download_sources": "Betűtípusok eltávolítva",
|
||||
"button_delete_all_sources": "Távolítsa el az összes letöltési forrást",
|
||||
"enable_repack_list_notifications": "Amikor egy új repack hozzáadásra kerül"
|
||||
"title_confirmation_delete_all_sources": "Az összes letöltési forrás törlése",
|
||||
"description_confirmation_delete_all_sources": "Az összes letöltési forrást törölni fogod ezáltal",
|
||||
"button_delete_all_sources": "Összes eltávolítása",
|
||||
"added_download_source": "Letöltési forrás hozzáadva",
|
||||
"download_sources_synced": "Az összes letöltési forrás szinkronizálva",
|
||||
"insert_valid_json_url": "Adj meg egy érvényes JSON url-t",
|
||||
"found_download_option_zero": "Nincs letöltési opció",
|
||||
"found_download_option_one": "{{countFormatted}} Letöltési opció találva",
|
||||
"found_download_option_other": "{{countFormatted}} Letöltési opciók találva",
|
||||
"import": "Importálás",
|
||||
"public": "Publikus",
|
||||
"private": "Privát",
|
||||
"friends_only": "Csak barátok",
|
||||
"privacy": "Adatvédelem",
|
||||
"profile_visibility": "Profil láthatósága",
|
||||
"profile_visibility_description": "Válaszd ki, ki láthatja a profilod és könyvtárad",
|
||||
"required_field": "Ez a mező kötelező",
|
||||
"source_already_exists": "Ez a forrás már használatban",
|
||||
"must_be_valid_url": "A forrás egy érvényes URL kell legyen",
|
||||
"blocked_users": "Letiltott felhasználók",
|
||||
"user_unblocked": "Felhasználó letiltva",
|
||||
"enable_achievement_notifications": "Amikor egy achievement feloldódik",
|
||||
"launch_minimized": "Hydra indítása minimalizálva",
|
||||
"disable_nsfw_alert": "NSFW figyelmeztetés kikapcsolása",
|
||||
"seed_after_download_complete": "Letöltés utáni seedelés",
|
||||
"show_hidden_achievement_description": "Rejtett achievementek leírásának megjelenítése feloldás előtt",
|
||||
"account": "Fiók",
|
||||
"no_users_blocked": "Nincsenek letiltott felhasználóid",
|
||||
"subscription_active_until": "Hydra Cloud előfizetésed aktív, eddig: {{date}}",
|
||||
"manage_subscription": "Előfizetés kezelése",
|
||||
"update_email": "Email változtatása",
|
||||
"update_password": "Jelszó változtatása",
|
||||
"current_email": "Jelenlegi email:",
|
||||
"no_email_account": "Még nincs beállított emailed",
|
||||
"account_data_updated_successfully": "Fiókadatok változtatása sikeres",
|
||||
"renew_subscription": "Hydra Cloud Megújítása",
|
||||
"subscription_expired_at": "Az előfizetésed lejárt, ekkor: {{date}}",
|
||||
"no_subscription": "Élvezd a Hydrát a lehető legjobb módon",
|
||||
"become_subscriber": "Légy Hydra Cloud tag",
|
||||
"subscription_renew_cancelled": "Automatikus megújítás kikapcsolva",
|
||||
"subscription_renews_on": "Az előfizetésed megújul, ekkor: {{date}}",
|
||||
"bill_sent_until": "A következő számlát ezen napon küldjük",
|
||||
"no_themes": "Úgy látom nincs egyetlen témád sem még, de ne aggódj, kattints ide hogy elkészítsd a remekművedet.",
|
||||
"editor_tab_code": "Code",
|
||||
"editor_tab_info": "Info",
|
||||
"editor_tab_save": "Mentés",
|
||||
"web_store": "Webáruház",
|
||||
"clear_themes": "Törlés",
|
||||
"create_theme": "Létrehozás",
|
||||
"create_theme_modal_title": "Egyéni téma létrehozása",
|
||||
"create_theme_modal_description": "Hozz létre egy új témát, hogy testreszabhasd a Hydrát ahogy szeretnéd",
|
||||
"theme_name": "Téma neve",
|
||||
"insert_theme_name": "Adj a témádnak nevet",
|
||||
"set_theme": "Téma beállítása",
|
||||
"unset_theme": "Téma visszavonása",
|
||||
"delete_theme": "Téma törlése",
|
||||
"edit_theme": "Téma szerkesztése",
|
||||
"delete_all_themes": "Összes téma törlése",
|
||||
"delete_all_themes_description": "Ez törölni fogja az összes témádat",
|
||||
"delete_theme_description": "Ez törölni fogja a(z) {{theme}} témát",
|
||||
"cancel": "Mégsem",
|
||||
"appearance": "Megjelenés",
|
||||
"enable_torbox": "TorBox bekapcsolása",
|
||||
"torbox_description": "A TorBox egy olyan premium seedbox szolgáltatás, amely még a piacon elérhető legjobb szerverekkel is felveszi a versenyt.",
|
||||
"torbox_account_linked": "TorBox fiók összekapcsolva",
|
||||
"create_real_debrid_account": "Kattints ide ha még nincs Real-Debrid fiókod",
|
||||
"create_torbox_account": "Kattints ide ha még nincs TorBox fiókod",
|
||||
"real_debrid_account_linked": "Real-Debrid fiók összekapcsolva",
|
||||
"name_min_length": "A téma neve legalább 3 karakter hosszú legyen",
|
||||
"import_theme": "Téma importálása",
|
||||
"import_theme_description": "Ezt a témát fogod importálni a Témaáruház-ból: {{theme}}",
|
||||
"error_importing_theme": "Hiba lépett fel a téma importálása közben",
|
||||
"theme_imported": "Téma sikeresen importálva",
|
||||
"enable_friend_request_notifications": "Amikor ismerősnek jelölnek",
|
||||
"enable_auto_install": "Frissítések letöltése automatikusan",
|
||||
"common_redist": "Alapvető Segédprogramok",
|
||||
"common_redist_description": "Egyes játékok futtatásához alapvető segédprogram fájlok szükségesek. A problémák elkerülése képpen ajánlott telepíteni őket.",
|
||||
"install_common_redist": "Telepítés",
|
||||
"installing_common_redist": "Telepítés alatt…",
|
||||
"show_download_speed_in_megabytes": "Letöltési sebesség megabájt/másodpercben lévő megjelenítése",
|
||||
"extract_files_by_default": "Fájlok kicsomagolása letöltés után",
|
||||
"enable_steam_achievements": "Steam-achievementek utáni keresés engedélyezése",
|
||||
"achievement_custom_notification_position": "Achievement-értesítések egyéni elhelyezése",
|
||||
"top-left": "Bal felső sarok",
|
||||
"top-center": "Felső közép",
|
||||
"top-right": "Jobb felső sarok",
|
||||
"bottom-left": "Bal alsó sarok",
|
||||
"bottom-center": "Alsó közép",
|
||||
"bottom-right": "Jobb alsó sarok",
|
||||
"enable_achievement_custom_notifications": "Egyéni achievement-értesítések bekapcsolása",
|
||||
"alignment": "Igazítás",
|
||||
"variation": "Variáció",
|
||||
"default": "Alapértelmezett",
|
||||
"rare": "Ritka",
|
||||
"platinum": "Platinum",
|
||||
"hidden": "Rejtett",
|
||||
"test_notification": "Értesítés tesztelése",
|
||||
"notification_preview": "Achievement Értesítés Előnézete",
|
||||
"enable_friend_start_game_notifications": "Amikor egy barátod elkezd játszani egy játékot"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Letöltés befejeződött",
|
||||
"game_ready_to_install": "{{title}} telepítésre kész",
|
||||
"download_complete": "Letöltés befejezve",
|
||||
"game_ready_to_install": "A(z) {{title}} telepítésre kész",
|
||||
"repack_list_updated": "Repack lista frissítve",
|
||||
"repack_count_one": "{{count}} repack hozzáadva",
|
||||
"repack_count_other": "{{count}} repack hozzáadva"
|
||||
"repack_count_other": "{{count}} repack hozzáadva",
|
||||
"new_update_available": "A(z) {{version}} verzió elérhető",
|
||||
"restart_to_install_update": "Indítsd újra a Hydrát a frissítés telepítéséhez",
|
||||
"notification_achievement_unlocked_title": "Achievement feloldva: {{game}}",
|
||||
"notification_achievement_unlocked_body": "{{achievement}} és további {{count}} feloldva",
|
||||
"new_friend_request_description": "{{displayName}} küldött neked egy barátfelkérést",
|
||||
"new_friend_request_title": "Új barátfelkérés",
|
||||
"extraction_complete": "Kicsomagolás befejezve",
|
||||
"game_extracted": "{{title}} sikeresen kicsomagolva",
|
||||
"friend_started_playing_game": "{{displayName}} játszani kezdett",
|
||||
"test_achievement_notification_title": "Ez egy teszt értesítés",
|
||||
"test_achievement_notification_description": "Elég menő, mi?"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Hydra megnyitása",
|
||||
"quit": "Kilépés"
|
||||
},
|
||||
"game_card": {
|
||||
"available_one": "Elérhető",
|
||||
"available_other": "Elérhető",
|
||||
"no_downloads": "Nincs elérhető letöltés"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "A programok nincsenek telepítve",
|
||||
"description": "A Wine vagy a Lutris végrehajtható fájljai nem találhatók a rendszereden",
|
||||
"instructions": "Ellenőrizd a megfelelő telepítési módot bármelyiküknek a Linux disztribúciódon, hogy a játék normálisan fusson"
|
||||
"description": "Wine vagy Lutris futtatható fájlok nem találhatók a rendszereden",
|
||||
"instructions": "Ellenőrízd, hogy melyiket kell helyesen telepíteni a Linux disztribúcióra, hogy a játék normálisan fusson"
|
||||
},
|
||||
"modal": {
|
||||
"close": "Bezárás gomb"
|
||||
},
|
||||
"forms": {
|
||||
"toggle_password_visibility": "Jelszó láthatóságának állítása"
|
||||
},
|
||||
"user_profile": {
|
||||
"amount_hours": "{{amount}} óra",
|
||||
"amount_minutes": "{{amount}} perc",
|
||||
"amount_hours_short": "{{amount}}ó",
|
||||
"amount_minutes_short": "{{amount}}p",
|
||||
"last_time_played": "Utoljára játszva {{period}}",
|
||||
"activity": "Legutóbbi tevékenység",
|
||||
"library": "Könyvtár",
|
||||
"pinned": "Kitűzve",
|
||||
"achievements_earned": "Elért achievementek",
|
||||
"played_recently": "Nemrég játszva",
|
||||
"playtime": "Játszottidő",
|
||||
"total_play_time": "Teljes játszottidő",
|
||||
"manual_playtime_tooltip": "Ez a játszottidő manuálisan lett frissítve",
|
||||
"no_recent_activity_title": "Hmmm… itt semmi sincs",
|
||||
"no_recent_activity_description": "Mostanában nem játszottál semmivel. Hát ideje ezt megváltoztatni!",
|
||||
"display_name": "Profilnév",
|
||||
"saving": "Mentésben",
|
||||
"save": "Mentés",
|
||||
"edit_profile": "Profil Szerkesztése",
|
||||
"saved_successfully": "Sikeresen elmentve",
|
||||
"try_again": "Kérlek, próbálkozz újra",
|
||||
"sign_out_modal_title": "Biztos vagy ebben?",
|
||||
"cancel": "Mégsem",
|
||||
"successfully_signed_out": "Sikeresen kijelentkezve",
|
||||
"sign_out": "Kijelentkezés",
|
||||
"playing_for": "Játékban: {{amount}}",
|
||||
"sign_out_modal_text": "A könyvtár a jelenlegi fiókodhoz van csatolva. Kijelentkezéskor a könyvtár többé nem lesz látható, és az eddigi előrehaladás nem lesz mentve. Folytatod a kijelentkezést?",
|
||||
"add_friends": "Barát bejelölés",
|
||||
"add": "Elküld",
|
||||
"friend_code": "Barát kód",
|
||||
"see_profile": "Profil megtekintése",
|
||||
"sending": "Küldés..",
|
||||
"friend_request_sent": "Barátfelkérés elküldve",
|
||||
"friends": "Barátok",
|
||||
"friends_list": "Barát lista",
|
||||
"user_not_found": "Felhasználó nem találva",
|
||||
"block_user": "Felhasználó letiltása",
|
||||
"add_friend": "Barát bejelölése",
|
||||
"request_sent": "Kérés elküldve",
|
||||
"request_received": "Barátfelkérést kaptál",
|
||||
"accept_request": "Kérés elfogadása",
|
||||
"ignore_request": "Kérés ignorálása",
|
||||
"cancel_request": "Kérés visszavonása",
|
||||
"undo_friendship": "Barát eltávolítása",
|
||||
"request_accepted": "Barátfelkérés elfogadva",
|
||||
"user_blocked_successfully": "Felhasználó sikeresen letiltva",
|
||||
"user_block_modal_text": "Ez által letiltod őt: {{displayName}}",
|
||||
"blocked_users": "Letiltott felhasználók",
|
||||
"unblock": "Tiltás feloldása",
|
||||
"no_friends_added": "Nincs bejelölt barátod",
|
||||
"pending": "Függőben",
|
||||
"no_pending_invites": "Nincs függőben lévő barátfelkérésed",
|
||||
"no_blocked_users": "Nincs letiltott felhasználó",
|
||||
"friend_code_copied": "Barát kód kimásolva",
|
||||
"undo_friendship_modal_text": "Ezáltal megszünteted a barátságod vele: {{displayName}}",
|
||||
"privacy_hint": "Hogy beállítsd ki láthassa ezt, menj a <0>Beállítások</0> menüjébe",
|
||||
"locked_profile": "Ez a profil privát",
|
||||
"image_process_failure": "Hiba a kép feldolgozása közben",
|
||||
"required_field": "Ez a mező kötelező",
|
||||
"displayname_min_length": "A megjelenített névnek legalább 3 karakter hosszúnak kell lennie",
|
||||
"displayname_max_length": "A megjelenített név hossza legfeljebb 50 karakter lehet",
|
||||
"report_profile": "Profil bejelentése",
|
||||
"report_reason": "Miért jelented ezt a profilt?",
|
||||
"report_description": "További információ",
|
||||
"report_description_placeholder": "További információ",
|
||||
"report": "Bejelentés",
|
||||
"report_reason_hate": "Gyűlöletbeszéd",
|
||||
"report_reason_sexual_content": "Szexuális tartalom",
|
||||
"report_reason_violence": "Fenyegető",
|
||||
"report_reason_spam": "Spam",
|
||||
"report_reason_other": "Egyéb",
|
||||
"profile_reported": "Profil jelentve",
|
||||
"your_friend_code": "A barát kódod:",
|
||||
"upload_banner": "Borítókép feltöltés",
|
||||
"uploading_banner": "Borítókép feltöltése…",
|
||||
"background_image_updated": "Borítókép frissítve",
|
||||
"stats": "Statisztikák",
|
||||
"achievements": "achievementek",
|
||||
"games": "Játékok",
|
||||
"top_percentile": "Top {{percentile}}%",
|
||||
"ranking_updated_weekly": "A rangsor hetente frissül.",
|
||||
"playing": "Játékban: {{game}}",
|
||||
"achievements_unlocked": "Achievementek feloldva",
|
||||
"earned_points": "Megszerzett pontok",
|
||||
"show_achievements_on_profile": "Mutasd az achievementjeid a profilodon",
|
||||
"show_points_on_profile": "Mutasd a megszerzett pontjaid a profilodon",
|
||||
"error_adding_friend": "Hiba, barátfelkérés sikertelen. Kérlek ellenőrízd a barát kódot",
|
||||
"friend_code_length_error": "A barát kódnak 8 karakterből kell állnia",
|
||||
"game_removed_from_pinned": "Játék eltávolítva a kitűzöttek közül",
|
||||
"game_added_to_pinned": "Játék hozzáadva a kitűzöttekhez"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Achievement feloldva",
|
||||
"user_achievements": "{{displayName}} Achievementjei",
|
||||
"your_achievements": "A te Achievementjeid",
|
||||
"unlocked_at": "Feloldva ekkor: {{date}}",
|
||||
"subscription_needed": "A tartalom megtekintéséhez Hydra Cloud előfizetés szükséges",
|
||||
"new_achievements_unlocked": "{{achievementCount}} új achievementet oldottál fel {{gameCount}} játékban",
|
||||
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievementek",
|
||||
"achievements_unlocked_for_game": "{{achievementCount}} új achievementet oldottál fel a(z) {{gameTitle}} játékban",
|
||||
"hidden_achievement_tooltip": "Ez egy rejtett achievement",
|
||||
"achievement_earn_points": "Szerezz be {{points}} pontot ezzel az achievement-el",
|
||||
"earned_points": "Megszerzett pontok:",
|
||||
"available_points": "Elérhető pontok:",
|
||||
"how_to_earn_achievements_points": "Hogy lehet elérni achievement pontokat?"
|
||||
},
|
||||
"hydra_cloud": {
|
||||
"subscription_tour_title": "Hydra Cloud Előfizetés",
|
||||
"subscribe_now": "Előfizetés",
|
||||
"cloud_saving": "Felhőmentés",
|
||||
"cloud_achievements": "Mentsd az achievementjeid el a felhőben",
|
||||
"animated_profile_picture": "Animált profilkép",
|
||||
"premium_support": "Premium Támogatás",
|
||||
"show_and_compare_achievements": "Jelenítsd és hasonlítsd az elért achievementjeid másokéhoz",
|
||||
"animated_profile_banner": "Animált profil borítókép",
|
||||
"hydra_cloud": "Hydra Cloud",
|
||||
"hydra_cloud_feature_found": "Épp felfedeztél egy Hydra Cloud funkciót!",
|
||||
"learn_more": "Tudj meg többet",
|
||||
"debrid_description": "Akár 4x gyorsabb letöltés a Nimbusszal"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@
|
||||
"open_download_location": "Lihat file yang diunduh",
|
||||
"create_shortcut": "Buat pintasan desktop",
|
||||
"remove_files": "Hapus file",
|
||||
"remove_from_library_title": "Apa kamu yakin?",
|
||||
"remove_from_library_description": "Ini akan menghapus {{game}} dari perpustakaan kamu",
|
||||
"options": "Opsi",
|
||||
"executable_section_title": "Eksekusi",
|
||||
|
||||
@@ -120,8 +120,10 @@
|
||||
"open_folder": "Abrir pasta",
|
||||
"open_download_location": "Ver arquivos baixados",
|
||||
"create_shortcut": "Criar atalho na área de trabalho",
|
||||
"create_shortcut_simple": "Criar atalho",
|
||||
"remove_files": "Remover arquivos",
|
||||
"options": "Gerenciar",
|
||||
"properties": "Propriedades",
|
||||
"remove_from_library_description": "Isso irá remover {{game}} da sua biblioteca",
|
||||
"remove_from_library_title": "Tem certeza?",
|
||||
"executable_section_title": "Executável",
|
||||
@@ -204,6 +206,13 @@
|
||||
"download_error_not_cached_on_hydra": "Este download não está disponível no Nimbus.",
|
||||
"game_removed_from_favorites": "Jogo removido dos favoritos",
|
||||
"game_added_to_favorites": "Jogo adicionado aos favoritos",
|
||||
"add_to_favorites": "Adicionar aos favoritos",
|
||||
"remove_from_favorites": "Remover dos favoritos",
|
||||
"failed_update_favorites": "Falha ao atualizar favoritos",
|
||||
"game_removed_from_library": "Jogo removido da biblioteca",
|
||||
"failed_remove_from_library": "Falha ao remover da biblioteca",
|
||||
"files_removed_success": "Arquivos removidos com sucesso",
|
||||
"failed_remove_files": "Falha ao remover arquivos",
|
||||
"automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados",
|
||||
"create_start_menu_shortcut": "Criar atalho no Menu Iniciar",
|
||||
"invalid_wine_prefix_path": "Caminho do prefixo Wine inválido",
|
||||
@@ -235,6 +244,8 @@
|
||||
"language": "Idioma",
|
||||
"caption": "Legenda",
|
||||
"audio": "Áudio",
|
||||
"filter_by_source": "Filtrar por fonte",
|
||||
"no_repacks_found": "Nenhuma fonte encontrada para este jogo",
|
||||
"edit_game_modal_button": "Alterar detalhes do jogo",
|
||||
"game_added_to_pinned": "Jogo adicionado aos fixados",
|
||||
"game_removed_from_pinned": "Jogo removido dos fixados",
|
||||
@@ -555,7 +566,7 @@
|
||||
"playtime": "Tempo de jogo",
|
||||
"played_recently": "Jogado recentemente",
|
||||
"pinned": "Fixado",
|
||||
"amount_minutes_short": "{{amount}}h",
|
||||
"amount_minutes_short": "{{amount}}m",
|
||||
"amount_hours_short": "{{amount}}h",
|
||||
"game_added_to_pinned": "Jogo adicionado aos fixados",
|
||||
"achievements_earned": "Conquistas recebidas"
|
||||
|
||||
@@ -142,6 +142,7 @@
|
||||
"uploading_backup": "A criar backup…",
|
||||
"no_backups": "Ainda não fizeste nenhum backup deste jogo",
|
||||
"backup_uploaded": "Backup criado",
|
||||
"backup_failed": "Falha ao criar backup",
|
||||
"backup_deleted": "Backup apagado",
|
||||
"backup_restored": "Backup restaurado",
|
||||
"see_all_achievements": "Ver todas as conquistas",
|
||||
|
||||
@@ -135,7 +135,11 @@
|
||||
"real_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la Real-Debrid",
|
||||
"debrid_linked_message": "Contul \"{{username}}\" a fost legat",
|
||||
"save_changes": "Salvează modificările",
|
||||
"changes_saved": "Modificările au fost salvate cu succes"
|
||||
"changes_saved": "Modificările au fost salvate cu succes",
|
||||
"enable_all_debrid": "Activează All-Debrid",
|
||||
"all_debrid_description": "All-Debrid este un descărcător fără restricții care îți permite să descarci fișiere din diverse surse.",
|
||||
"all_debrid_free_account_error": "Contul \"{{username}}\" este un cont gratuit. Te rugăm să te abonezi la All-Debrid",
|
||||
"all_debrid_account_linked": "Contul All-Debrid a fost conectat cu succes"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Descărcare completă",
|
||||
|
||||
@@ -67,7 +67,14 @@
|
||||
"edit_game_modal_image_filter": "Изображение",
|
||||
"edit_game_modal_icon_resolution": "Рекомендуемое разрешение: 256x256px",
|
||||
"edit_game_modal_logo_resolution": "Рекомендуемое разрешение: 640x360px",
|
||||
"edit_game_modal_hero_resolution": "Рекомендуемое разрешение: 1920x620px"
|
||||
"edit_game_modal_hero_resolution": "Рекомендуемое разрешение: 1920x620px",
|
||||
"edit_game_modal_assets": "Ресурсы",
|
||||
"edit_game_modal_drop_icon_image_here": "Перетащите изображение иконки сюда",
|
||||
"edit_game_modal_drop_logo_image_here": "Перетащите изображение логотипа сюда",
|
||||
"edit_game_modal_drop_hero_image_here": "Перетащите изображение обложки сюда",
|
||||
"edit_game_modal_drop_to_replace_icon": "Перетащите для замены иконки",
|
||||
"edit_game_modal_drop_to_replace_logo": "Перетащите для замены логотипа",
|
||||
"edit_game_modal_drop_to_replace_hero": "Перетащите для замены обложки"
|
||||
},
|
||||
"header": {
|
||||
"search": "Поиск",
|
||||
@@ -182,10 +189,14 @@
|
||||
"refuse_nsfw_content": "Назад",
|
||||
"stats": "Статистика",
|
||||
"player_count": "Активные игроки",
|
||||
"rating_count": "Рейтинг",
|
||||
"warning": "Внимание:",
|
||||
"hydra_needs_to_remain_open": "Для этой загрузки Hydra должна оставаться открытой до завершения. Если Hydra закроется до завершения, вы потеряете прогресс.",
|
||||
"achievements": "Достижения",
|
||||
"achievements_count": "Достижения {{unlockedCount}}/{{achievementsCount}}",
|
||||
"show_more": "Показать больше",
|
||||
"reviews": "Отзывы",
|
||||
"leave_a_review": "Оставить отзыв",
|
||||
"cloud_save": "Облачное сохранение",
|
||||
"cloud_save_description": "Сохраняйте ваш прогресс в облаке и продолжайте играть на любом устройстве",
|
||||
"backups": "Резервные копии",
|
||||
@@ -264,7 +275,41 @@
|
||||
"backup_unfrozen": "Резервная копия откреплена",
|
||||
"backup_freeze_failed": "Не удалось закрепить резервную копию",
|
||||
"backup_freeze_failed_description": "Вы должны оставить как минимум один свободный слот для автоматических резервных копий",
|
||||
"manual_playtime_tooltip": "Это время игры было обновлено вручную"
|
||||
"manual_playtime_tooltip": "Это время игры было обновлено вручную",
|
||||
"write_review_placeholder": "Поделитесь своими мыслями об этой игре...",
|
||||
"sort_newest": "Новые",
|
||||
"no_reviews_yet": "Пока нет отзывов",
|
||||
"be_first_to_review": "Будьте первым, кто поделится своими мыслями об этой игре!",
|
||||
"sort_oldest": "Старые",
|
||||
"sort_highest_score": "Высший балл",
|
||||
"sort_lowest_score": "Низший балл",
|
||||
"sort_most_voted": "Самые популярные",
|
||||
"rating": "Рейтинг",
|
||||
"rating_stats": "Рейтинг",
|
||||
"rating_very_negative": "Очень негативный",
|
||||
"rating_negative": "Негативный",
|
||||
"rating_neutral": "Нейтральный",
|
||||
"rating_positive": "Позитивный",
|
||||
"rating_very_positive": "Очень позитивный",
|
||||
"submit_review": "Отправить отзыв",
|
||||
"submitting": "Отправка...",
|
||||
"remove_review": "Удалить отзыв",
|
||||
"delete_review_modal_title": "Вы уверены, что хотите удалить свой отзыв?",
|
||||
"delete_review_modal_description": "Это действие нельзя отменить.",
|
||||
"delete_review_modal_delete_button": "Удалить",
|
||||
"delete_review_modal_cancel_button": "Отмена",
|
||||
"review_submitted_successfully": "Отзыв успешно отправлен!",
|
||||
"review_submission_failed": "Не удалось отправить отзыв. Попробуйте еще раз.",
|
||||
"review_cannot_be_empty": "Поле отзыва не может быть пустым.",
|
||||
"review_deleted_successfully": "Отзыв успешно удален.",
|
||||
"review_deletion_failed": "Не удалось удалить отзыв. Попробуйте еще раз.",
|
||||
"loading_reviews": "Загрузка отзывов...",
|
||||
"loading_more_reviews": "Загрузка дополнительных отзывов...",
|
||||
"load_more_reviews": "Загрузить больше отзывов",
|
||||
"you_seemed_to_enjoy_this_game": "Похоже, вам понравилась эта игра",
|
||||
"would_you_recommend_this_game": "Хотели бы вы оставить отзыв об этой игре?",
|
||||
"yes": "Да",
|
||||
"maybe_later": "Может быть позже"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Активировать Hydra",
|
||||
@@ -468,7 +513,8 @@
|
||||
"game_card": {
|
||||
"available_one": "Доступный",
|
||||
"available_other": "Доступный",
|
||||
"no_downloads": "Нет доступных источников"
|
||||
"no_downloads": "Нет доступных источников",
|
||||
"calculating": "Вычисление"
|
||||
},
|
||||
"binary_not_found_modal": {
|
||||
"title": "Программы не установлены",
|
||||
@@ -565,7 +611,12 @@
|
||||
"show_achievements_on_profile": "Покажите свои достижения в профиле",
|
||||
"show_points_on_profile": "Показывать заработанные очки в своем профиле",
|
||||
"error_adding_friend": "Не удалось отправить запрос в друзья. Пожалуйста, проверьте код друга",
|
||||
"friend_code_length_error": "Код друга должен содержать 8 символов"
|
||||
"friend_code_length_error": "Код друга должен содержать 8 символов",
|
||||
"game_removed_from_pinned": "Игра удалена из закрепленных",
|
||||
"game_added_to_pinned": "Игра добавлена в закрепленные",
|
||||
"karma": "Карма",
|
||||
"karma_count": "карма",
|
||||
"karma_description": "Заработано от положительных лайков на отзывах"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Достижение разблокировано",
|
||||
|
||||
15
src/main/events/catalogue/check-game-review.ts
Normal file
15
src/main/events/catalogue/check-game-review.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const checkGameReview = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => {
|
||||
return HydraApi.get(`/games/${shop}/${objectId}/reviews/check`, null, {
|
||||
needsAuth: true,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("checkGameReview", checkGameReview);
|
||||
18
src/main/events/catalogue/create-game-review.ts
Normal file
18
src/main/events/catalogue/create-game-review.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const createGameReview = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
reviewHtml: string,
|
||||
score: number
|
||||
) => {
|
||||
return HydraApi.post(`/games/${shop}/${objectId}/reviews`, {
|
||||
reviewHtml,
|
||||
score,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("createGameReview", createGameReview);
|
||||
14
src/main/events/catalogue/delete-review.ts
Normal file
14
src/main/events/catalogue/delete-review.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const deleteReview = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
reviewId: string
|
||||
) => {
|
||||
return HydraApi.delete(`/games/${shop}/${objectId}/reviews/${reviewId}`);
|
||||
};
|
||||
|
||||
registerEvent("deleteReview", deleteReview);
|
||||
51
src/main/events/catalogue/get-game-assets.ts
Normal file
51
src/main/events/catalogue/get-game-assets.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { GameShop, ShopAssets } from "@types";
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
|
||||
|
||||
const LOCAL_CACHE_EXPIRATION = 1000 * 60 * 60 * 8; // 8 hours
|
||||
|
||||
export const getGameAssets = async (objectId: string, shop: GameShop) => {
|
||||
const cachedAssets = await gamesShopAssetsSublevel.get(
|
||||
levelKeys.game(shop, objectId)
|
||||
);
|
||||
|
||||
if (
|
||||
cachedAssets &&
|
||||
cachedAssets.updatedAt + LOCAL_CACHE_EXPIRATION > Date.now()
|
||||
) {
|
||||
return cachedAssets;
|
||||
}
|
||||
|
||||
return HydraApi.get<ShopAssets | null>(
|
||||
`/games/${shop}/${objectId}/assets`,
|
||||
null,
|
||||
{
|
||||
needsAuth: false,
|
||||
}
|
||||
).then(async (assets) => {
|
||||
if (!assets) return null;
|
||||
|
||||
// Preserve existing title if it differs from the incoming title (indicating it was customized)
|
||||
const shouldPreserveTitle =
|
||||
cachedAssets?.title && cachedAssets.title !== assets.title;
|
||||
|
||||
await gamesShopAssetsSublevel.put(levelKeys.game(shop, objectId), {
|
||||
...assets,
|
||||
title: shouldPreserveTitle ? cachedAssets.title : assets.title,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
return assets;
|
||||
});
|
||||
};
|
||||
|
||||
const getGameAssetsEvent = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => {
|
||||
return getGameAssets(objectId, shop);
|
||||
};
|
||||
|
||||
registerEvent("getGameAssets", getGameAssetsEvent);
|
||||
26
src/main/events/catalogue/get-game-reviews.ts
Normal file
26
src/main/events/catalogue/get-game-reviews.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const getGameReviews = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
take: number = 20,
|
||||
skip: number = 0,
|
||||
sortBy: string = "newest"
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
take: take.toString(),
|
||||
skip: skip.toString(),
|
||||
sortBy,
|
||||
});
|
||||
|
||||
return HydraApi.get(
|
||||
`/games/${shop}/${objectId}/reviews?${params.toString()}`,
|
||||
null,
|
||||
{ needsAuth: false }
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("getGameReviews", getGameReviews);
|
||||
@@ -21,11 +21,9 @@ const getGameStats = async (
|
||||
return cachedStats;
|
||||
}
|
||||
|
||||
return HydraApi.get<GameStats>(
|
||||
`/games/stats`,
|
||||
{ objectId, shop },
|
||||
{ needsAuth: false }
|
||||
).then(async (data) => {
|
||||
return HydraApi.get<GameStats>(`/games/${shop}/${objectId}/stats`, null, {
|
||||
needsAuth: false,
|
||||
}).then(async (data) => {
|
||||
await gamesStatsCacheSublevel.put(levelKeys.game(shop, objectId), {
|
||||
...data,
|
||||
updatedAt: Date.now(),
|
||||
|
||||
@@ -8,12 +8,7 @@ const getHowLongToBeat = async (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
): Promise<HowLongToBeatCategory[] | null> => {
|
||||
const params = new URLSearchParams({
|
||||
objectId,
|
||||
shop,
|
||||
});
|
||||
|
||||
return HydraApi.get(`/games/how-long-to-beat?${params.toString()}`, null, {
|
||||
return HydraApi.get(`/games/${shop}/${objectId}/how-long-to-beat`, null, {
|
||||
needsAuth: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ const getTrendingGames = async (_event: Electron.IpcMainInvokeEvent) => {
|
||||
.then((language) => language || "en");
|
||||
|
||||
const trendingGames = await HydraApi.get<TrendingGame[]>(
|
||||
"/games/featured",
|
||||
"/catalogue/featured",
|
||||
{ language },
|
||||
{ needsAuth: false }
|
||||
).catch(() => []);
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { GameShop, ShopAssets } from "@types";
|
||||
import { gamesShopAssetsSublevel, levelKeys } from "@main/level";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const saveGameShopAssets = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
assets: ShopAssets
|
||||
): Promise<void> => {
|
||||
const key = levelKeys.game(shop, objectId);
|
||||
const existingAssets = await gamesShopAssetsSublevel.get(key);
|
||||
|
||||
// Preserve existing title if it differs from the incoming title (indicating it was customized)
|
||||
const shouldPreserveTitle =
|
||||
existingAssets?.title && existingAssets.title !== assets.title;
|
||||
|
||||
return gamesShopAssetsSublevel.put(key, {
|
||||
...existingAssets,
|
||||
...assets,
|
||||
title: shouldPreserveTitle ? existingAssets.title : assets.title,
|
||||
});
|
||||
};
|
||||
|
||||
registerEvent("saveGameShopAssets", saveGameShopAssets);
|
||||
18
src/main/events/catalogue/vote-review.ts
Normal file
18
src/main/events/catalogue/vote-review.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import type { GameShop } from "@types";
|
||||
|
||||
const voteReview = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
reviewId: string,
|
||||
voteType: "upvote" | "downvote"
|
||||
) => {
|
||||
return HydraApi.put(
|
||||
`/games/${shop}/${objectId}/reviews/${reviewId}/${voteType}`,
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
||||
registerEvent("voteReview", voteReview);
|
||||
@@ -3,7 +3,6 @@ import { ipcMain } from "electron";
|
||||
|
||||
import "./catalogue/get-catalogue";
|
||||
import "./catalogue/get-game-shop-details";
|
||||
import "./catalogue/save-game-shop-assets";
|
||||
import "./catalogue/get-how-long-to-beat";
|
||||
import "./catalogue/get-random-game";
|
||||
import "./catalogue/search-games";
|
||||
@@ -11,6 +10,11 @@ import "./catalogue/get-game-stats";
|
||||
import "./catalogue/get-trending-games";
|
||||
import "./catalogue/get-publishers";
|
||||
import "./catalogue/get-developers";
|
||||
import "./catalogue/create-game-review";
|
||||
import "./catalogue/get-game-reviews";
|
||||
import "./catalogue/vote-review";
|
||||
import "./catalogue/delete-review";
|
||||
import "./catalogue/check-game-review";
|
||||
import "./hardware/get-disk-free-space";
|
||||
import "./hardware/check-folder-write-permission";
|
||||
import "./library/add-game-to-library";
|
||||
@@ -66,6 +70,7 @@ import "./user-preferences/auto-launch";
|
||||
import "./autoupdater/check-for-updates";
|
||||
import "./autoupdater/restart-and-install-update";
|
||||
import "./user-preferences/authenticate-real-debrid";
|
||||
import "./user-preferences/authenticate-all-debrid";
|
||||
import "./user-preferences/authenticate-torbox";
|
||||
import "./download-sources/put-download-source";
|
||||
import "./auth/sign-out";
|
||||
|
||||
@@ -27,6 +27,7 @@ const addCustomGameToLibrary = async (
|
||||
}
|
||||
|
||||
const assets = {
|
||||
updatedAt: Date.now(),
|
||||
objectId,
|
||||
shop,
|
||||
title,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import type { GameShop, GameStats } from "@types";
|
||||
import type { GameShop, ShopAssets } from "@types";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
import {
|
||||
composeSteamShortcut,
|
||||
getSteamLocation,
|
||||
getSteamShortcuts,
|
||||
getSteamUsersIds,
|
||||
HydraApi,
|
||||
logger,
|
||||
SystemPath,
|
||||
writeSteamShortcuts,
|
||||
@@ -15,6 +14,7 @@ import fs from "node:fs";
|
||||
import axios from "axios";
|
||||
import path from "node:path";
|
||||
import { ASSETS_PATH } from "@main/constants";
|
||||
import { getGameAssets } from "../catalogue/get-game-assets";
|
||||
|
||||
const downloadAsset = async (downloadPath: string, url?: string | null) => {
|
||||
try {
|
||||
@@ -41,7 +41,7 @@ const downloadAsset = async (downloadPath: string, url?: string | null) => {
|
||||
const downloadAssetsFromSteam = async (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
assets: GameStats["assets"]
|
||||
assets: ShopAssets | null
|
||||
) => {
|
||||
const gameAssetsPath = path.join(ASSETS_PATH, `${shop}-${objectId}`);
|
||||
|
||||
@@ -86,9 +86,7 @@ const createSteamShortcut = async (
|
||||
throw new Error("No executable path found for game");
|
||||
}
|
||||
|
||||
const { assets } = await HydraApi.get<GameStats>(
|
||||
`/games/stats?objectId=${objectId}&shop=${shop}`
|
||||
);
|
||||
const assets = await getGameAssets(objectId, shop);
|
||||
|
||||
const steamUserIds = await getSteamUsersIds();
|
||||
|
||||
|
||||
@@ -93,14 +93,9 @@ const startGameDownload = async (
|
||||
|
||||
await Promise.all([
|
||||
createGame(updatedGame!).catch(() => {}),
|
||||
HydraApi.post(
|
||||
"/games/download",
|
||||
{
|
||||
objectId,
|
||||
shop,
|
||||
},
|
||||
{ needsAuth: false }
|
||||
).catch(() => {}),
|
||||
HydraApi.post(`/games/${shop}/${objectId}/download`, null, {
|
||||
needsAuth: false,
|
||||
}).catch(() => {}),
|
||||
]);
|
||||
|
||||
return { ok: true };
|
||||
|
||||
17
src/main/events/user-preferences/authenticate-all-debrid.ts
Normal file
17
src/main/events/user-preferences/authenticate-all-debrid.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AllDebridClient } from "@main/services/download/all-debrid";
|
||||
import { registerEvent } from "../register-event";
|
||||
|
||||
const authenticateAllDebrid = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
apiKey: string
|
||||
) => {
|
||||
AllDebridClient.authorize(apiKey);
|
||||
const result = await AllDebridClient.getUser();
|
||||
if ("error_code" in result) {
|
||||
return { error_code: result.error_code };
|
||||
}
|
||||
|
||||
return result.user;
|
||||
};
|
||||
|
||||
registerEvent("authenticateAllDebrid", authenticateAllDebrid);
|
||||
@@ -3,9 +3,9 @@ import type { ShopAssets } from "@types";
|
||||
import { db } from "../level";
|
||||
import { levelKeys } from "./keys";
|
||||
|
||||
export const gamesShopAssetsSublevel = db.sublevel<string, ShopAssets>(
|
||||
levelKeys.gameShopAssets,
|
||||
{
|
||||
valueEncoding: "json",
|
||||
}
|
||||
);
|
||||
export const gamesShopAssetsSublevel = db.sublevel<
|
||||
string,
|
||||
ShopAssets & { updatedAt: number }
|
||||
>(levelKeys.gameShopAssets, {
|
||||
valueEncoding: "json",
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CommonRedistManager,
|
||||
TorBoxClient,
|
||||
RealDebridClient,
|
||||
AllDebridClient,
|
||||
Aria2,
|
||||
DownloadManager,
|
||||
HydraApi,
|
||||
@@ -37,6 +38,10 @@ export const loadState = async () => {
|
||||
RealDebridClient.authorize(userPreferences.realDebridApiToken);
|
||||
}
|
||||
|
||||
if (userPreferences?.allDebridApiKey) {
|
||||
AllDebridClient.authorize(userPreferences.allDebridApiKey);
|
||||
}
|
||||
|
||||
if (userPreferences?.torBoxApiToken) {
|
||||
TorBoxClient.authorize(userPreferences.torBoxApiToken);
|
||||
}
|
||||
|
||||
@@ -45,10 +45,8 @@ export const getGameAchievementData = async (
|
||||
.then((language) => language || "en");
|
||||
|
||||
return HydraApi.get<SteamAchievement[]>(
|
||||
"/games/achievements",
|
||||
`/games/${shop}/${objectId}/achievements`,
|
||||
{
|
||||
shop,
|
||||
objectId,
|
||||
language,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -80,7 +80,7 @@ export class CloudSync {
|
||||
try {
|
||||
await fs.promises.rm(backupPath, { recursive: true });
|
||||
} catch (error) {
|
||||
logger.error("Failed to remove backup path", error);
|
||||
logger.error("Failed to remove backup path", { backupPath, error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ export class CloudSync {
|
||||
try {
|
||||
await fs.promises.unlink(bundleLocation);
|
||||
} catch (error) {
|
||||
logger.error("Failed to remove tar file", error);
|
||||
logger.error("Failed to remove tar file", { bundleLocation, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
315
src/main/services/download/all-debrid.ts
Normal file
315
src/main/services/download/all-debrid.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import type { AllDebridUser } from "@types";
|
||||
import { logger } from "@main/services";
|
||||
|
||||
interface AllDebridMagnetStatus {
|
||||
id: number;
|
||||
filename: string;
|
||||
size: number;
|
||||
status: string;
|
||||
statusCode: number;
|
||||
downloaded: number;
|
||||
uploaded: number;
|
||||
seeders: number;
|
||||
downloadSpeed: number;
|
||||
uploadSpeed: number;
|
||||
uploadDate: number;
|
||||
completionDate: number;
|
||||
links: Array<{
|
||||
link: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface AllDebridError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AllDebridDownloadUrl {
|
||||
link: string;
|
||||
size?: number;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export class AllDebridClient {
|
||||
private static instance: AxiosInstance;
|
||||
private static readonly baseURL = "https://api.alldebrid.com/v4";
|
||||
|
||||
static authorize(apiKey: string) {
|
||||
logger.info("[AllDebrid] Authorizing with key:", apiKey ? "***" : "empty");
|
||||
this.instance = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
params: {
|
||||
agent: "hydra",
|
||||
apikey: apiKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async getUser() {
|
||||
try {
|
||||
const response = await this.instance.get<{
|
||||
status: string;
|
||||
data?: { user: AllDebridUser };
|
||||
error?: AllDebridError;
|
||||
}>("/user");
|
||||
|
||||
logger.info("[AllDebrid] API Response:", response.data);
|
||||
|
||||
if (response.data.status === "error") {
|
||||
const error = response.data.error;
|
||||
logger.error("[AllDebrid] API Error:", error);
|
||||
if (error?.code === "AUTH_MISSING_APIKEY") {
|
||||
return { error_code: "alldebrid_missing_key" };
|
||||
}
|
||||
if (error?.code === "AUTH_BAD_APIKEY") {
|
||||
return { error_code: "alldebrid_invalid_key" };
|
||||
}
|
||||
if (error?.code === "AUTH_BLOCKED") {
|
||||
return { error_code: "alldebrid_blocked" };
|
||||
}
|
||||
if (error?.code === "AUTH_USER_BANNED") {
|
||||
return { error_code: "alldebrid_banned" };
|
||||
}
|
||||
return { error_code: "alldebrid_unknown_error" };
|
||||
}
|
||||
|
||||
if (!response.data.data?.user) {
|
||||
logger.error("[AllDebrid] No user data in response");
|
||||
return { error_code: "alldebrid_invalid_response" };
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Successfully got user:",
|
||||
response.data.data.user.username
|
||||
);
|
||||
return { user: response.data.data.user };
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Request Error:", error);
|
||||
if (error.response?.data?.error) {
|
||||
return { error_code: "alldebrid_invalid_key" };
|
||||
}
|
||||
return { error_code: "alldebrid_network_error" };
|
||||
}
|
||||
}
|
||||
|
||||
private static async uploadMagnet(magnet: string) {
|
||||
try {
|
||||
logger.info("[AllDebrid] Uploading magnet with params:", { magnet });
|
||||
|
||||
const response = await this.instance.get("/magnet/upload", {
|
||||
params: {
|
||||
magnets: [magnet],
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Upload Magnet Raw Response:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
|
||||
if (response.data.status === "error") {
|
||||
throw new Error(response.data.error?.message || "Unknown error");
|
||||
}
|
||||
|
||||
const magnetInfo = response.data.data.magnets[0];
|
||||
logger.info(
|
||||
"[AllDebrid] Magnet Info:",
|
||||
JSON.stringify(magnetInfo, null, 2)
|
||||
);
|
||||
|
||||
if (magnetInfo.error) {
|
||||
throw new Error(magnetInfo.error.message);
|
||||
}
|
||||
|
||||
return magnetInfo.id;
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Upload Magnet Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async checkMagnetStatus(
|
||||
magnetId: number
|
||||
): Promise<AllDebridMagnetStatus> {
|
||||
try {
|
||||
logger.info("[AllDebrid] Checking magnet status for ID:", magnetId);
|
||||
|
||||
const response = await this.instance.get(`/magnet/status`, {
|
||||
params: {
|
||||
id: magnetId,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Check Magnet Status Raw Response:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("No response data received");
|
||||
}
|
||||
|
||||
if (response.data.status === "error") {
|
||||
throw new Error(response.data.error?.message || "Unknown error");
|
||||
}
|
||||
|
||||
// Verificăm noua structură a răspunsului
|
||||
const magnetData = response.data.data?.magnets;
|
||||
if (!magnetData || typeof magnetData !== "object") {
|
||||
logger.error(
|
||||
"[AllDebrid] Invalid response structure:",
|
||||
JSON.stringify(response.data, null, 2)
|
||||
);
|
||||
throw new Error("Invalid magnet status response format");
|
||||
}
|
||||
|
||||
// Convertim răspunsul în formatul așteptat
|
||||
const magnetStatus: AllDebridMagnetStatus = {
|
||||
id: magnetData.id,
|
||||
filename: magnetData.filename,
|
||||
size: magnetData.size,
|
||||
status: magnetData.status,
|
||||
statusCode: magnetData.statusCode,
|
||||
downloaded: magnetData.downloaded,
|
||||
uploaded: magnetData.uploaded,
|
||||
seeders: magnetData.seeders,
|
||||
downloadSpeed: magnetData.downloadSpeed,
|
||||
uploadSpeed: magnetData.uploadSpeed,
|
||||
uploadDate: magnetData.uploadDate,
|
||||
completionDate: magnetData.completionDate,
|
||||
links: magnetData.links.map((link) => ({
|
||||
link: link.link,
|
||||
filename: link.filename,
|
||||
size: link.size,
|
||||
})),
|
||||
};
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Magnet Status:",
|
||||
JSON.stringify(magnetStatus, null, 2)
|
||||
);
|
||||
|
||||
return magnetStatus;
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Check Magnet Status Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static async unlockLink(link: string) {
|
||||
try {
|
||||
const response = await this.instance.get<{
|
||||
status: string;
|
||||
data?: { link: string };
|
||||
error?: AllDebridError;
|
||||
}>("/link/unlock", {
|
||||
params: {
|
||||
link,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data.status === "error") {
|
||||
throw new Error(response.data.error?.message || "Unknown error");
|
||||
}
|
||||
|
||||
const unlockedLink = response.data.data?.link;
|
||||
if (!unlockedLink) {
|
||||
throw new Error("No download link received from AllDebrid");
|
||||
}
|
||||
|
||||
return unlockedLink;
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Unlock Link Error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async getDownloadUrls(
|
||||
uri: string
|
||||
): Promise<AllDebridDownloadUrl[]> {
|
||||
try {
|
||||
logger.info("[AllDebrid] Getting download URLs for URI:", uri);
|
||||
|
||||
if (uri.startsWith("magnet:")) {
|
||||
logger.info("[AllDebrid] Detected magnet link, uploading...");
|
||||
// 1. Upload magnet
|
||||
const magnetId = await this.uploadMagnet(uri);
|
||||
logger.info("[AllDebrid] Magnet uploaded, ID:", magnetId);
|
||||
|
||||
// 2. Verificăm statusul până când avem link-uri
|
||||
let retries = 0;
|
||||
let magnetStatus: AllDebridMagnetStatus;
|
||||
|
||||
do {
|
||||
magnetStatus = await this.checkMagnetStatus(magnetId);
|
||||
logger.info(
|
||||
"[AllDebrid] Magnet status:",
|
||||
magnetStatus.status,
|
||||
"statusCode:",
|
||||
magnetStatus.statusCode
|
||||
);
|
||||
|
||||
if (magnetStatus.statusCode === 4) {
|
||||
// Ready
|
||||
// Deblocăm fiecare link în parte și aruncăm eroare dacă oricare eșuează
|
||||
const unlockedLinks = await Promise.all(
|
||||
magnetStatus.links.map(async (link) => {
|
||||
try {
|
||||
const unlockedLink = await this.unlockLink(link.link);
|
||||
logger.info(
|
||||
"[AllDebrid] Successfully unlocked link:",
|
||||
unlockedLink
|
||||
);
|
||||
|
||||
return {
|
||||
link: unlockedLink,
|
||||
size: link.size,
|
||||
filename: link.filename,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"[AllDebrid] Failed to unlock link:",
|
||||
link.link,
|
||||
error
|
||||
);
|
||||
throw new Error("Failed to unlock all links");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(
|
||||
"[AllDebrid] Got unlocked download links:",
|
||||
unlockedLinks
|
||||
);
|
||||
console.log("[AllDebrid] FINAL LINKS →", unlockedLinks);
|
||||
return unlockedLinks;
|
||||
}
|
||||
|
||||
if (retries++ > 30) {
|
||||
// Maximum 30 de încercări
|
||||
throw new Error("Timeout waiting for magnet to be ready");
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // Așteptăm 2 secunde între verificări
|
||||
} while (magnetStatus.statusCode !== 4);
|
||||
} else {
|
||||
logger.info("[AllDebrid] Regular link, unlocking...");
|
||||
// Pentru link-uri normale, doar debridam link-ul
|
||||
const downloadUrl = await this.unlockLink(uri);
|
||||
logger.info("[AllDebrid] Got unlocked download URL:", downloadUrl);
|
||||
return [
|
||||
{
|
||||
link: downloadUrl,
|
||||
},
|
||||
];
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("[AllDebrid] Get Download URLs Error:", error);
|
||||
throw error;
|
||||
}
|
||||
return []; // Add default return for TypeScript
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./types";
|
||||
import { calculateETA, getDirSize } from "./helpers";
|
||||
import { RealDebridClient } from "./real-debrid";
|
||||
import { AllDebridClient } from "./all-debrid";
|
||||
import path from "path";
|
||||
import { logger } from "../logger";
|
||||
import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level";
|
||||
@@ -40,6 +41,7 @@ export class DownloadManager {
|
||||
})
|
||||
: undefined,
|
||||
downloadsToSeed?.map((download) => ({
|
||||
action: "seed",
|
||||
game_id: levelKeys.game(download.shop, download.objectId),
|
||||
url: download.uri,
|
||||
save_path: download.downloadPath,
|
||||
@@ -377,6 +379,27 @@ export class DownloadManager {
|
||||
allow_multiple_connections: true,
|
||||
};
|
||||
}
|
||||
case Downloader.AllDebrid: {
|
||||
const downloadUrls = await AllDebridClient.getDownloadUrls(
|
||||
download.uri
|
||||
);
|
||||
|
||||
if (!downloadUrls.length)
|
||||
throw new Error(DownloadError.NotCachedInAllDebrid);
|
||||
|
||||
const totalSize = downloadUrls.reduce(
|
||||
(total, url) => total + (url.size || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
action: "start",
|
||||
game_id: downloadId,
|
||||
url: downloadUrls.map((d) => d.link),
|
||||
save_path: download.downloadPath,
|
||||
total_size: totalSize,
|
||||
};
|
||||
}
|
||||
case Downloader.TorBox: {
|
||||
const { name, url } = await TorBoxClient.getDownloadInfo(download.uri);
|
||||
|
||||
|
||||
@@ -17,17 +17,24 @@ export const calculateETA = (
|
||||
};
|
||||
|
||||
export const getDirSize = async (dir: string): Promise<number> => {
|
||||
const getItemSize = async (filePath: string): Promise<number> => {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
try {
|
||||
const stat = await fs.promises.stat(dir);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return getDirSize(filePath);
|
||||
// If it's a file, return its size directly
|
||||
if (!stat.isDirectory()) {
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
return stat.size;
|
||||
};
|
||||
const getItemSize = async (filePath: string): Promise<number> => {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return getDirSize(filePath);
|
||||
}
|
||||
|
||||
return stat.size;
|
||||
};
|
||||
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const filePaths = files.map((file) => path.join(dir, file));
|
||||
const sizes = await Promise.all(filePaths.map(getItemSize));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./download-manager";
|
||||
export * from "./real-debrid";
|
||||
export * from "./all-debrid";
|
||||
export * from "./torbox";
|
||||
|
||||
@@ -22,7 +22,8 @@ export const mergeWithRemoteGames = async () => {
|
||||
const updatedLastTimePlayed =
|
||||
localGame.lastTimePlayed == null ||
|
||||
(game.lastTimePlayed &&
|
||||
new Date(game.lastTimePlayed) > localGame.lastTimePlayed)
|
||||
new Date(game.lastTimePlayed) >
|
||||
new Date(localGame.lastTimePlayed))
|
||||
? game.lastTimePlayed
|
||||
: localGame.lastTimePlayed;
|
||||
|
||||
@@ -57,7 +58,11 @@ export const mergeWithRemoteGames = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const localGameShopAsset = await gamesShopAssetsSublevel.get(gameKey);
|
||||
|
||||
await gamesShopAssetsSublevel.put(gameKey, {
|
||||
updatedAt: Date.now(),
|
||||
...localGameShopAsset,
|
||||
shop: game.shop,
|
||||
objectId: game.objectId,
|
||||
title: localGame?.title || game.title, // Preserve local title if it exists
|
||||
|
||||
@@ -10,7 +10,7 @@ import icon from "@resources/icon.png?asset";
|
||||
import { NotificationOptions, toXmlString } from "./xml";
|
||||
import { logger } from "../logger";
|
||||
import { WindowManager } from "../window-manager";
|
||||
import type { Game, GameStats, UserPreferences, UserProfile } from "@types";
|
||||
import type { Game, UserPreferences, UserProfile } from "@types";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update";
|
||||
import { SystemPath } from "../system-path";
|
||||
@@ -108,15 +108,14 @@ export const publishNewFriendRequestNotification = async (
|
||||
};
|
||||
|
||||
export const publishFriendStartedPlayingGameNotification = async (
|
||||
friend: UserProfile,
|
||||
game: GameStats
|
||||
friend: UserProfile
|
||||
) => {
|
||||
new Notification({
|
||||
title: t("friend_started_playing_game", {
|
||||
ns: "notifications",
|
||||
displayName: friend.displayName,
|
||||
}),
|
||||
body: game.assets?.title,
|
||||
body: friend?.currentGame?.title,
|
||||
icon: friend?.profileImageUrl
|
||||
? await downloadImage(friend.profileImageUrl)
|
||||
: trayIcon,
|
||||
|
||||
@@ -11,9 +11,13 @@ import { app, dialog } from "electron";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
|
||||
interface GamePayload {
|
||||
action: string;
|
||||
game_id: string;
|
||||
url: string;
|
||||
url: string | string[];
|
||||
save_path: string;
|
||||
header?: string;
|
||||
out?: string;
|
||||
total_size?: number;
|
||||
}
|
||||
|
||||
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const requestSteam250 = async (path: string) => {
|
||||
const { window } = new JSDOM(response.data);
|
||||
const { document } = window;
|
||||
|
||||
return Array.from(document.querySelectorAll(".appline .title a"))
|
||||
return Array.from(document.querySelectorAll("a[data-title]"))
|
||||
.map(($title) => {
|
||||
const steamGameUrl = ($title as HTMLAnchorElement).href;
|
||||
if (!steamGameUrl) return null;
|
||||
|
||||
@@ -76,7 +76,11 @@ export const getSteamAppDetails = async (
|
||||
return null;
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(err, { method: "getSteamAppDetails" });
|
||||
logger.error("Error on getSteamAppDetails", {
|
||||
message: err?.message,
|
||||
code: err?.code,
|
||||
name: err?.name,
|
||||
});
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FriendGameSession } from "@main/generated/envelope";
|
||||
import { db, levelKeys } from "@main/level";
|
||||
import { HydraApi } from "@main/services/hydra-api";
|
||||
import { publishFriendStartedPlayingGameNotification } from "@main/services/notifications";
|
||||
import type { GameStats, UserPreferences, UserProfile } from "@types";
|
||||
import type { UserPreferences, UserProfile } from "@types";
|
||||
|
||||
export const friendGameSessionEvent = async (payload: FriendGameSession) => {
|
||||
const userPreferences = await db.get<string, UserPreferences | null>(
|
||||
@@ -14,14 +14,9 @@ export const friendGameSessionEvent = async (payload: FriendGameSession) => {
|
||||
|
||||
if (userPreferences?.friendStartGameNotificationsEnabled === false) return;
|
||||
|
||||
const [friend, gameStats] = await Promise.all([
|
||||
HydraApi.get<UserProfile>(`/users/${payload.friendId}`),
|
||||
HydraApi.get<GameStats>(
|
||||
`/games/stats?objectId=${payload.objectId}&shop=steam`
|
||||
),
|
||||
]).catch(() => [null, null]);
|
||||
const friend = await HydraApi.get<UserProfile>(`/users/${payload.friendId}`);
|
||||
|
||||
if (friend && gameStats) {
|
||||
publishFriendStartedPlayingGameNotification(friend, gameStats);
|
||||
if (friend) {
|
||||
publishFriendStartedPlayingGameNotification(friend);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
Theme,
|
||||
FriendRequestSync,
|
||||
ShortcutLocation,
|
||||
ShopAssets,
|
||||
AchievementCustomNotificationPosition,
|
||||
AchievementNotificationInfo,
|
||||
} from "@types";
|
||||
@@ -67,8 +66,6 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("searchGames", payload, take, skip),
|
||||
getCatalogue: (category: CatalogueCategory) =>
|
||||
ipcRenderer.invoke("getCatalogue", category),
|
||||
saveGameShopAssets: (objectId: string, shop: GameShop, assets: ShopAssets) =>
|
||||
ipcRenderer.invoke("saveGameShopAssets", objectId, shop, assets),
|
||||
getGameShopDetails: (objectId: string, shop: GameShop, language: string) =>
|
||||
ipcRenderer.invoke("getGameShopDetails", objectId, shop, language),
|
||||
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
|
||||
@@ -76,7 +73,33 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("getHowLongToBeat", objectId, shop),
|
||||
getGameStats: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameStats", objectId, shop),
|
||||
getGameAssets: (objectId: string, shop: GameShop) =>
|
||||
ipcRenderer.invoke("getGameAssets", objectId, shop),
|
||||
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
|
||||
createGameReview: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
reviewHtml: string,
|
||||
score: number
|
||||
) =>
|
||||
ipcRenderer.invoke("createGameReview", shop, objectId, reviewHtml, score),
|
||||
getGameReviews: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
take?: number,
|
||||
skip?: number,
|
||||
sortBy?: string
|
||||
) => ipcRenderer.invoke("getGameReviews", shop, objectId, take, skip, sortBy),
|
||||
voteReview: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
reviewId: string,
|
||||
voteType: "upvote" | "downvote"
|
||||
) => ipcRenderer.invoke("voteReview", shop, objectId, reviewId, voteType),
|
||||
deleteReview: (shop: GameShop, objectId: string, reviewId: string) =>
|
||||
ipcRenderer.invoke("deleteReview", shop, objectId, reviewId),
|
||||
checkGameReview: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("checkGameReview", shop, objectId),
|
||||
onUpdateAchievements: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
@@ -102,6 +125,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("autoLaunch", autoLaunchProps),
|
||||
authenticateRealDebrid: (apiToken: string) =>
|
||||
ipcRenderer.invoke("authenticateRealDebrid", apiToken),
|
||||
authenticateAllDebrid: (apiKey: string) =>
|
||||
ipcRenderer.invoke("authenticateAllDebrid", apiKey),
|
||||
authenticateTorBox: (apiToken: string) =>
|
||||
ipcRenderer.invoke("authenticateTorBox", apiToken),
|
||||
|
||||
|
||||
11
src/renderer/src/components/confirm-modal/confirm-modal.scss
Normal file
11
src/renderer/src/components/confirm-modal/confirm-modal.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.confirm-modal {
|
||||
&__actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
}
|
||||
57
src/renderer/src/components/confirm-modal/confirm-modal.tsx
Normal file
57
src/renderer/src/components/confirm-modal/confirm-modal.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import "./confirm-modal.scss";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
confirmTheme?: "primary" | "outline" | "danger";
|
||||
confirmDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
visible,
|
||||
title,
|
||||
description,
|
||||
onClose,
|
||||
onConfirm,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
confirmTheme = "outline",
|
||||
confirmDisabled = false,
|
||||
}: ConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={title}
|
||||
description={description}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="confirm-modal__actions">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
theme={confirmTheme}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
{confirmLabel || t("confirm")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={onClose} theme="primary">
|
||||
{cancelLabel || t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
159
src/renderer/src/components/context-menu/context-menu.scss
Normal file
159
src/renderer/src/components/context-menu/context-menu.scss
Normal file
@@ -0,0 +1,159 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background-color: globals.$background-color;
|
||||
border: 1px solid globals.$border-color;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
padding: 4px 0;
|
||||
min-width: 180px;
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&__item-container {
|
||||
position: relative;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
&__item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: globals.$body-color;
|
||||
cursor: pointer;
|
||||
font-size: globals.$body-font-size;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover:not(&--disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active:not(&--disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
color: globals.$muted-color;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: globals.$danger-color;
|
||||
|
||||
&:hover:not(.context-menu__item--disabled) {
|
||||
background-color: rgba(128, 29, 30, 0.1);
|
||||
}
|
||||
|
||||
.context-menu__item-icon {
|
||||
color: globals.$danger-color;
|
||||
}
|
||||
}
|
||||
|
||||
&--has-submenu {
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__item-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__item-arrow {
|
||||
font-size: 10px;
|
||||
color: globals.$muted-color;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&__submenu {
|
||||
position: absolute;
|
||||
left: calc(100% - 2px);
|
||||
top: 0;
|
||||
background-color: globals.$background-color;
|
||||
border: 1px solid globals.$border-color;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
padding: 4px 0;
|
||||
min-width: 160px;
|
||||
backdrop-filter: blur(8px);
|
||||
margin-left: 0;
|
||||
z-index: 1200;
|
||||
|
||||
pointer-events: auto;
|
||||
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__content {
|
||||
border-top: 1px solid globals.$border-color;
|
||||
padding: 8px 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__item + &__item {
|
||||
border-top: 1px solid transparent;
|
||||
}
|
||||
|
||||
&__item--danger:first-of-type {
|
||||
border-top: 1px solid globals.$border-color;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&--game-not-installed &__submenu &__item--danger:first-of-type {
|
||||
border-top: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&__separator {
|
||||
height: 1px;
|
||||
background: globals.$border-color;
|
||||
margin: 6px 8px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
animation: contextMenuFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes contextMenuFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
254
src/renderer/src/components/context-menu/context-menu.tsx
Normal file
254
src/renderer/src/components/context-menu/context-menu.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import cn from "classnames";
|
||||
import "./context-menu.scss";
|
||||
|
||||
export interface ContextMenuItemData {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
separator?: boolean;
|
||||
submenu?: ContextMenuItemData[];
|
||||
}
|
||||
|
||||
export interface ContextMenuProps {
|
||||
items: ContextMenuItemData[];
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContextMenu({
|
||||
items,
|
||||
visible,
|
||||
position,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
}: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [adjustedPosition, setAdjustedPosition] = useState(position);
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||
const submenuCloseTimeout = useRef<number | null>(null);
|
||||
const itemRefs = useRef<Record<string, HTMLLIElement | null>>({});
|
||||
const [submenuStyles, setSubmenuStyles] = useState<
|
||||
Record<string, React.CSSProperties>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [visible, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !menuRef.current) return;
|
||||
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let adjustedX = position.x;
|
||||
let adjustedY = position.y;
|
||||
|
||||
if (position.x + rect.width > viewportWidth) {
|
||||
adjustedX = viewportWidth - rect.width - 10;
|
||||
}
|
||||
|
||||
if (position.y + rect.height > viewportHeight) {
|
||||
adjustedY = viewportHeight - rect.height - 10;
|
||||
}
|
||||
|
||||
setAdjustedPosition({ x: adjustedX, y: adjustedY });
|
||||
}, [visible, position]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setActiveSubmenu(null);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleItemClick = (item: ContextMenuItemData) => {
|
||||
if (item.disabled) return;
|
||||
|
||||
if (item.submenu) {
|
||||
setActiveSubmenu(activeSubmenu === item.id ? null : item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmenuMouseEnter = (itemId: string) => {
|
||||
if (submenuCloseTimeout.current) {
|
||||
window.clearTimeout(submenuCloseTimeout.current);
|
||||
submenuCloseTimeout.current = null;
|
||||
}
|
||||
setActiveSubmenu(itemId);
|
||||
};
|
||||
|
||||
const handleSubmenuMouseLeave = () => {
|
||||
if (submenuCloseTimeout.current) {
|
||||
window.clearTimeout(submenuCloseTimeout.current);
|
||||
}
|
||||
submenuCloseTimeout.current = window.setTimeout(() => {
|
||||
setActiveSubmenu(null);
|
||||
submenuCloseTimeout.current = null;
|
||||
}, 120);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSubmenu) return;
|
||||
|
||||
const parentEl = itemRefs.current[activeSubmenu];
|
||||
if (!parentEl) return;
|
||||
|
||||
const submenuEl = parentEl.querySelector(
|
||||
".context-menu__submenu"
|
||||
) as HTMLElement | null;
|
||||
if (!submenuEl) return;
|
||||
|
||||
const parentRect = parentEl.getBoundingClientRect();
|
||||
const submenuWidth = submenuEl.offsetWidth;
|
||||
const submenuHeight = submenuEl.offsetHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
const styles: React.CSSProperties = {};
|
||||
|
||||
if (parentRect.right + submenuWidth > viewportWidth - 8) {
|
||||
styles.left = "auto";
|
||||
styles.right = "calc(100% - 2px)";
|
||||
} else {
|
||||
styles.left = "calc(100% - 2px)";
|
||||
styles.right = undefined;
|
||||
}
|
||||
|
||||
const overflowBottom = parentRect.top + submenuHeight - viewportHeight;
|
||||
if (overflowBottom > 0) {
|
||||
const topAdjust = Math.min(overflowBottom + 8, parentRect.top - 8);
|
||||
styles.top = `${-topAdjust}px`;
|
||||
} else {
|
||||
styles.top = undefined;
|
||||
}
|
||||
|
||||
setSubmenuStyles((prev) => ({ ...prev, [activeSubmenu]: styles }));
|
||||
}, [activeSubmenu]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const menuContent = (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={cn("context-menu", className)}
|
||||
style={{
|
||||
left: adjustedPosition.x,
|
||||
top: adjustedPosition.y,
|
||||
}}
|
||||
>
|
||||
<ul className="context-menu__list">
|
||||
{items.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
ref={(el) => (itemRefs.current[item.id] = el)}
|
||||
className="context-menu__item-container"
|
||||
onMouseEnter={() =>
|
||||
item.submenu && handleSubmenuMouseEnter(item.id)
|
||||
}
|
||||
onMouseLeave={() => item.submenu && handleSubmenuMouseLeave()}
|
||||
>
|
||||
{item.separator && <div className="context-menu__separator" />}
|
||||
<button
|
||||
type="button"
|
||||
className={cn("context-menu__item", {
|
||||
"context-menu__item--disabled": item.disabled,
|
||||
"context-menu__item--danger": item.danger,
|
||||
"context-menu__item--has-submenu": item.submenu,
|
||||
"context-menu__item--active": activeSubmenu === item.id,
|
||||
})}
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="context-menu__item-icon">{item.icon}</span>
|
||||
)}
|
||||
<span className="context-menu__item-label">{item.label}</span>
|
||||
{item.submenu && (
|
||||
<span className="context-menu__item-arrow">▶</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{item.submenu && activeSubmenu === item.id && (
|
||||
<div
|
||||
className="context-menu__submenu"
|
||||
style={submenuStyles[item.id] || undefined}
|
||||
onMouseEnter={() => handleSubmenuMouseEnter(item.id)}
|
||||
onMouseLeave={() => handleSubmenuMouseLeave()}
|
||||
>
|
||||
<ul className="context-menu__list">
|
||||
{item.submenu.map((subItem) => (
|
||||
<li
|
||||
key={subItem.id}
|
||||
className="context-menu__item-container"
|
||||
>
|
||||
{subItem.separator && (
|
||||
<div className="context-menu__separator" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn("context-menu__item", {
|
||||
"context-menu__item--disabled": subItem.disabled,
|
||||
"context-menu__item--danger": subItem.danger,
|
||||
})}
|
||||
onClick={() => handleItemClick(subItem)}
|
||||
disabled={subItem.disabled}
|
||||
>
|
||||
{subItem.icon && (
|
||||
<span className="context-menu__item-icon">
|
||||
{subItem.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="context-menu__item-label">
|
||||
{subItem.label}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{children && <div className="context-menu__content">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(menuContent, document.body);
|
||||
}
|
||||
@@ -72,7 +72,11 @@
|
||||
display: flex;
|
||||
color: globals.$muted-color;
|
||||
font-size: 12px;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
|
||||
.star-rating {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__title-container {
|
||||
|
||||
@@ -7,6 +7,7 @@ import "./game-card.scss";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "../badge/badge";
|
||||
import { StarRating } from "../star-rating/star-rating";
|
||||
import { useCallback, useState, useMemo } from "react";
|
||||
import { useFormat, useRepacks } from "@renderer/hooks";
|
||||
|
||||
@@ -107,6 +108,14 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
{stats ? numberFormatter.format(stats.playerCount) : "…"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="game-card__specifics-item">
|
||||
<StarRating
|
||||
rating={stats?.averageScore || null}
|
||||
size={14}
|
||||
showCalculating={!!(stats && stats.averageScore === null)}
|
||||
calculatingText={t("calculating")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
PlayIcon,
|
||||
DownloadIcon,
|
||||
HeartIcon,
|
||||
HeartFillIcon,
|
||||
GearIcon,
|
||||
PencilIcon,
|
||||
FileDirectoryIcon,
|
||||
LinkIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
|
||||
import { LibraryGame } from "@types";
|
||||
import { ContextMenu, ContextMenuItemData, ContextMenuProps } from "..";
|
||||
import { ConfirmModal } from "@renderer/components/confirm-modal/confirm-modal";
|
||||
import { useGameActions } from "..";
|
||||
|
||||
interface GameContextMenuProps extends Omit<ContextMenuProps, "items"> {
|
||||
game: LibraryGame;
|
||||
}
|
||||
|
||||
export function GameContextMenu({
|
||||
game,
|
||||
visible,
|
||||
position,
|
||||
onClose,
|
||||
}: GameContextMenuProps) {
|
||||
const { t } = useTranslation("game_details");
|
||||
const [showConfirmRemoveLibrary, setShowConfirmRemoveLibrary] =
|
||||
useState(false);
|
||||
const [showConfirmRemoveFiles, setShowConfirmRemoveFiles] = useState(false);
|
||||
const {
|
||||
canPlay,
|
||||
isDeleting,
|
||||
isGameDownloading,
|
||||
hasRepacks,
|
||||
shouldShowCreateStartMenuShortcut,
|
||||
handlePlayGame,
|
||||
handleToggleFavorite,
|
||||
handleCreateShortcut,
|
||||
handleCreateSteamShortcut,
|
||||
handleOpenFolder,
|
||||
handleOpenDownloadOptions,
|
||||
handleOpenDownloadLocation,
|
||||
handleRemoveFromLibrary,
|
||||
handleRemoveFiles,
|
||||
handleOpenGameOptions,
|
||||
} = useGameActions(game);
|
||||
|
||||
const items: ContextMenuItemData[] = [
|
||||
{
|
||||
id: "play",
|
||||
label: canPlay ? t("play") : t("download"),
|
||||
icon: canPlay ? <PlayIcon size={16} /> : <DownloadIcon size={16} />,
|
||||
onClick: () => {
|
||||
void handlePlayGame();
|
||||
},
|
||||
disabled: isDeleting,
|
||||
},
|
||||
{
|
||||
id: "favorite",
|
||||
label: game.favorite ? t("remove_from_favorites") : t("add_to_favorites"),
|
||||
icon: game.favorite ? (
|
||||
<HeartFillIcon size={16} />
|
||||
) : (
|
||||
<HeartIcon size={16} />
|
||||
),
|
||||
onClick: () => {
|
||||
void handleToggleFavorite();
|
||||
},
|
||||
disabled: isDeleting,
|
||||
},
|
||||
...(game.executablePath
|
||||
? [
|
||||
{
|
||||
id: "shortcuts",
|
||||
label: t("create_shortcut_simple"),
|
||||
icon: <LinkIcon size={16} />,
|
||||
disabled: isDeleting,
|
||||
submenu: [
|
||||
{
|
||||
id: "desktop-shortcut",
|
||||
label: t("create_shortcut"),
|
||||
icon: <LinkIcon size={16} />,
|
||||
onClick: () => handleCreateShortcut("desktop"),
|
||||
disabled: isDeleting,
|
||||
},
|
||||
{
|
||||
id: "steam-shortcut",
|
||||
label: t("create_steam_shortcut"),
|
||||
icon: <SteamLogo style={{ width: 16, height: 16 }} />,
|
||||
onClick: handleCreateSteamShortcut,
|
||||
disabled: isDeleting,
|
||||
},
|
||||
...(shouldShowCreateStartMenuShortcut
|
||||
? [
|
||||
{
|
||||
id: "start-menu-shortcut",
|
||||
label: t("create_start_menu_shortcut"),
|
||||
icon: <LinkIcon size={16} />,
|
||||
onClick: () => handleCreateShortcut("start_menu"),
|
||||
disabled: isDeleting,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
id: "manage",
|
||||
label: t("options"),
|
||||
icon: <GearIcon size={16} />,
|
||||
disabled: isDeleting,
|
||||
submenu: [
|
||||
...(game.executablePath
|
||||
? [
|
||||
{
|
||||
id: "open-folder",
|
||||
label: t("open_folder"),
|
||||
icon: <FileDirectoryIcon size={16} />,
|
||||
onClick: handleOpenFolder,
|
||||
disabled: isDeleting,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(game.executablePath
|
||||
? [
|
||||
{
|
||||
id: "download-options",
|
||||
label: t("open_download_options"),
|
||||
icon: <PlayIcon size={16} />,
|
||||
onClick: handleOpenDownloadOptions,
|
||||
disabled: isDeleting || isGameDownloading || !hasRepacks,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(game.download?.downloadPath
|
||||
? [
|
||||
{
|
||||
id: "download-location",
|
||||
label: t("open_download_location"),
|
||||
icon: <FileDirectoryIcon size={16} />,
|
||||
onClick: handleOpenDownloadLocation,
|
||||
disabled: isDeleting,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
id: "remove-library",
|
||||
label: t("remove_from_library"),
|
||||
icon: <XIcon size={16} />,
|
||||
onClick: () => setShowConfirmRemoveLibrary(true),
|
||||
disabled: isDeleting,
|
||||
danger: true,
|
||||
},
|
||||
...(game.download?.downloadPath
|
||||
? [
|
||||
{
|
||||
id: "remove-files",
|
||||
label: t("remove_files"),
|
||||
icon: <TrashIcon size={16} />,
|
||||
onClick: () => setShowConfirmRemoveFiles(true),
|
||||
disabled: isDeleting || isGameDownloading,
|
||||
danger: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "properties",
|
||||
label: t("properties"),
|
||||
separator: true,
|
||||
icon: <PencilIcon size={16} />,
|
||||
onClick: () => handleOpenGameOptions(),
|
||||
disabled: isDeleting,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu
|
||||
items={items}
|
||||
visible={visible}
|
||||
position={position}
|
||||
onClose={onClose}
|
||||
className={
|
||||
!game.executablePath ? "context-menu--game-not-installed" : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
visible={showConfirmRemoveLibrary}
|
||||
title={t("remove_from_library_title")}
|
||||
description={t("remove_from_library_description", { game: game.title })}
|
||||
onClose={() => {
|
||||
setShowConfirmRemoveLibrary(false);
|
||||
onClose();
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
await handleRemoveFromLibrary();
|
||||
}}
|
||||
confirmLabel={t("remove")}
|
||||
cancelLabel={t("cancel")}
|
||||
confirmTheme="danger"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
visible={showConfirmRemoveFiles}
|
||||
title={t("remove_files")}
|
||||
description={t("delete_modal_description", { ns: "downloads" })}
|
||||
onClose={() => {
|
||||
setShowConfirmRemoveFiles(false);
|
||||
onClose();
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
await handleRemoveFiles();
|
||||
}}
|
||||
confirmLabel={t("remove")}
|
||||
cancelLabel={t("cancel")}
|
||||
confirmTheme="danger"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LibraryGame, ShortcutLocation } from "@types";
|
||||
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import { logger } from "@renderer/logger";
|
||||
|
||||
export function useGameActions(game: LibraryGame) {
|
||||
const { t } = useTranslation("game_details");
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { updateLibrary } = useLibrary();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const {
|
||||
removeGameInstaller,
|
||||
removeGameFromLibrary,
|
||||
isGameDeleting,
|
||||
lastPacket,
|
||||
cancelDownload,
|
||||
} = useDownload();
|
||||
|
||||
const [creatingSteamShortcut, setCreatingSteamShortcut] = useState(false);
|
||||
|
||||
const canPlay = Boolean(game.executablePath);
|
||||
const isDeleting = isGameDeleting(game.id);
|
||||
const isGameDownloading =
|
||||
game.download?.status === "active" && lastPacket?.gameId === game.id;
|
||||
const hasRepacks = true;
|
||||
const shouldShowCreateStartMenuShortcut =
|
||||
window.electron.platform === "win32";
|
||||
|
||||
const handlePlayGame = async () => {
|
||||
if (!canPlay) {
|
||||
const path = buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectId,
|
||||
});
|
||||
if (location.pathname === path) {
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:openRepacks", {
|
||||
detail: { objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
} else {
|
||||
navigate(path, { state: { openRepacks: true } });
|
||||
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:openRepacks", {
|
||||
detail: { objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electron.openGame(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
game.executablePath!,
|
||||
game.launchOptions
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorToast("Failed to start game");
|
||||
logger.error("Failed to start game", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async () => {
|
||||
try {
|
||||
if (game.favorite) {
|
||||
await window.electron.removeGameFromFavorites(game.shop, game.objectId);
|
||||
showSuccessToast(t("game_removed_from_favorites"));
|
||||
} else {
|
||||
await window.electron.addGameToFavorites(game.shop, game.objectId);
|
||||
showSuccessToast(t("game_added_to_favorites"));
|
||||
}
|
||||
updateLibrary();
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:game-favorite-toggled", {
|
||||
detail: { shop: game.shop, objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(t("failed_update_favorites"));
|
||||
logger.error("Failed to toggle favorite", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateShortcut = async (location: ShortcutLocation) => {
|
||||
try {
|
||||
const success = await window.electron.createGameShortcut(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
location
|
||||
);
|
||||
|
||||
if (success) {
|
||||
showSuccessToast(t("create_shortcut_success"));
|
||||
} else {
|
||||
showErrorToast(t("create_shortcut_error"));
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(t("create_shortcut_error"));
|
||||
logger.error("Failed to create shortcut", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateSteamShortcut = async () => {
|
||||
try {
|
||||
setCreatingSteamShortcut(true);
|
||||
await window.electron.createSteamShortcut(game.shop, game.objectId);
|
||||
|
||||
showSuccessToast(
|
||||
t("create_shortcut_success"),
|
||||
t("you_might_need_to_restart_steam")
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to create Steam shortcut", error);
|
||||
showErrorToast(t("create_shortcut_error"));
|
||||
} finally {
|
||||
setCreatingSteamShortcut(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
try {
|
||||
await window.electron.openGameExecutablePath(game.shop, game.objectId);
|
||||
} catch (error) {
|
||||
showErrorToast("Failed to open folder");
|
||||
logger.error("Failed to open folder", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDownloadOptions = () => {
|
||||
const path = buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectId,
|
||||
});
|
||||
navigate(path, { state: { openRepacks: true } });
|
||||
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:openRepacks", {
|
||||
detail: { objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenGameOptions = () => {
|
||||
const path = buildGameDetailsPath({
|
||||
...game,
|
||||
objectId: game.objectId,
|
||||
});
|
||||
|
||||
navigate(path, { state: { openGameOptions: true } });
|
||||
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:openGameOptions", {
|
||||
detail: { objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDownloadLocation = async () => {
|
||||
try {
|
||||
await window.electron.openGameInstallerPath(game.shop, game.objectId);
|
||||
} catch (error) {
|
||||
showErrorToast("Failed to open download location");
|
||||
logger.error("Failed to open download location", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFromLibrary = async () => {
|
||||
try {
|
||||
if (isGameDownloading) {
|
||||
await cancelDownload(game.shop, game.objectId);
|
||||
}
|
||||
|
||||
await removeGameFromLibrary(game.shop, game.objectId);
|
||||
updateLibrary();
|
||||
showSuccessToast(t("game_removed_from_library"));
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:game-removed-from-library", {
|
||||
detail: { shop: game.shop, objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(t("failed_remove_from_library"));
|
||||
logger.error("Failed to remove from library", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFiles = async () => {
|
||||
try {
|
||||
await removeGameInstaller(game.shop, game.objectId);
|
||||
updateLibrary();
|
||||
showSuccessToast(t("files_removed_success"));
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hydra:game-files-removed", {
|
||||
detail: { shop: game.shop, objectId: game.objectId },
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(t("failed_remove_files"));
|
||||
logger.error("Failed to remove files", error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
canPlay,
|
||||
isDeleting,
|
||||
isGameDownloading,
|
||||
hasRepacks,
|
||||
shouldShowCreateStartMenuShortcut,
|
||||
creatingSteamShortcut,
|
||||
handlePlayGame,
|
||||
handleToggleFavorite,
|
||||
handleCreateShortcut,
|
||||
handleCreateSteamShortcut,
|
||||
handleOpenFolder,
|
||||
handleOpenDownloadOptions,
|
||||
handleOpenDownloadLocation,
|
||||
handleRemoveFromLibrary,
|
||||
handleRemoveFiles,
|
||||
handleOpenGameOptions,
|
||||
};
|
||||
}
|
||||
@@ -15,3 +15,7 @@ export * from "./badge/badge";
|
||||
export * from "./confirmation-modal/confirmation-modal";
|
||||
export * from "./suspense-wrapper/suspense-wrapper";
|
||||
export * from "./debrid-badge/debrid-badge";
|
||||
export * from "./context-menu/context-menu";
|
||||
export * from "./game-context-menu/game-context-menu";
|
||||
export * from "./game-context-menu/use-game-actions";
|
||||
export * from "./star-rating/star-rating";
|
||||
|
||||
@@ -3,6 +3,8 @@ import PlayLogo from "@renderer/assets/play-logo.svg?react";
|
||||
import { LibraryGame } from "@types";
|
||||
import cn from "classnames";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { GameContextMenu } from "..";
|
||||
|
||||
interface SidebarGameItemProps {
|
||||
game: LibraryGame;
|
||||
@@ -16,6 +18,24 @@ export function SidebarGameItem({
|
||||
getGameTitle,
|
||||
}: Readonly<SidebarGameItemProps>) {
|
||||
const location = useLocation();
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
}>({ visible: false, position: { x: 0, y: 0 } });
|
||||
|
||||
const handleContextMenu = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setContextMenu({ visible: false, position: { x: 0, y: 0 } });
|
||||
};
|
||||
|
||||
const isCustomGame = game.shop === "custom";
|
||||
const sidebarIcon = isCustomGame
|
||||
@@ -31,34 +51,44 @@ export function SidebarGameItem({
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
key={game.id}
|
||||
className={cn("sidebar__menu-item", {
|
||||
"sidebar__menu-item--active":
|
||||
location.pathname === `/game/${game.shop}/${game.objectId}`,
|
||||
"sidebar__menu-item--muted": game.download?.status === "removed",
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
<>
|
||||
<li
|
||||
key={game.id}
|
||||
className={cn("sidebar__menu-item", {
|
||||
"sidebar__menu-item--active":
|
||||
location.pathname === `/game/${game.shop}/${game.objectId}`,
|
||||
"sidebar__menu-item--muted": game.download?.status === "removed",
|
||||
})}
|
||||
>
|
||||
{sidebarIcon ? (
|
||||
<img
|
||||
className="sidebar__game-icon"
|
||||
src={sidebarIcon}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
getFallbackIcon()
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={(event) => handleSidebarGameClick(event, game)}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{sidebarIcon ? (
|
||||
<img
|
||||
className="sidebar__game-icon"
|
||||
src={sidebarIcon}
|
||||
alt={game.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
getFallbackIcon()
|
||||
)}
|
||||
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<span className="sidebar__menu-item-button-label">
|
||||
{getGameTitle(game)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<GameContextMenu
|
||||
game={game}
|
||||
visible={contextMenu.visible}
|
||||
position={contextMenu.position}
|
||||
onClose={handleCloseContextMenu}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
1
src/renderer/src/components/star-rating/index.ts
Normal file
1
src/renderer/src/components/star-rating/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./star-rating";
|
||||
54
src/renderer/src/components/star-rating/star-rating.scss
Normal file
54
src/renderer/src/components/star-rating/star-rating.scss
Normal file
@@ -0,0 +1,54 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.star-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
|
||||
&__star {
|
||||
color: globals.$muted-color;
|
||||
transition: color ease 0.2s;
|
||||
|
||||
&--filled {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&--empty {
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&--half {
|
||||
color: #ffffff;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
clip-path: polygon(0 0, 50% 0, 50% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
&__half-star {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&__value {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: globals.$muted-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__calculating-text,
|
||||
&__no-rating-text {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
|
||||
&--calculating,
|
||||
&--no-rating {
|
||||
.star-rating__star {
|
||||
color: globals.$muted-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
77
src/renderer/src/components/star-rating/star-rating.tsx
Normal file
77
src/renderer/src/components/star-rating/star-rating.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { StarIcon, StarFillIcon } from "@primer/octicons-react";
|
||||
import "./star-rating.scss";
|
||||
|
||||
export interface StarRatingProps {
|
||||
rating: number | null;
|
||||
maxStars?: number;
|
||||
size?: number;
|
||||
showCalculating?: boolean;
|
||||
calculatingText?: string;
|
||||
hideIcon?: boolean;
|
||||
}
|
||||
|
||||
export function StarRating({
|
||||
rating,
|
||||
maxStars = 5,
|
||||
size = 12,
|
||||
showCalculating = false,
|
||||
calculatingText = "Calculating",
|
||||
hideIcon = false,
|
||||
}: Readonly<StarRatingProps>) {
|
||||
if (rating === null && showCalculating) {
|
||||
return (
|
||||
<div className="star-rating star-rating--calculating">
|
||||
{!hideIcon && <StarIcon size={size} />}
|
||||
<span className="star-rating__calculating-text">{calculatingText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rating === null || rating === undefined) {
|
||||
return (
|
||||
<div className="star-rating star-rating--no-rating">
|
||||
{!hideIcon && <StarIcon size={size} />}
|
||||
<span className="star-rating__no-rating-text">…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filledStars = Math.floor(rating);
|
||||
const hasHalfStar = rating % 1 >= 0.5;
|
||||
const emptyStars = maxStars - filledStars - (hasHalfStar ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="star-rating">
|
||||
{Array.from({ length: filledStars }, (_, index) => (
|
||||
<StarFillIcon
|
||||
key={`filled-${index}`}
|
||||
size={size}
|
||||
className="star-rating__star star-rating__star--filled"
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasHalfStar && (
|
||||
<div className="star-rating__half-star" key="half-star">
|
||||
<StarIcon
|
||||
size={size}
|
||||
className="star-rating__star star-rating__star--empty"
|
||||
/>
|
||||
<StarFillIcon
|
||||
size={size}
|
||||
className="star-rating__star star-rating__star--half"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array.from({ length: emptyStars }, (_, index) => (
|
||||
<StarIcon
|
||||
key={`empty-${index}`}
|
||||
size={size}
|
||||
className="star-rating__star star-rating__star--empty"
|
||||
/>
|
||||
))}
|
||||
|
||||
<span className="star-rating__value">{rating.toFixed(1)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const DOWNLOADER_NAME = {
|
||||
[Downloader.Datanodes]: "Datanodes",
|
||||
[Downloader.Mediafire]: "Mediafire",
|
||||
[Downloader.TorBox]: "TorBox",
|
||||
[Downloader.AllDebrid]: "All-Debrid",
|
||||
[Downloader.Hydra]: "Nimbus",
|
||||
};
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ export function CloudSyncContextProvider({
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [freezingArtifact, setFreezingArtifact] = useState(false);
|
||||
|
||||
const { showSuccessToast } = useToast();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const downloadGameArtifact = useCallback(
|
||||
async (gameArtifactId: string) => {
|
||||
@@ -122,9 +122,15 @@ export function CloudSyncContextProvider({
|
||||
const uploadSaveGame = useCallback(
|
||||
async (downloadOptionTitle: string | null) => {
|
||||
setUploadingBackup(true);
|
||||
window.electron.uploadSaveGame(objectId, shop, downloadOptionTitle);
|
||||
window.electron
|
||||
.uploadSaveGame(objectId, shop, downloadOptionTitle)
|
||||
.catch((err) => {
|
||||
setUploadingBackup(false);
|
||||
logger.error("Failed to upload save game", { objectId, shop, err });
|
||||
showErrorToast(t("backup_failed"));
|
||||
});
|
||||
},
|
||||
[objectId, shop]
|
||||
[objectId, shop, t, showErrorToast]
|
||||
);
|
||||
|
||||
const toggleArtifactFreeze = useCallback(
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
} from "@types";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { GameDetailsContext } from "./game-details.context.types";
|
||||
import { SteamContentDescriptor } from "@shared";
|
||||
|
||||
@@ -91,6 +92,7 @@ export function GameDetailsContextProvider({
|
||||
}, [getRepacksForObjectId, objectId]);
|
||||
|
||||
const { i18n } = useTranslation("game_details");
|
||||
const location = useLocation();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -140,29 +142,23 @@ export function GameDetailsContextProvider({
|
||||
}
|
||||
});
|
||||
|
||||
const statsPromise = window.electron
|
||||
.getGameStats(objectId, shop)
|
||||
.then((result) => {
|
||||
if (abortController.signal.aborted) return null;
|
||||
setStats(result);
|
||||
return result;
|
||||
});
|
||||
window.electron.getGameStats(objectId, shop).then((result) => {
|
||||
if (abortController.signal.aborted) return;
|
||||
setStats(result);
|
||||
});
|
||||
|
||||
Promise.all([shopDetailsPromise, statsPromise])
|
||||
.then(([_, stats]) => {
|
||||
if (stats) {
|
||||
const assets = stats.assets;
|
||||
if (assets) {
|
||||
window.electron.saveGameShopAssets(objectId, shop, assets);
|
||||
const assetsPromise = window.electron.getGameAssets(objectId, shop);
|
||||
|
||||
setShopDetails((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
assets,
|
||||
};
|
||||
});
|
||||
}
|
||||
Promise.all([shopDetailsPromise, assetsPromise])
|
||||
.then(([_, assets]) => {
|
||||
if (assets) {
|
||||
setShopDetails((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
assets,
|
||||
};
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -177,7 +173,7 @@ export function GameDetailsContextProvider({
|
||||
if (abortController.signal.aborted) return;
|
||||
setAchievements(achievements);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => void 0);
|
||||
}
|
||||
}, [
|
||||
updateGame,
|
||||
@@ -198,6 +194,19 @@ export function GameDetailsContextProvider({
|
||||
dispatch(setHeaderTitle(gameTitle));
|
||||
}, [objectId, gameTitle, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const state =
|
||||
(location && (location.state as Record<string, unknown>)) || {};
|
||||
if (state.openRepacks) {
|
||||
setShowRepacksModal(true);
|
||||
try {
|
||||
window.history.replaceState({}, document.title, location.pathname);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
useEffect(() => {
|
||||
if (game?.title) {
|
||||
dispatch(setHeaderTitle(game.title));
|
||||
@@ -222,6 +231,61 @@ export function GameDetailsContextProvider({
|
||||
};
|
||||
}, [game?.id, isGameRunning, updateGame]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
try {
|
||||
const detail = (ev as CustomEvent).detail || {};
|
||||
if (detail.objectId && detail.objectId === objectId) {
|
||||
setShowRepacksModal(true);
|
||||
}
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("hydra:openRepacks", handler as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hydra:openRepacks", handler as EventListener);
|
||||
};
|
||||
}, [objectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
try {
|
||||
const detail = (ev as CustomEvent).detail || {};
|
||||
if (detail.objectId && detail.objectId === objectId) {
|
||||
setShowGameOptionsModal(true);
|
||||
}
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("hydra:openGameOptions", handler as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"hydra:openGameOptions",
|
||||
handler as EventListener
|
||||
);
|
||||
};
|
||||
}, [objectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const state =
|
||||
(location && (location.state as Record<string, unknown>)) || {};
|
||||
if (state.openGameOptions) {
|
||||
setShowGameOptionsModal(true);
|
||||
|
||||
try {
|
||||
window.history.replaceState({}, document.title, location.pathname);
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const lastDownloadedOption = useMemo(() => {
|
||||
if (game?.download) {
|
||||
const repack = repacks.find((repack) =>
|
||||
|
||||
@@ -63,6 +63,7 @@ export function SettingsContextProvider({
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const defaultSourceUrl = searchParams.get("urls");
|
||||
const defaultTab = searchParams.get("tab");
|
||||
const defaultAppearanceTheme = searchParams.get("theme");
|
||||
const defaultAppearanceAuthorId = searchParams.get("authorId");
|
||||
const defaultAppearanceAuthorName = searchParams.get("authorName");
|
||||
@@ -77,6 +78,13 @@ export function SettingsContextProvider({
|
||||
}
|
||||
}, [defaultSourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultTab) {
|
||||
const idx = Number(defaultTab);
|
||||
if (!Number.isNaN(idx)) setCurrentCategoryIndex(idx);
|
||||
}
|
||||
}, [defaultTab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (appearance.theme) setCurrentCategoryIndex(3);
|
||||
}, [appearance.theme]);
|
||||
|
||||
42
src/renderer/src/declaration.d.ts
vendored
42
src/renderer/src/declaration.d.ts
vendored
@@ -9,6 +9,7 @@ import type {
|
||||
UserPreferences,
|
||||
StartGameDownloadPayload,
|
||||
RealDebridUser,
|
||||
AllDebridUser,
|
||||
UserProfile,
|
||||
FriendRequest,
|
||||
FriendRequestAction,
|
||||
@@ -38,6 +39,7 @@ import type {
|
||||
AchievementCustomNotificationPosition,
|
||||
AchievementNotificationInfo,
|
||||
UserLibraryResponse,
|
||||
Game,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type disk from "diskusage";
|
||||
@@ -76,11 +78,6 @@ declare global {
|
||||
skip: number
|
||||
) => Promise<{ edges: CatalogueSearchResult[]; count: number }>;
|
||||
getCatalogue: (category: CatalogueCategory) => Promise<ShopAssets[]>;
|
||||
saveGameShopAssets: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
assets: ShopAssets
|
||||
) => Promise<void>;
|
||||
getGameShopDetails: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
@@ -92,7 +89,39 @@ declare global {
|
||||
shop: GameShop
|
||||
) => Promise<HowLongToBeatCategory[] | null>;
|
||||
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
|
||||
getGameAssets: (
|
||||
objectId: string,
|
||||
shop: GameShop
|
||||
) => Promise<ShopAssets | null>;
|
||||
getTrendingGames: () => Promise<TrendingGame[]>;
|
||||
createGameReview: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
reviewHtml: string,
|
||||
score: number
|
||||
) => Promise<void>;
|
||||
getGameReviews: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
take?: number,
|
||||
skip?: number,
|
||||
sortBy?: string
|
||||
) => Promise<any[]>;
|
||||
voteReview: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
reviewId: string,
|
||||
voteType: "upvote" | "downvote"
|
||||
) => Promise<void>;
|
||||
deleteReview: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
reviewId: string
|
||||
) => Promise<void>;
|
||||
checkGameReview: (
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<{ hasReviewed: boolean }>;
|
||||
onUpdateAchievements: (
|
||||
objectId: string,
|
||||
shop: GameShop,
|
||||
@@ -212,6 +241,9 @@ declare global {
|
||||
) => Promise<void>;
|
||||
/* User preferences */
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
authenticateAllDebrid: (
|
||||
apiKey: string
|
||||
) => Promise<AllDebridUser | { error_code: string }>;
|
||||
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
|
||||
getUserPreferences: () => Promise<UserPreferences | null>;
|
||||
updateUserPreferences: (
|
||||
|
||||
@@ -68,6 +68,7 @@ export function useUserDetails() {
|
||||
username: userDetails?.username || "",
|
||||
subscription: userDetails?.subscription || null,
|
||||
featurebaseJwt: userDetails?.featurebaseJwt || "",
|
||||
karma: userDetails?.karma || 0,
|
||||
});
|
||||
},
|
||||
[
|
||||
|
||||
@@ -114,6 +114,15 @@ export function DownloadGroup({
|
||||
return <p>{t("deleting")}</p>;
|
||||
}
|
||||
|
||||
if (download.downloader === Downloader.AllDebrid) {
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
<p>{t("alldebrid_size_not_supported")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGameDownloading) {
|
||||
if (lastPacket?.isDownloadingMetadata) {
|
||||
return <p>{t("downloading_metadata")}</p>;
|
||||
@@ -181,6 +190,15 @@ export function DownloadGroup({
|
||||
}
|
||||
|
||||
if (download.status === "active") {
|
||||
if ((download.downloader as unknown as string) === "alldebrid") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(download.progress)}</p>
|
||||
<p>{t("alldebrid_size_not_supported")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(download.progress)}</p>
|
||||
@@ -275,7 +293,9 @@ export function DownloadGroup({
|
||||
(download?.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(download?.downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken);
|
||||
!userPreferences?.torBoxApiToken) ||
|
||||
(download?.downloader === Downloader.AllDebrid &&
|
||||
!userPreferences?.allDebridApiKey);
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.description-header {
|
||||
width: calc(100% - calc(globals.$spacing-unit * 2));
|
||||
margin: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 1);
|
||||
width: 100%;
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -12,6 +11,7 @@
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: calc(globals.$spacing-unit * 1);
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
&__preview {
|
||||
width: 100%;
|
||||
padding: globals.$spacing-unit 0;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
|
||||
@@ -1,33 +1,114 @@
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { PencilIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
ClockIcon,
|
||||
NoteIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { ThumbsUp, ThumbsDown, Star } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { motion } from "framer-motion";
|
||||
import type { GameReview } from "@types";
|
||||
|
||||
import { HeroPanel } from "./hero";
|
||||
import { DescriptionHeader } from "./description-header/description-header";
|
||||
import { GallerySlider } from "./gallery-slider/gallery-slider";
|
||||
import { Sidebar } from "./sidebar/sidebar";
|
||||
import { EditGameModal } from "./modals";
|
||||
import { EditGameModal, DeleteReviewModal } from "./modals";
|
||||
import { ReviewSortOptions } from "./review-sort-options";
|
||||
import { ReviewPromptBanner } from "./review-prompt-banner";
|
||||
|
||||
import { sanitizeHtml, AuthPage } from "@shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
|
||||
import { AuthPage } from "@shared";
|
||||
|
||||
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
|
||||
import { useUserDetails, useLibrary } from "@renderer/hooks";
|
||||
import { useUserDetails, useLibrary, useDate, useToast } from "@renderer/hooks";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import "./game-details.scss";
|
||||
|
||||
const getScoreColorClass = (score: number): string => {
|
||||
if (score >= 1 && score <= 2) return "game-details__review-score--red";
|
||||
if (score >= 3 && score <= 3) return "game-details__review-score--yellow";
|
||||
if (score >= 4 && score <= 5) return "game-details__review-score--green";
|
||||
return "";
|
||||
};
|
||||
|
||||
const processMediaElements = (document: Document) => {
|
||||
const $images = Array.from(document.querySelectorAll("img"));
|
||||
$images.forEach(($image) => {
|
||||
$image.loading = "lazy";
|
||||
$image.removeAttribute("width");
|
||||
$image.removeAttribute("height");
|
||||
$image.removeAttribute("style");
|
||||
$image.style.maxWidth = "100%";
|
||||
$image.style.width = "auto";
|
||||
$image.style.height = "auto";
|
||||
$image.style.boxSizing = "border-box";
|
||||
});
|
||||
|
||||
// Handle videos the same way
|
||||
const $videos = Array.from(document.querySelectorAll("video"));
|
||||
$videos.forEach(($video) => {
|
||||
$video.removeAttribute("width");
|
||||
$video.removeAttribute("height");
|
||||
$video.removeAttribute("style");
|
||||
$video.style.maxWidth = "100%";
|
||||
$video.style.width = "auto";
|
||||
$video.style.height = "auto";
|
||||
$video.style.boxSizing = "border-box";
|
||||
});
|
||||
};
|
||||
|
||||
const getSelectScoreColorClass = (score: number): string => {
|
||||
if (score >= 1 && score <= 2) return "game-details__review-score-select--red";
|
||||
if (score >= 3 && score <= 3)
|
||||
return "game-details__review-score-select--yellow";
|
||||
if (score >= 4 && score <= 5)
|
||||
return "game-details__review-score-select--green";
|
||||
return "";
|
||||
};
|
||||
|
||||
const getRatingText = (score: number, t: (key: string) => string): string => {
|
||||
switch (score) {
|
||||
case 1:
|
||||
return t("rating_very_negative");
|
||||
case 2:
|
||||
return t("rating_negative");
|
||||
case 3:
|
||||
return t("rating_neutral");
|
||||
case 4:
|
||||
return t("rating_positive");
|
||||
case 5:
|
||||
return t("rating_very_positive");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export function GameDetailsContent() {
|
||||
const heroRef = useRef<HTMLDivElement | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { objectId, shopDetails, game, hasNSFWContentBlocked, updateGame } =
|
||||
useContext(gameDetailsContext);
|
||||
const {
|
||||
objectId,
|
||||
shopDetails,
|
||||
game,
|
||||
hasNSFWContentBlocked,
|
||||
updateGame,
|
||||
shop,
|
||||
} = useContext(gameDetailsContext);
|
||||
|
||||
const { showHydraCloudModal } = useSubscription();
|
||||
|
||||
const { userDetails, hasActiveSubscription } = useUserDetails();
|
||||
const { updateLibrary } = useLibrary();
|
||||
const { updateLibrary, library } = useLibrary();
|
||||
const { formatDistance } = useDate();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const { setShowCloudSyncModal, getGameArtifacts } =
|
||||
useContext(cloudSyncContext);
|
||||
@@ -40,33 +121,7 @@ export function GameDetailsContent() {
|
||||
"text/html"
|
||||
);
|
||||
|
||||
const $images = Array.from(document.querySelectorAll("img"));
|
||||
$images.forEach(($image) => {
|
||||
$image.loading = "lazy";
|
||||
// Remove any inline width/height styles that might cause overflow
|
||||
$image.removeAttribute("width");
|
||||
$image.removeAttribute("height");
|
||||
$image.removeAttribute("style");
|
||||
// Set max-width to prevent overflow
|
||||
$image.style.maxWidth = "100%";
|
||||
$image.style.width = "auto";
|
||||
$image.style.height = "auto";
|
||||
$image.style.boxSizing = "border-box";
|
||||
});
|
||||
|
||||
// Handle videos the same way
|
||||
const $videos = Array.from(document.querySelectorAll("video"));
|
||||
$videos.forEach(($video) => {
|
||||
// Remove any inline width/height styles that might cause overflow
|
||||
$video.removeAttribute("width");
|
||||
$video.removeAttribute("height");
|
||||
$video.removeAttribute("style");
|
||||
// Set max-width to prevent overflow
|
||||
$video.style.maxWidth = "100%";
|
||||
$video.style.width = "auto";
|
||||
$video.style.height = "auto";
|
||||
$video.style.boxSizing = "border-box";
|
||||
});
|
||||
processMediaElements(document);
|
||||
|
||||
return document.body.outerHTML;
|
||||
}
|
||||
@@ -80,6 +135,88 @@ export function GameDetailsContent() {
|
||||
|
||||
const [backdropOpacity, setBackdropOpacity] = useState(1);
|
||||
const [showEditGameModal, setShowEditGameModal] = useState(false);
|
||||
const [showDeleteReviewModal, setShowDeleteReviewModal] = useState(false);
|
||||
const [reviewToDelete, setReviewToDelete] = useState<string | null>(null);
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
|
||||
// Reviews state management
|
||||
const [reviews, setReviews] = useState<GameReview[]>([]);
|
||||
const [reviewsLoading, setReviewsLoading] = useState(false);
|
||||
const [reviewScore, setReviewScore] = useState<number | null>(null);
|
||||
const [submittingReview, setSubmittingReview] = useState(false);
|
||||
const [reviewCharCount, setReviewCharCount] = useState(0);
|
||||
const MAX_REVIEW_CHARS = 1000;
|
||||
const [reviewsSortBy, setReviewsSortBy] = useState("newest");
|
||||
const [reviewsPage, setReviewsPage] = useState(0);
|
||||
const [hasMoreReviews, setHasMoreReviews] = useState(true);
|
||||
const [visibleBlockedReviews, setVisibleBlockedReviews] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [totalReviewCount, setTotalReviewCount] = useState(0);
|
||||
const [showReviewForm, setShowReviewForm] = useState(false);
|
||||
|
||||
const [showReviewPrompt, setShowReviewPrompt] = useState(false);
|
||||
const [hasUserReviewed, setHasUserReviewed] = useState(false);
|
||||
const [reviewCheckLoading, setReviewCheckLoading] = useState(false);
|
||||
|
||||
// Check if the current game is in the user's library
|
||||
const isGameInLibrary = useMemo(() => {
|
||||
if (!library || !shop || !objectId) return false;
|
||||
return library.some(
|
||||
(libItem) => libItem.shop === shop && libItem.objectId === objectId
|
||||
);
|
||||
}, [library, shop, objectId]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
link: false,
|
||||
}),
|
||||
],
|
||||
content: "",
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "game-details__review-editor",
|
||||
"data-placeholder": t("write_review_placeholder"),
|
||||
},
|
||||
handlePaste: (view, event) => {
|
||||
const htmlContent = event.clipboardData?.getData("text/html") || "";
|
||||
const plainText = event.clipboardData?.getData("text/plain") || "";
|
||||
|
||||
const currentText = view.state.doc.textContent;
|
||||
const remainingChars = MAX_REVIEW_CHARS - currentText.length;
|
||||
|
||||
if ((htmlContent || plainText) && remainingChars > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
if (htmlContent) {
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
const textLength = tempDiv.textContent?.length || 0;
|
||||
|
||||
if (textLength <= remainingChars) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const truncatedText = plainText.slice(0, remainingChars);
|
||||
view.dispatch(view.state.tr.insertText(truncatedText));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const text = editor.getText();
|
||||
setReviewCharCount(text.length);
|
||||
|
||||
if (text.length > MAX_REVIEW_CHARS) {
|
||||
const truncatedContent = text.slice(0, MAX_REVIEW_CHARS);
|
||||
editor.commands.setContent(truncatedContent);
|
||||
setReviewCharCount(MAX_REVIEW_CHARS);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBackdropOpacity(1);
|
||||
@@ -114,7 +251,207 @@ export function GameDetailsContent() {
|
||||
|
||||
const isCustomGame = game?.shop === "custom";
|
||||
|
||||
// Helper function to get image with custom asset priority
|
||||
const checkUserReview = async () => {
|
||||
if (!objectId || !userDetails) return;
|
||||
|
||||
setReviewCheckLoading(true);
|
||||
try {
|
||||
const response = await window.electron.checkGameReview(shop, objectId);
|
||||
const hasReviewed = (response as any)?.hasReviewed || false;
|
||||
setHasUserReviewed(hasReviewed);
|
||||
|
||||
if (
|
||||
!hasReviewed &&
|
||||
!sessionStorage.getItem(`reviewPromptDismissed_${objectId}`)
|
||||
) {
|
||||
setShowReviewPrompt(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check user review:", error);
|
||||
} finally {
|
||||
setReviewCheckLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadReviews = async (reset = false) => {
|
||||
if (!objectId) return;
|
||||
|
||||
setReviewsLoading(true);
|
||||
try {
|
||||
const skip = reset ? 0 : reviewsPage * 20;
|
||||
const response = await window.electron.getGameReviews(
|
||||
shop,
|
||||
objectId,
|
||||
20,
|
||||
skip,
|
||||
reviewsSortBy
|
||||
);
|
||||
|
||||
const reviewsData = (response as any)?.reviews || [];
|
||||
const reviewCount = (response as any)?.totalCount || 0;
|
||||
|
||||
if (reset) {
|
||||
setReviews(reviewsData);
|
||||
setReviewsPage(0);
|
||||
setTotalReviewCount(reviewCount);
|
||||
} else {
|
||||
setReviews((prev) => [...prev, ...reviewsData]);
|
||||
}
|
||||
|
||||
setHasMoreReviews(reviewsData.length === 20);
|
||||
} catch (error) {
|
||||
console.error("Failed to load reviews:", error);
|
||||
} finally {
|
||||
setReviewsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoteReview = async (
|
||||
reviewId: string,
|
||||
voteType: "upvote" | "downvote"
|
||||
) => {
|
||||
if (!objectId) return;
|
||||
|
||||
try {
|
||||
await window.electron.voteReview(shop, objectId, reviewId, voteType);
|
||||
loadReviews(true);
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${voteType} review:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteReview = async (reviewId: string) => {
|
||||
setReviewToDelete(reviewId);
|
||||
setShowDeleteReviewModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteReview = async () => {
|
||||
if (!objectId || !reviewToDelete) return;
|
||||
|
||||
try {
|
||||
await window.electron.deleteReview(shop, objectId, reviewToDelete);
|
||||
loadReviews(true);
|
||||
setShowDeleteReviewModal(false);
|
||||
setReviewToDelete(null);
|
||||
showSuccessToast(t("review_deleted_successfully"));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete review:", error);
|
||||
showErrorToast(t("review_deletion_failed"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitReview = async () => {
|
||||
const reviewHtml = editor?.getHTML() || "";
|
||||
const reviewText = editor?.getText() || "";
|
||||
|
||||
if (!objectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reviewText.trim()) {
|
||||
showErrorToast(t("review_cannot_be_empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (submittingReview || reviewCharCount > MAX_REVIEW_CHARS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reviewScore === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmittingReview(true);
|
||||
|
||||
try {
|
||||
await window.electron.createGameReview(
|
||||
shop,
|
||||
objectId,
|
||||
reviewHtml,
|
||||
reviewScore
|
||||
);
|
||||
|
||||
editor?.commands.clearContent();
|
||||
setReviewScore(null);
|
||||
showSuccessToast(t("review_submitted_successfully"));
|
||||
|
||||
await loadReviews(true);
|
||||
setShowReviewForm(false);
|
||||
setShowReviewPrompt(false);
|
||||
setHasUserReviewed(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to submit review:", error);
|
||||
showErrorToast(t("review_submission_failed"));
|
||||
} finally {
|
||||
setSubmittingReview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReviewPromptYes = () => {
|
||||
setShowReviewPrompt(false);
|
||||
setShowReviewForm(true);
|
||||
|
||||
setTimeout(() => {
|
||||
const reviewFormElement = document.querySelector(
|
||||
".game-details__review-form"
|
||||
);
|
||||
if (reviewFormElement) {
|
||||
reviewFormElement.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleReviewPromptLater = () => {
|
||||
setShowReviewPrompt(false);
|
||||
if (objectId) {
|
||||
sessionStorage.setItem(`reviewPromptDismissed_${objectId}`, "true");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortChange = (newSortBy: string) => {
|
||||
if (newSortBy !== reviewsSortBy) {
|
||||
setReviewsSortBy(newSortBy);
|
||||
setReviewsPage(0);
|
||||
setHasMoreReviews(true);
|
||||
loadReviews(true);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleBlockedReview = (reviewId: string) => {
|
||||
setVisibleBlockedReviews((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(reviewId)) {
|
||||
newSet.delete(reviewId);
|
||||
} else {
|
||||
newSet.add(reviewId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const loadMoreReviews = () => {
|
||||
if (!reviewsLoading && hasMoreReviews) {
|
||||
setReviewsPage((prev) => prev + 1);
|
||||
loadReviews(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (objectId && (game || shop)) {
|
||||
loadReviews(true);
|
||||
checkUserReview();
|
||||
}
|
||||
}, [game, shop, objectId, reviewsSortBy, userDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reviewsPage > 0) {
|
||||
loadReviews(false);
|
||||
}
|
||||
}, [reviewsPage]);
|
||||
|
||||
const getImageWithCustomPriority = (
|
||||
customUrl: string | null | undefined,
|
||||
originalUrl: string | null | undefined,
|
||||
@@ -191,14 +528,16 @@ export function GameDetailsContent() {
|
||||
{renderGameLogo()}
|
||||
|
||||
<div className="game-details__hero-buttons game-details__hero-buttons--right">
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__edit-custom-game-button"
|
||||
onClick={handleEditGameClick}
|
||||
title={t("edit_game_modal_button")}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</button>
|
||||
{game && (
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__edit-custom-game-button"
|
||||
onClick={handleEditGameClick}
|
||||
title={t("edit_game_modal_button")}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{game?.shop !== "custom" && (
|
||||
<button
|
||||
@@ -225,6 +564,19 @@ export function GameDetailsContent() {
|
||||
|
||||
<div className="game-details__description-container">
|
||||
<div className="game-details__description-content">
|
||||
{/* Review Prompt Banner */}
|
||||
{game?.shop !== "custom" &&
|
||||
showReviewPrompt &&
|
||||
userDetails &&
|
||||
!hasUserReviewed &&
|
||||
!reviewCheckLoading &&
|
||||
isGameInLibrary && (
|
||||
<ReviewPromptBanner
|
||||
onYesClick={handleReviewPromptYes}
|
||||
onLaterClick={handleReviewPromptLater}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DescriptionHeader />
|
||||
<GallerySlider />
|
||||
|
||||
@@ -232,20 +584,370 @@ export function GameDetailsContent() {
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: aboutTheGame,
|
||||
}}
|
||||
className="game-details__description"
|
||||
className={`game-details__description ${
|
||||
isDescriptionExpanded
|
||||
? "game-details__description--expanded"
|
||||
: "game-details__description--collapsed"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{aboutTheGame && aboutTheGame.length > 500 && (
|
||||
<button
|
||||
type="button"
|
||||
className="game-details__description-toggle"
|
||||
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
|
||||
>
|
||||
{isDescriptionExpanded ? t("show_less") : t("show_more")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{game?.shop !== "custom" && (
|
||||
<div className="game-details__reviews-section">
|
||||
{showReviewForm && (
|
||||
<>
|
||||
<div className="game-details__reviews-header">
|
||||
<h3 className="game-details__reviews-title">
|
||||
{t("leave_a_review")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="game-details__review-form">
|
||||
<div className="game-details__review-input-container">
|
||||
<div className="game-details__review-input-header">
|
||||
<div className="game-details__review-editor-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleBold().run()
|
||||
}
|
||||
className={`game-details__editor-button ${editor?.isActive("bold") ? "is-active" : ""}`}
|
||||
disabled={!editor}
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleItalic().run()
|
||||
}
|
||||
className={`game-details__editor-button ${editor?.isActive("italic") ? "is-active" : ""}`}
|
||||
disabled={!editor}
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
editor?.chain().focus().toggleUnderline().run()
|
||||
}
|
||||
className={`game-details__editor-button ${editor?.isActive("underline") ? "is-active" : ""}`}
|
||||
disabled={!editor}
|
||||
>
|
||||
<u>U</u>
|
||||
</button>
|
||||
</div>
|
||||
<div className="game-details__review-char-counter">
|
||||
<span
|
||||
className={
|
||||
reviewCharCount > MAX_REVIEW_CHARS
|
||||
? "over-limit"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{reviewCharCount}/{MAX_REVIEW_CHARS}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="game-details__review-input">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="game-details__review-form-bottom">
|
||||
<div className="game-details__review-score-container">
|
||||
<div className="game-details__star-rating">
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<button
|
||||
key={starValue}
|
||||
type="button"
|
||||
className={`game-details__star ${
|
||||
reviewScore && starValue <= reviewScore
|
||||
? "game-details__star--filled"
|
||||
: "game-details__star--empty"
|
||||
} ${
|
||||
reviewScore && starValue <= reviewScore
|
||||
? getSelectScoreColorClass(reviewScore)
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setReviewScore(starValue)}
|
||||
title={getRatingText(starValue, t)}
|
||||
>
|
||||
<Star
|
||||
size={24}
|
||||
fill={
|
||||
reviewScore && starValue <= reviewScore
|
||||
? "currentColor"
|
||||
: "none"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="game-details__review-submit-button"
|
||||
onClick={handleSubmitReview}
|
||||
disabled={
|
||||
!editor?.getHTML().trim() ||
|
||||
reviewScore === null ||
|
||||
submittingReview ||
|
||||
reviewCharCount > MAX_REVIEW_CHARS
|
||||
}
|
||||
>
|
||||
{submittingReview
|
||||
? t("submitting")
|
||||
: t("submit_review")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showReviewForm && (
|
||||
<div className="game-details__reviews-separator"></div>
|
||||
)}
|
||||
|
||||
<div className="game-details__reviews-list">
|
||||
<div className="game-details__reviews-list-header">
|
||||
<div className="game-details__reviews-title-group">
|
||||
<h3 className="game-details__reviews-title">
|
||||
{t("reviews")}
|
||||
</h3>
|
||||
<span className="game-details__reviews-badge">
|
||||
{totalReviewCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ReviewSortOptions
|
||||
sortBy={reviewsSortBy as any}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
|
||||
{reviewsLoading && reviews.length === 0 && (
|
||||
<div className="game-details__reviews-loading">
|
||||
{t("loading_reviews")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!reviewsLoading && reviews.length === 0 && (
|
||||
<div className="game-details__reviews-empty">
|
||||
<div className="game-details__reviews-empty-icon">
|
||||
<NoteIcon size={48} />
|
||||
</div>
|
||||
<h4 className="game-details__reviews-empty-title">
|
||||
{t("no_reviews_yet")}
|
||||
</h4>
|
||||
<p className="game-details__reviews-empty-message">
|
||||
{t("be_first_to_review")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reviews.map((review) => (
|
||||
<div key={review.id} className="game-details__review-item">
|
||||
{review.isBlocked &&
|
||||
!visibleBlockedReviews.has(review.id) ? (
|
||||
<div className="game-details__blocked-review-simple">
|
||||
Review from blocked user —{" "}
|
||||
<button
|
||||
className="game-details__blocked-review-show-link"
|
||||
onClick={() => toggleBlockedReview(review.id)}
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="game-details__review-header">
|
||||
<div className="game-details__review-user">
|
||||
{review.user?.profileImageUrl && (
|
||||
<img
|
||||
src={review.user.profileImageUrl}
|
||||
alt={review.user.displayName || "User"}
|
||||
className="game-details__review-avatar"
|
||||
/>
|
||||
)}
|
||||
<div className="game-details__review-user-info">
|
||||
<button
|
||||
className="game-details__review-display-name game-details__review-display-name--clickable"
|
||||
onClick={() =>
|
||||
review.user?.id &&
|
||||
navigate(`/profile/${review.user.id}`)
|
||||
}
|
||||
>
|
||||
{review.user?.displayName || "Anonymous"}
|
||||
</button>
|
||||
<div className="game-details__review-date">
|
||||
<ClockIcon size={12} />
|
||||
{formatDistance(
|
||||
new Date(review.createdAt),
|
||||
new Date(),
|
||||
{ addSuffix: true }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="game-details__review-score-stars"
|
||||
title={getRatingText(review.score, t)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((starValue) => (
|
||||
<Star
|
||||
key={starValue}
|
||||
size={20}
|
||||
fill={
|
||||
starValue <= review.score
|
||||
? "currentColor"
|
||||
: "none"
|
||||
}
|
||||
className={`game-details__review-star ${
|
||||
starValue <= review.score
|
||||
? "game-details__review-star--filled"
|
||||
: "game-details__review-star--empty"
|
||||
} ${
|
||||
starValue <= review.score
|
||||
? getScoreColorClass(review.score)
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="game-details__review-content"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(review.reviewHtml),
|
||||
}}
|
||||
/>
|
||||
<div className="game-details__review-actions">
|
||||
<div className="game-details__review-votes">
|
||||
<motion.button
|
||||
className={`game-details__vote-button game-details__vote-button--upvote ${review.hasUpvoted ? "game-details__vote-button--active" : ""}`}
|
||||
onClick={() =>
|
||||
handleVoteReview(review.id, "upvote")
|
||||
}
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
transition: { duration: 0.1 },
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
transition: { duration: 0.2 },
|
||||
}}
|
||||
animate={
|
||||
review.hasUpvoted
|
||||
? {
|
||||
scale: [1, 1.2, 1],
|
||||
transition: { duration: 0.3 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<ThumbsUp size={16} />
|
||||
<span>{review.upvotes || 0}</span>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className={`game-details__vote-button game-details__vote-button--downvote ${review.hasDownvoted ? "game-details__vote-button--active" : ""}`}
|
||||
onClick={() =>
|
||||
handleVoteReview(review.id, "downvote")
|
||||
}
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
transition: { duration: 0.1 },
|
||||
}}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
transition: { duration: 0.2 },
|
||||
}}
|
||||
animate={
|
||||
review.hasDownvoted
|
||||
? {
|
||||
scale: [1, 1.2, 1],
|
||||
transition: { duration: 0.3 },
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
<span>{review.downvotes || 0}</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
{userDetails?.id === review.user?.id && (
|
||||
<button
|
||||
className="game-details__delete-review-button"
|
||||
onClick={() => handleDeleteReview(review.id)}
|
||||
title={t("delete_review")}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
<span>{t("remove_review")}</span>
|
||||
</button>
|
||||
)}
|
||||
{review.isBlocked &&
|
||||
visibleBlockedReviews.has(review.id) && (
|
||||
<button
|
||||
className="game-details__blocked-review-hide-link"
|
||||
onClick={() => toggleBlockedReview(review.id)}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasMoreReviews && !reviewsLoading && (
|
||||
<button
|
||||
className="game-details__load-more-reviews"
|
||||
onClick={loadMoreReviews}
|
||||
>
|
||||
{t("load_more_reviews")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{reviewsLoading && reviews.length > 0 && (
|
||||
<div className="game-details__reviews-loading">
|
||||
{t("loading_more_reviews")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{game?.shop !== "custom" && <Sidebar />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EditGameModal
|
||||
visible={showEditGameModal}
|
||||
onClose={() => setShowEditGameModal(false)}
|
||||
game={game}
|
||||
shopDetails={shopDetails}
|
||||
onGameUpdated={handleGameUpdated}
|
||||
{game && (
|
||||
<EditGameModal
|
||||
visible={showEditGameModal}
|
||||
onClose={() => setShowEditGameModal(false)}
|
||||
game={game}
|
||||
shopDetails={shopDetails}
|
||||
onGameUpdated={handleGameUpdated}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteReviewModal
|
||||
visible={showDeleteReviewModal}
|
||||
onClose={() => {
|
||||
setShowDeleteReviewModal(false);
|
||||
setReviewToDelete(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteReview}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,6 +35,11 @@ export function GameDetailsSkeleton() {
|
||||
))}
|
||||
<Skeleton className="game-details__hero-image-skeleton" />
|
||||
<Skeleton />
|
||||
<Skeleton
|
||||
width={120}
|
||||
height={36}
|
||||
className="game-details__description-toggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="content-sidebar">
|
||||
|
||||
@@ -27,6 +27,517 @@ $hero-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
&__review-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&__review-form-controls {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
&__review-form-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__review-message {
|
||||
padding: calc(globals.$spacing-unit * 1);
|
||||
border-radius: 4px;
|
||||
font-size: globals.$small-font-size;
|
||||
font-weight: 500;
|
||||
margin-top: calc(globals.$spacing-unit * 1);
|
||||
border: 1px solid;
|
||||
|
||||
&--success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #86efac;
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
&--error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #fca5a5;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__review-score-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__review-score-label {
|
||||
font-size: 14px;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__review-score-select {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&--red {
|
||||
border-color: #e74c3c;
|
||||
background-color: rgba(231, 76, 60, 0.1);
|
||||
}
|
||||
|
||||
&--yellow {
|
||||
border-color: #f39c12;
|
||||
background-color: rgba(243, 156, 18, 0.1);
|
||||
}
|
||||
|
||||
&--green {
|
||||
border-color: #27ae60;
|
||||
background-color: rgba(39, 174, 96, 0.1);
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: #2a2a2a;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
&__star-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__star {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666666;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&--filled {
|
||||
color: #ffffff;
|
||||
|
||||
&.game-details__review-score-select--red {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
&.game-details__review-score-select--yellow {
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
&.game-details__review-score-select--green {
|
||||
color: #27ae60;
|
||||
}
|
||||
}
|
||||
|
||||
&--empty {
|
||||
color: #666666;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
&__reviews-sort {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 0.75);
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
&__reviews-sort-label {
|
||||
display: block;
|
||||
font-size: globals.$body-font-size;
|
||||
color: globals.$body-color;
|
||||
}
|
||||
|
||||
&__reviews-sort-select {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid globals.$border-color;
|
||||
border-radius: 4px;
|
||||
padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1);
|
||||
color: globals.$body-color;
|
||||
font-size: globals.$body-font-size;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-color: globals.$brand-teal;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: globals.$dark-background-color;
|
||||
color: globals.$body-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__review-submit-button {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
cursor: not-allowed;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&__reviews-list {
|
||||
margin-top: calc(globals.$spacing-unit * 3);
|
||||
}
|
||||
|
||||
&__reviews-separator {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
margin: calc(globals.$spacing-unit * 3) 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__reviews-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: calc(globals.$spacing-unit * 1);
|
||||
}
|
||||
|
||||
&__reviews-empty {
|
||||
text-align: center;
|
||||
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__reviews-empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
&__reviews-empty-title {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 600;
|
||||
margin: 0 0 calc(globals.$spacing-unit * 1) 0;
|
||||
}
|
||||
|
||||
&__reviews-empty-message {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: globals.$small-font-size;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__review-item {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
&__review-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
|
||||
&__review-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
}
|
||||
|
||||
&__review-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&__review-user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 0.25);
|
||||
}
|
||||
|
||||
&__review-display-name {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: globals.$small-font-size;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__review-actions {
|
||||
margin-top: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__review-votes {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__vote-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
color: #ccc;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
&--upvote:hover {
|
||||
color: #4caf50;
|
||||
border-color: #4caf50;
|
||||
}
|
||||
|
||||
&--downvote:hover {
|
||||
color: #f44336;
|
||||
border-color: #f44336;
|
||||
}
|
||||
|
||||
&--active {
|
||||
&.game-details__vote-button--upvote {
|
||||
svg {
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
|
||||
&.game-details__vote-button--downvote {
|
||||
svg {
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
&__delete-review-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(244, 67, 54, 0.1);
|
||||
border: 1px solid rgba(244, 67, 54, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
color: #f44336;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
gap: 6px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(244, 67, 54, 0.2);
|
||||
border-color: #f44336;
|
||||
color: #ff5722;
|
||||
}
|
||||
}
|
||||
|
||||
&__blocked-review-simple {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: globals.$small-font-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 0.5);
|
||||
}
|
||||
|
||||
&__blocked-review-show-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ffc107;
|
||||
font-size: globals.$small-font-size;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #ffeb3b;
|
||||
}
|
||||
}
|
||||
|
||||
&__blocked-review-hide-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: globals.$small-font-size;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&__review-score-stars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__review-star {
|
||||
color: #666666;
|
||||
transition: color 0.2s ease;
|
||||
cursor: default;
|
||||
|
||||
&--filled {
|
||||
color: #ffffff;
|
||||
|
||||
&.game-details__review-score--red {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
&.game-details__review-score--yellow {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
&.game-details__review-score--green {
|
||||
color: #86efac;
|
||||
}
|
||||
}
|
||||
|
||||
&--empty {
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
&__review-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: globals.$small-font-size;
|
||||
}
|
||||
|
||||
&__review-content {
|
||||
color: globals.$body-color;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&__reviews-loading {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__load-more-reviews {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid globals.$border-color;
|
||||
color: globals.$body-color;
|
||||
padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 2);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: globals.$body-font-size;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
margin-top: calc(globals.$spacing-unit * 2);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-color: globals.$brand-teal;
|
||||
}
|
||||
}
|
||||
|
||||
&__hero {
|
||||
width: 100%;
|
||||
height: $hero-height;
|
||||
@@ -182,6 +693,8 @@ $hero-height: 300px;
|
||||
globals.$background-color 50%,
|
||||
globals.$dark-background-color 100%
|
||||
);
|
||||
padding: calc(globals.$spacing-unit * 1.5);
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
|
||||
&__description-content {
|
||||
@@ -190,27 +703,20 @@ $hero-height: 300px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__description {
|
||||
user-select: text;
|
||||
line-height: 22px;
|
||||
font-size: globals.$body-font-size;
|
||||
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5);
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
overflow-x: auto;
|
||||
min-height: auto;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
|
||||
width: 80%;
|
||||
}
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
@@ -220,6 +726,27 @@ $hero-height: 300px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
&--collapsed {
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background: linear-gradient(transparent, globals.$background-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
border-radius: 5px;
|
||||
@@ -245,6 +772,25 @@ $hero-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
&__description-toggle {
|
||||
background: none;
|
||||
border: 1px solid globals.$border-color;
|
||||
color: globals.$body-color;
|
||||
padding: calc(globals.$spacing-unit * 0.75)
|
||||
calc(globals.$spacing-unit * 1.5);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: globals.$body-font-size;
|
||||
margin-top: calc(globals.$spacing-unit * 1.5);
|
||||
transition: all 0.2s ease;
|
||||
align-self: center;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__description-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -375,4 +921,192 @@ $hero-height: 300px;
|
||||
flex: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&__reviews-section {
|
||||
margin-top: calc(globals.$spacing-unit * 3);
|
||||
padding-top: calc(globals.$spacing-unit * 3);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
&__reviews-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
&__reviews-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: globals.$muted-color;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__reviews-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__reviews-badge {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__leave-review-cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 0.5);
|
||||
padding: calc(globals.$spacing-unit * 0.75)
|
||||
calc(globals.$spacing-unit * 1.5);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
globals.$brand-teal,
|
||||
globals.$brand-blue
|
||||
);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: calc(globals.$spacing-unit);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(globals.$brand-teal, 0.3);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__review-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 8px;
|
||||
background-color: #1e1e1e;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__review-input-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background-color: #2a2a2a;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
&__review-editor-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__editor-button {
|
||||
background: none;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #3a3a3a;
|
||||
border-color: #5a5a5a;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: #0078d4;
|
||||
border-color: #0078d4;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__review-char-counter {
|
||||
font-size: 12px;
|
||||
color: #888888;
|
||||
|
||||
.over-limit {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
}
|
||||
|
||||
&__review-input {
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
cursor: text;
|
||||
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
min-height: 96px; // 120px - 24px padding
|
||||
width: 100%;
|
||||
cursor: text;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 8px 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
u {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
.hero-panel-actions {
|
||||
&__action {
|
||||
border: solid 1px globals.$muted-color;
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&__container {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
|
||||
import "./hero-panel-actions.scss";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function HeroPanelActions() {
|
||||
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
|
||||
@@ -52,6 +53,51 @@ export function HeroPanelActions() {
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
useEffect(() => {
|
||||
const onFavoriteToggled = () => {
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
};
|
||||
|
||||
const onGameRemoved = () => {
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
};
|
||||
|
||||
const onFilesRemoved = () => {
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"hydra:game-favorite-toggled",
|
||||
onFavoriteToggled as EventListener
|
||||
);
|
||||
window.addEventListener(
|
||||
"hydra:game-removed-from-library",
|
||||
onGameRemoved as EventListener
|
||||
);
|
||||
window.addEventListener(
|
||||
"hydra:game-files-removed",
|
||||
onFilesRemoved as EventListener
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"hydra:game-favorite-toggled",
|
||||
onFavoriteToggled as EventListener
|
||||
);
|
||||
window.removeEventListener(
|
||||
"hydra:game-removed-from-library",
|
||||
onGameRemoved as EventListener
|
||||
);
|
||||
window.removeEventListener(
|
||||
"hydra:game-files-removed",
|
||||
onFilesRemoved as EventListener
|
||||
);
|
||||
};
|
||||
}, [updateLibrary, updateGame]);
|
||||
|
||||
const addGameToLibrary = async () => {
|
||||
setToggleLibraryGameDisabled(true);
|
||||
|
||||
@@ -197,8 +243,8 @@ export function HeroPanelActions() {
|
||||
<Button
|
||||
onClick={() => setShowRepacksModal(true)}
|
||||
theme="outline"
|
||||
disabled={isGameDownloading || !repacks.length}
|
||||
className="hero-panel-actions__action"
|
||||
disabled={isGameDownloading}
|
||||
className={`hero-panel-actions__action ${repacks.length === 0 ? "hero-panel-actions__action--disabled" : ""}`}
|
||||
>
|
||||
<DownloadIcon />
|
||||
{t("download")}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.delete-review-modal {
|
||||
&__karma-warning {
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
color: #ffc107;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Modal } from "@renderer/components";
|
||||
import "./delete-review-modal.scss";
|
||||
|
||||
interface DeleteReviewModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteReviewModal({
|
||||
visible,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Readonly<DeleteReviewModalProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const handleDeleteReview = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("delete_review_modal_title")}
|
||||
description={t("delete_review_modal_description")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="delete-review-modal__actions">
|
||||
<Button onClick={onClose} theme="outline">
|
||||
{t("delete_review_modal_cancel_button")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleDeleteReview} theme="danger">
|
||||
{t("delete_review_modal_delete_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -117,6 +117,8 @@ export function DownloadSettingsModal({
|
||||
return userPreferences?.realDebridApiToken;
|
||||
if (downloader === Downloader.TorBox)
|
||||
return userPreferences?.torBoxApiToken;
|
||||
if (downloader === Downloader.AllDebrid)
|
||||
return userPreferences?.allDebridApiKey;
|
||||
if (downloader === Downloader.Hydra)
|
||||
return isFeatureEnabled(Feature.Nimbus);
|
||||
return true;
|
||||
@@ -131,6 +133,7 @@ export function DownloadSettingsModal({
|
||||
downloaders,
|
||||
userPreferences?.realDebridApiToken,
|
||||
userPreferences?.torBoxApiToken,
|
||||
userPreferences?.allDebridApiKey,
|
||||
]);
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
@@ -191,6 +194,8 @@ export function DownloadSettingsModal({
|
||||
const shouldDisableButton =
|
||||
(downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(downloader === Downloader.AllDebrid &&
|
||||
!userPreferences?.allDebridApiKey) ||
|
||||
(downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken) ||
|
||||
(downloader === Downloader.Hydra &&
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ImageIcon, XIcon } from "@primer/octicons-react";
|
||||
|
||||
import { Modal, TextField, Button } from "@renderer/components";
|
||||
import { useToast } from "@renderer/hooks";
|
||||
import { generateRandomGradient } from "@renderer/helpers";
|
||||
import type { LibraryGame, Game, ShopDetailsWithAssets } from "@types";
|
||||
|
||||
import "./edit-game-modal.scss";
|
||||
@@ -44,6 +45,11 @@ export function EditGameModal({
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
const [removedAssets, setRemovedAssets] = useState({
|
||||
icon: false,
|
||||
logo: false,
|
||||
hero: false,
|
||||
});
|
||||
const [defaultUrls, setDefaultUrls] = useState({
|
||||
icon: null as string | null,
|
||||
logo: null as string | null,
|
||||
@@ -158,6 +164,21 @@ export function EditGameModal({
|
||||
return defaultUrls[assetType];
|
||||
};
|
||||
|
||||
const getOriginalAssetUrl = (assetType: AssetType): string | null => {
|
||||
if (!game || !isCustomGame(game)) return null;
|
||||
|
||||
switch (assetType) {
|
||||
case "icon":
|
||||
return game.iconUrl;
|
||||
case "logo":
|
||||
return game.logoImageUrl;
|
||||
case "hero":
|
||||
return game.libraryHeroImageUrl;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAsset = async (assetType: AssetType) => {
|
||||
const { filePaths } = await window.electron.showOpenDialog({
|
||||
properties: ["openFile"],
|
||||
@@ -183,6 +204,8 @@ export function EditGameModal({
|
||||
...prev,
|
||||
[assetType]: originalPath,
|
||||
}));
|
||||
// Clear the removed flag when a new asset is selected
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy ${assetType} asset:`, error);
|
||||
setAssetPath(assetType, originalPath);
|
||||
@@ -191,14 +214,25 @@ export function EditGameModal({
|
||||
...prev,
|
||||
[assetType]: originalPath,
|
||||
}));
|
||||
// Clear the removed flag when a new asset is selected
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreDefault = (assetType: AssetType) => {
|
||||
setAssetPath(assetType, "");
|
||||
setAssetDisplayPath(assetType, "");
|
||||
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
|
||||
if (game && isCustomGame(game)) {
|
||||
// For custom games, mark asset as removed and clear paths
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: true }));
|
||||
setAssetPath(assetType, "");
|
||||
setAssetDisplayPath(assetType, "");
|
||||
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
|
||||
} else {
|
||||
// For non-custom games, clear custom assets (restore to shop defaults)
|
||||
setAssetPath(assetType, "");
|
||||
setAssetDisplayPath(assetType, "");
|
||||
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
const getOriginalTitle = (): string => {
|
||||
@@ -330,13 +364,38 @@ export function EditGameModal({
|
||||
|
||||
// Helper function to prepare custom game assets
|
||||
const prepareCustomGameAssets = (game: LibraryGame | Game) => {
|
||||
const iconUrl = assetPaths.icon ? `local:${assetPaths.icon}` : game.iconUrl;
|
||||
const logoImageUrl = assetPaths.logo
|
||||
? `local:${assetPaths.logo}`
|
||||
: game.logoImageUrl;
|
||||
const libraryHeroImageUrl = assetPaths.hero
|
||||
? `local:${assetPaths.hero}`
|
||||
: game.libraryHeroImageUrl;
|
||||
// For custom games, check if asset was explicitly removed
|
||||
let iconUrl;
|
||||
if (removedAssets.icon) {
|
||||
iconUrl = null;
|
||||
} else if (assetPaths.icon) {
|
||||
iconUrl = `local:${assetPaths.icon}`;
|
||||
} else {
|
||||
iconUrl = game.iconUrl;
|
||||
}
|
||||
|
||||
let logoImageUrl;
|
||||
if (removedAssets.logo) {
|
||||
logoImageUrl = null;
|
||||
} else if (assetPaths.logo) {
|
||||
logoImageUrl = `local:${assetPaths.logo}`;
|
||||
} else {
|
||||
logoImageUrl = game.logoImageUrl;
|
||||
}
|
||||
|
||||
// For hero image, if removed, restore to the original gradient or keep the original
|
||||
let libraryHeroImageUrl;
|
||||
if (removedAssets.hero) {
|
||||
// If the original hero was a gradient (data URL), keep it, otherwise generate a new one
|
||||
const originalHero = game.libraryHeroImageUrl;
|
||||
libraryHeroImageUrl = originalHero?.startsWith("data:image/svg+xml")
|
||||
? originalHero
|
||||
: generateRandomGradient();
|
||||
} else {
|
||||
libraryHeroImageUrl = assetPaths.hero
|
||||
? `local:${assetPaths.hero}`
|
||||
: game.libraryHeroImageUrl;
|
||||
}
|
||||
|
||||
return { iconUrl, logoImageUrl, libraryHeroImageUrl };
|
||||
};
|
||||
@@ -418,6 +477,13 @@ export function EditGameModal({
|
||||
(game: LibraryGame | Game) => {
|
||||
setGameName(game.title || "");
|
||||
|
||||
// Reset removed assets state
|
||||
setRemovedAssets({
|
||||
icon: false,
|
||||
logo: false,
|
||||
hero: false,
|
||||
});
|
||||
|
||||
if (isCustomGame(game)) {
|
||||
setCustomGameAssets(game);
|
||||
// Clear default URLs for custom games
|
||||
@@ -481,17 +547,19 @@ export function EditGameModal({
|
||||
<ImageIcon />
|
||||
{t("edit_game_modal_browse")}
|
||||
</Button>
|
||||
{game && !isCustomGame(game) && assetPath && (
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleRestoreDefault(assetType)}
|
||||
disabled={isUpdating}
|
||||
title={`Remove ${assetType}`}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
)}
|
||||
{game &&
|
||||
(assetPath ||
|
||||
(isCustomGame(game) && getOriginalAssetUrl(assetType))) && (
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleRestoreDefault(assetType)}
|
||||
disabled={isUpdating}
|
||||
title={`Remove ${assetType}`}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -519,7 +587,7 @@ export function EditGameModal({
|
||||
/>
|
||||
{isDragOver && (
|
||||
<div className="edit-game-modal__drop-overlay">
|
||||
<span>Drop to replace {assetType}</span>
|
||||
<span>{t(`edit_game_modal_drop_to_replace_${assetType}`)}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
@@ -542,7 +610,7 @@ export function EditGameModal({
|
||||
>
|
||||
<div className="edit-game-modal__drop-zone-content">
|
||||
<ImageIcon />
|
||||
<span>Drop {assetType} image here</span>
|
||||
<span>{t(`edit_game_modal_drop_${assetType}_image_here`)}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./repacks-modal";
|
||||
export * from "./download-settings-modal";
|
||||
export * from "./game-options-modal";
|
||||
export * from "./edit-game-modal";
|
||||
export * from "./delete-review-modal";
|
||||
|
||||
@@ -2,12 +2,33 @@
|
||||
|
||||
.repacks-modal {
|
||||
&__filter-container {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&__filter-top {
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__filter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
font-size: globals.$small-font-size;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 1.5);
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__repacks {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -16,7 +37,7 @@
|
||||
text-align: left;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: globals.$spacing-unit;
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
color: globals.$body-color;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
@@ -29,4 +50,106 @@
|
||||
&__repack-info {
|
||||
font-size: globals.$small-font-size;
|
||||
}
|
||||
|
||||
&__no-results {
|
||||
width: 100%;
|
||||
padding: calc(globals.$spacing-unit * 4) 0;
|
||||
text-align: center;
|
||||
color: globals.$muted-color;
|
||||
font-size: globals.$small-font-size;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__no-results-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__no-results-text {
|
||||
color: globals.$muted-color;
|
||||
font-size: globals.$small-font-size;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__no-results-button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__download-sources {
|
||||
padding: 0;
|
||||
background-color: var(--color-background-light);
|
||||
border-radius: 8px;
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
margin-top: calc(globals.$spacing-unit * 1);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.3s ease,
|
||||
padding 0.3s ease;
|
||||
|
||||
&--open {
|
||||
max-height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
&__filter-label {
|
||||
display: none;
|
||||
font-size: globals.$small-font-size;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__source-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
align-items: start;
|
||||
padding: calc(globals.$spacing-unit * 0.5) calc(globals.$spacing-unit * 0.5)
|
||||
calc(globals.$spacing-unit * 0.5) 0;
|
||||
}
|
||||
|
||||
&__source-item {
|
||||
padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1);
|
||||
background: var(--color-surface, rgba(0, 0, 0, 0.03));
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: calc(globals.$spacing-unit * 5);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
&__source-item :global(.checkbox-field) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__source-item :global(.checkbox-field__label) {
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
PlusCircleIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@primer/octicons-react";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
@@ -7,7 +13,10 @@ import {
|
||||
DebridBadge,
|
||||
Modal,
|
||||
TextField,
|
||||
CheckboxField,
|
||||
} from "@renderer/components";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import type { DownloadSource } from "@types";
|
||||
import type { GameRepack } from "@types";
|
||||
|
||||
import { DownloadSettingsModal } from "./download-settings-modal";
|
||||
@@ -36,6 +45,11 @@ export function RepacksModal({
|
||||
const [filteredRepacks, setFilteredRepacks] = useState<GameRepack[]>([]);
|
||||
const [repack, setRepack] = useState<GameRepack | null>(null);
|
||||
const [showSelectFolderModal, setShowSelectFolderModal] = useState(false);
|
||||
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
|
||||
const [selectedFingerprints, setSelectedFingerprints] = useState<string[]>(
|
||||
[]
|
||||
);
|
||||
const [filterTerm, setFilterTerm] = useState("");
|
||||
|
||||
const [hashesInDebrid, setHashesInDebrid] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
@@ -46,6 +60,7 @@ export function RepacksModal({
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const { formatDate } = useDate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getHashFromMagnet = (magnet: string) => {
|
||||
if (!magnet || typeof magnet !== "string") {
|
||||
@@ -90,8 +105,37 @@ export function RepacksModal({
|
||||
}, [repacks, hashesInDebrid]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredRepacks(sortedRepacks);
|
||||
}, [sortedRepacks, visible, game]);
|
||||
downloadSourcesTable.toArray().then((sources) => {
|
||||
const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker));
|
||||
const filteredSources = sources.filter(
|
||||
(s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint
|
||||
);
|
||||
setDownloadSources(filteredSources);
|
||||
});
|
||||
}, [sortedRepacks]);
|
||||
|
||||
useEffect(() => {
|
||||
const term = filterTerm.trim().toLowerCase();
|
||||
|
||||
const byTerm = sortedRepacks.filter((repack) => {
|
||||
if (!term) return true;
|
||||
const lowerTitle = repack.title.toLowerCase();
|
||||
const lowerRepacker = repack.repacker.toLowerCase();
|
||||
return lowerTitle.includes(term) || lowerRepacker.includes(term);
|
||||
});
|
||||
|
||||
const bySource = byTerm.filter((repack) => {
|
||||
if (selectedFingerprints.length === 0) return true;
|
||||
|
||||
return downloadSources.some(
|
||||
(src) =>
|
||||
selectedFingerprints.includes(src.fingerprint) &&
|
||||
src.name === repack.repacker
|
||||
);
|
||||
});
|
||||
|
||||
setFilteredRepacks(bySource);
|
||||
}, [sortedRepacks, filterTerm, selectedFingerprints, downloadSources]);
|
||||
|
||||
const handleRepackClick = (repack: GameRepack) => {
|
||||
setRepack(repack);
|
||||
@@ -99,17 +143,14 @@ export function RepacksModal({
|
||||
};
|
||||
|
||||
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
const term = event.target.value.toLocaleLowerCase();
|
||||
setFilterTerm(event.target.value);
|
||||
};
|
||||
|
||||
setFilteredRepacks(
|
||||
sortedRepacks.filter((repack) => {
|
||||
const lowerCaseTitle = repack.title.toLowerCase();
|
||||
const lowerCaseRepacker = repack.repacker.toLowerCase();
|
||||
|
||||
return [lowerCaseTitle, lowerCaseRepacker].some((value) =>
|
||||
value.includes(term)
|
||||
);
|
||||
})
|
||||
const toggleFingerprint = (fingerprint: string) => {
|
||||
setSelectedFingerprints((prev) =>
|
||||
prev.includes(fingerprint)
|
||||
? prev.filter((f) => f !== fingerprint)
|
||||
: [...prev, fingerprint]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -118,6 +159,8 @@ export function RepacksModal({
|
||||
return repack.uris.some((uri) => uri.includes(game.download!.uri));
|
||||
};
|
||||
|
||||
const [isFilterDrawerOpen, setIsFilterDrawerOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DownloadSettingsModal
|
||||
@@ -133,38 +176,103 @@ export function RepacksModal({
|
||||
description={t("repacks_modal_description")}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="repacks-modal__filter-container">
|
||||
<TextField placeholder={t("filter")} onChange={handleFilter} />
|
||||
<div
|
||||
className={`repacks-modal__filter-container ${isFilterDrawerOpen ? "repacks-modal__filter-container--drawer-open" : ""}`}
|
||||
>
|
||||
<div className="repacks-modal__filter-top">
|
||||
<TextField placeholder={t("filter")} onChange={handleFilter} />
|
||||
{downloadSources.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => setIsFilterDrawerOpen(!isFilterDrawerOpen)}
|
||||
className="repacks-modal__filter-toggle"
|
||||
>
|
||||
{t("filter_by_source")}
|
||||
{isFilterDrawerOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`repacks-modal__download-sources ${isFilterDrawerOpen ? "repacks-modal__download-sources--open" : ""}`}
|
||||
>
|
||||
<div className="repacks-modal__source-grid">
|
||||
{downloadSources.map((source) => {
|
||||
const label = source.name || source.url;
|
||||
const truncatedLabel =
|
||||
label.length > 16 ? label.substring(0, 16) + "..." : label;
|
||||
return (
|
||||
<div
|
||||
key={source.fingerprint}
|
||||
className="repacks-modal__source-item"
|
||||
>
|
||||
<CheckboxField
|
||||
label={truncatedLabel}
|
||||
checked={selectedFingerprints.includes(
|
||||
source.fingerprint
|
||||
)}
|
||||
onChange={() => toggleFingerprint(source.fingerprint)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="repacks-modal__repacks">
|
||||
{filteredRepacks.map((repack) => {
|
||||
const isLastDownloadedOption = checkIfLastDownloadedOption(repack);
|
||||
{filteredRepacks.length === 0 ? (
|
||||
<div className="repacks-modal__no-results">
|
||||
<div className="repacks-modal__no-results-content">
|
||||
<div className="repacks-modal__no-results-text">
|
||||
{t("no_repacks_found")}
|
||||
</div>
|
||||
<div className="repacks-modal__no-results-button">
|
||||
<Button
|
||||
type="button"
|
||||
theme="primary"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate("/settings?tab=2");
|
||||
}}
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
{t("add_download_source", { ns: "settings" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filteredRepacks.map((repack) => {
|
||||
const isLastDownloadedOption =
|
||||
checkIfLastDownloadedOption(repack);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={repack.id}
|
||||
theme="dark"
|
||||
onClick={() => handleRepackClick(repack)}
|
||||
className="repacks-modal__repack-button"
|
||||
>
|
||||
<p className="repacks-modal__repack-title">{repack.title}</p>
|
||||
return (
|
||||
<Button
|
||||
key={repack.id}
|
||||
theme="dark"
|
||||
onClick={() => handleRepackClick(repack)}
|
||||
className="repacks-modal__repack-button"
|
||||
>
|
||||
<p className="repacks-modal__repack-title">{repack.title}</p>
|
||||
|
||||
{isLastDownloadedOption && (
|
||||
<Badge>{t("last_downloaded_option")}</Badge>
|
||||
)}
|
||||
{isLastDownloadedOption && (
|
||||
<Badge>{t("last_downloaded_option")}</Badge>
|
||||
)}
|
||||
|
||||
<p className="repacks-modal__repack-info">
|
||||
{repack.fileSize} - {repack.repacker} -{" "}
|
||||
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
|
||||
</p>
|
||||
<p className="repacks-modal__repack-info">
|
||||
{repack.fileSize} - {repack.repacker} -{" "}
|
||||
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
|
||||
</p>
|
||||
|
||||
{hashesInDebrid[getHashFromMagnet(repack.uris[0]) ?? ""] && (
|
||||
<DebridBadge />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{hashesInDebrid[getHashFromMagnet(repack.uris[0]) ?? ""] && (
|
||||
<DebridBadge />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.review-prompt-banner {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 8px;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
margin-bottom: calc(globals.$spacing-unit * 1.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: calc(globals.$spacing-unit * 2.5);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 0.5);
|
||||
}
|
||||
|
||||
&__playtime {
|
||||
font-size: globals.$body-font-size;
|
||||
color: globals.$body-color;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__question {
|
||||
font-size: globals.$small-font-size;
|
||||
color: globals.$muted-color;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
38
src/renderer/src/pages/game-details/review-prompt-banner.tsx
Normal file
38
src/renderer/src/pages/game-details/review-prompt-banner.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@renderer/components";
|
||||
import "./review-prompt-banner.scss";
|
||||
|
||||
interface ReviewPromptBannerProps {
|
||||
onYesClick: () => void;
|
||||
onLaterClick: () => void;
|
||||
}
|
||||
|
||||
export function ReviewPromptBanner({
|
||||
onYesClick,
|
||||
onLaterClick,
|
||||
}: Readonly<ReviewPromptBannerProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
return (
|
||||
<div className="review-prompt-banner">
|
||||
<div className="review-prompt-banner__content">
|
||||
<div className="review-prompt-banner__text">
|
||||
<span className="review-prompt-banner__playtime">
|
||||
{t("you_seemed_to_enjoy_this_game")}
|
||||
</span>
|
||||
<span className="review-prompt-banner__question">
|
||||
{t("would_you_recommend_this_game")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="review-prompt-banner__actions">
|
||||
<Button theme="outline" onClick={onLaterClick}>
|
||||
{t("maybe_later")}
|
||||
</Button>
|
||||
<Button theme="primary" onClick={onYesClick}>
|
||||
{t("yes")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/renderer/src/pages/game-details/review-sort-options.scss
Normal file
73
src/renderer/src/pages/game-details/review-sort-options.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.review-sort-options {
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
margin-bottom: calc(globals.$spacing-unit * 3);
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
font-size: 14px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
gap: calc(globals.$spacing-unit * 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
&__option {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
transition: all ease 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__separator {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-size: 14px;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/renderer/src/pages/game-details/review-sort-options.tsx
Normal file
90
src/renderer/src/pages/game-details/review-sort-options.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
ThumbsupIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./review-sort-options.scss";
|
||||
|
||||
type ReviewSortOption =
|
||||
| "newest"
|
||||
| "oldest"
|
||||
| "score_high"
|
||||
| "score_low"
|
||||
| "most_voted";
|
||||
|
||||
interface ReviewSortOptionsProps {
|
||||
sortBy: ReviewSortOption;
|
||||
onSortChange: (sortBy: ReviewSortOption) => void;
|
||||
}
|
||||
|
||||
export function ReviewSortOptions({
|
||||
sortBy,
|
||||
onSortChange,
|
||||
}: Readonly<ReviewSortOptionsProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const handleDateToggle = () => {
|
||||
const newSort = sortBy === "newest" ? "oldest" : "newest";
|
||||
onSortChange(newSort);
|
||||
};
|
||||
|
||||
const handleScoreToggle = () => {
|
||||
const newSort = sortBy === "score_high" ? "score_low" : "score_high";
|
||||
onSortChange(newSort);
|
||||
};
|
||||
|
||||
const handleMostVotedClick = () => {
|
||||
if (sortBy !== "most_voted") {
|
||||
onSortChange("most_voted");
|
||||
}
|
||||
};
|
||||
|
||||
const isDateActive = sortBy === "newest" || sortBy === "oldest";
|
||||
const isScoreActive = sortBy === "score_high" || sortBy === "score_low";
|
||||
const isMostVotedActive = sortBy === "most_voted";
|
||||
|
||||
return (
|
||||
<div className="review-sort-options__container">
|
||||
<div className="review-sort-options__options">
|
||||
<button
|
||||
className={`review-sort-options__option review-sort-options__toggle-option ${isDateActive ? "active" : ""}`}
|
||||
onClick={handleDateToggle}
|
||||
>
|
||||
{sortBy === "newest" ? (
|
||||
<ChevronDownIcon size={16} />
|
||||
) : (
|
||||
<ChevronUpIcon size={16} />
|
||||
)}
|
||||
<span>
|
||||
{sortBy === "oldest" ? t("sort_oldest") : t("sort_newest")}
|
||||
</span>
|
||||
</button>
|
||||
<span className="review-sort-options__separator">|</span>
|
||||
<button
|
||||
className={`review-sort-options__option review-sort-options__toggle-option ${isScoreActive ? "active" : ""}`}
|
||||
onClick={handleScoreToggle}
|
||||
>
|
||||
{sortBy === "score_high" ? (
|
||||
<ChevronDownIcon size={16} />
|
||||
) : (
|
||||
<ChevronUpIcon size={16} />
|
||||
)}
|
||||
<span>
|
||||
{sortBy === "score_low"
|
||||
? t("sort_lowest_score")
|
||||
: t("sort_highest_score")}
|
||||
</span>
|
||||
</button>
|
||||
<span className="review-sort-options__separator">|</span>
|
||||
<button
|
||||
className={`review-sort-options__option ${isMostVotedActive ? "active" : ""}`}
|
||||
onClick={handleMostVotedClick}
|
||||
>
|
||||
<ThumbsupIcon size={16} />
|
||||
<span>{t("sort_most_voted")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.sidebar-section {
|
||||
background-color: globals.$dark-background-color;
|
||||
background-color: globals.$background-color;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
@@ -21,7 +21,7 @@
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -40,6 +40,7 @@
|
||||
&__content {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s cubic-bezier(0, 1, 0, 1);
|
||||
background-color: globals.$dark-background-color;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.content-sidebar {
|
||||
border-left: solid 1px globals.$border-color;
|
||||
background-color: globals.$dark-background-color;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 320px;
|
||||
@@ -105,7 +106,7 @@
|
||||
.stats {
|
||||
&__section {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
justify-content: space-between;
|
||||
transition: max-height ease 0.5s;
|
||||
@@ -114,10 +115,6 @@
|
||||
@media (min-width: 1024px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
&__category-title {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
UserAchievement,
|
||||
} from "@types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Link } from "@renderer/components";
|
||||
import { Button, Link, StarRating } from "@renderer/components";
|
||||
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import { useDate, useFormat, useUserDetails } from "@renderer/hooks";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DownloadIcon,
|
||||
LockIcon,
|
||||
PeopleIcon,
|
||||
StarIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||
import { howLongToBeatEntriesTable } from "@renderer/dexie";
|
||||
@@ -225,6 +226,29 @@ export function Sidebar() {
|
||||
</p>
|
||||
<p>{numberFormatter.format(stats?.playerCount)}</p>
|
||||
</div>
|
||||
|
||||
<div className="stats__category">
|
||||
<p className="stats__category-title">
|
||||
<StarIcon size={18} />
|
||||
{t("rating_count")}
|
||||
</p>
|
||||
<StarRating
|
||||
rating={
|
||||
stats?.averageScore === 0
|
||||
? null
|
||||
: (stats?.averageScore ?? null)
|
||||
}
|
||||
size={16}
|
||||
showCalculating={
|
||||
!!(
|
||||
stats &&
|
||||
(stats.averageScore === null || stats.averageScore === 0)
|
||||
)
|
||||
}
|
||||
calculatingText={t("calculating", { ns: "game_card" })}
|
||||
hideIcon={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ReportProfile } from "../report-profile/report-profile";
|
||||
import { FriendsBox } from "./friends-box";
|
||||
import { RecentGamesBox } from "./recent-games-box";
|
||||
import { UserStatsBox } from "./user-stats-box";
|
||||
import { UserKarmaBox } from "./user-karma-box";
|
||||
import { UserLibraryGameCard } from "./user-library-game-card";
|
||||
import { SortOptions } from "./sort-options";
|
||||
import { useSectionCollapse } from "@renderer/hooks/use-section-collapse";
|
||||
@@ -177,6 +178,7 @@ export function ProfileContent() {
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
@@ -208,6 +210,7 @@ export function ProfileContent() {
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
@@ -221,6 +224,7 @@ export function ProfileContent() {
|
||||
{shouldShowRightContent && (
|
||||
<div className="profile-content__right-content">
|
||||
<UserStatsBox />
|
||||
<UserKarmaBox />
|
||||
<RecentGamesBox />
|
||||
<FriendsBox />
|
||||
<ReportProfile />
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.user-karma {
|
||||
&__box {
|
||||
background-color: globals.$background-color;
|
||||
border-radius: 4px;
|
||||
border: solid 1px globals.$border-color;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
|
||||
&__stats-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: globals.$body-color;
|
||||
}
|
||||
|
||||
&__description {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: globals.$spacing-unit;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
&__info {
|
||||
padding-top: calc(globals.$spacing-unit * 0.5);
|
||||
}
|
||||
|
||||
&__info-text {
|
||||
color: globals.$muted-color;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useContext } from "react";
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFormat, useUserDetails } from "@renderer/hooks";
|
||||
import { Award } from "lucide-react";
|
||||
import "./user-karma-box.scss";
|
||||
|
||||
export function UserKarmaBox() {
|
||||
const { isMe, userProfile } = useContext(userProfileContext);
|
||||
const { userDetails } = useUserDetails();
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
// Get karma from userDetails (for current user) or userProfile (for other users)
|
||||
const karma = isMe ? userDetails?.karma : userProfile?.karma;
|
||||
|
||||
// Don't show if karma is not available
|
||||
if (karma === undefined || karma === null) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="user-karma__section-header">
|
||||
<h2>{t("karma")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="user-karma__box">
|
||||
<div className="user-karma__content">
|
||||
<div className="user-karma__stats-row">
|
||||
<p className="user-karma__description">
|
||||
<Award size={20} /> {numberFormatter.format(karma)}{" "}
|
||||
{t("karma_count")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="user-karma__info">
|
||||
<small className="user-karma__info-text">
|
||||
{t("karma_description")}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,13 +133,6 @@
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&-long {
|
||||
display: inline;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -26,6 +26,7 @@ interface UserLibraryGameCardProps {
|
||||
statIndex: number;
|
||||
onMouseEnter: () => void;
|
||||
onMouseLeave: () => void;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
export function UserLibraryGameCard({
|
||||
@@ -33,6 +34,7 @@ export function UserLibraryGameCard({
|
||||
statIndex,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
sortBy,
|
||||
}: UserLibraryGameCardProps) {
|
||||
const { userProfile, isMe, getUserLibraryGames } =
|
||||
useContext(userProfileContext);
|
||||
@@ -108,7 +110,7 @@ export function UserLibraryGameCard({
|
||||
!game.isPinned
|
||||
);
|
||||
|
||||
await getUserLibraryGames();
|
||||
await getUserLibraryGames(sortBy);
|
||||
|
||||
if (game.isPinned) {
|
||||
showSuccessToast(t("game_removed_from_pinned"));
|
||||
@@ -232,7 +234,7 @@ export function UserLibraryGameCard({
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={game.coverImageUrl}
|
||||
src={game.coverImageUrl ?? undefined}
|
||||
alt={game.title}
|
||||
className="user-library-game__game-image"
|
||||
/>
|
||||
|
||||
12
src/renderer/src/pages/settings/settings-all-debrid.scss
Normal file
12
src/renderer/src/pages/settings/settings-all-debrid.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
.settings-all-debrid {
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
129
src/renderer/src/pages/settings/settings-all-debrid.tsx
Normal file
129
src/renderer/src/pages/settings/settings-all-debrid.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, CheckboxField, Link, TextField } from "@renderer/components";
|
||||
import "./settings-all-debrid.scss";
|
||||
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
|
||||
import { settingsContext } from "@renderer/context";
|
||||
|
||||
const ALL_DEBRID_API_TOKEN_URL = "https://alldebrid.com/apikeys";
|
||||
|
||||
export function SettingsAllDebrid() {
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const { updateUserPreferences } = useContext(settingsContext);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
useAllDebrid: false,
|
||||
allDebridApiKey: null as string | null,
|
||||
});
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences) {
|
||||
setForm({
|
||||
useAllDebrid: Boolean(userPreferences.allDebridApiKey),
|
||||
allDebridApiKey: userPreferences.allDebridApiKey ?? null,
|
||||
});
|
||||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = async (
|
||||
event
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
if (form.useAllDebrid) {
|
||||
if (!form.allDebridApiKey) {
|
||||
showErrorToast(t("alldebrid_missing_key"));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electron.authenticateAllDebrid(
|
||||
form.allDebridApiKey
|
||||
);
|
||||
|
||||
if ("error_code" in result) {
|
||||
showErrorToast(t(result.error_code));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.isPremium) {
|
||||
showErrorToast(
|
||||
t("all_debrid_free_account_error", { username: result.username })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccessToast(
|
||||
t("all_debrid_account_linked"),
|
||||
t("debrid_linked_message", { username: result.username })
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(t("changes_saved"));
|
||||
}
|
||||
|
||||
updateUserPreferences({
|
||||
allDebridApiKey: form.useAllDebrid ? form.allDebridApiKey : null,
|
||||
});
|
||||
} catch (err: any) {
|
||||
showErrorToast(t("alldebrid_unknown_error"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled =
|
||||
(form.useAllDebrid && !form.allDebridApiKey) || isLoading;
|
||||
|
||||
return (
|
||||
<form className="settings-all-debrid__form" onSubmit={handleFormSubmit}>
|
||||
<p className="settings-all-debrid__description">
|
||||
{t("all_debrid_description")}
|
||||
</p>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_all_debrid")}
|
||||
checked={form.useAllDebrid}
|
||||
onChange={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
useAllDebrid: !form.useAllDebrid,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
{form.useAllDebrid && (
|
||||
<TextField
|
||||
label={t("api_token")}
|
||||
value={form.allDebridApiKey ?? ""}
|
||||
type="password"
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, allDebridApiKey: event.target.value })
|
||||
}
|
||||
rightContent={
|
||||
<Button type="submit" disabled={isButtonDisabled}>
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
}
|
||||
placeholder="API Key"
|
||||
hint={
|
||||
<Trans i18nKey="debrid_api_token_hint" ns="settings">
|
||||
<Link to={ALL_DEBRID_API_TOKEN_URL} />
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from "@renderer/components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsRealDebrid } from "./settings-real-debrid";
|
||||
import { SettingsAllDebrid } from "./settings-all-debrid";
|
||||
import { SettingsGeneral } from "./settings-general";
|
||||
import { SettingsBehavior } from "./settings-behavior";
|
||||
import { SettingsDownloadSources } from "./settings-download-sources";
|
||||
@@ -42,6 +43,7 @@ export default function Settings() {
|
||||
]
|
||||
: []),
|
||||
{ tabLabel: "Real-Debrid", contentTitle: "Real-Debrid" },
|
||||
{ tabLabel: "All-Debrid", contentTitle: "All-Debrid" },
|
||||
];
|
||||
|
||||
if (userDetails)
|
||||
@@ -81,6 +83,10 @@ export default function Settings() {
|
||||
return <SettingsRealDebrid />;
|
||||
}
|
||||
|
||||
if (currentCategoryIndex === 6) {
|
||||
return <SettingsAllDebrid />;
|
||||
}
|
||||
|
||||
return <SettingsAccount />;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum Downloader {
|
||||
RealDebrid,
|
||||
AllDebrid,
|
||||
Torrent,
|
||||
Gofile,
|
||||
PixelDrain,
|
||||
@@ -55,6 +56,7 @@ export enum AuthPage {
|
||||
|
||||
export enum DownloadError {
|
||||
NotCachedOnRealDebrid = "download_error_not_cached_on_real_debrid",
|
||||
NotCachedInAllDebrid = "download_error_not_cached_in_alldebrid",
|
||||
NotCachedOnTorBox = "download_error_not_cached_on_torbox",
|
||||
GofileQuotaExceeded = "download_error_gofile_quota_exceeded",
|
||||
RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized",
|
||||
|
||||
73
src/shared/html-sanitizer.ts
Normal file
73
src/shared/html-sanitizer.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
function removeZalgoText(text: string): string {
|
||||
const zalgoRegex =
|
||||
// eslint-disable-next-line no-misleading-character-class
|
||||
/[\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/g;
|
||||
|
||||
return text.replaceAll(zalgoRegex, "");
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
const entityMap: { [key: string]: string } = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
""": '"',
|
||||
"'": "'",
|
||||
" ": " ",
|
||||
};
|
||||
|
||||
return text.replaceAll(/&[#\w]+;/g, (entity) => {
|
||||
return entityMap[entity] || entity;
|
||||
});
|
||||
}
|
||||
|
||||
function removeHtmlTags(html: string): string {
|
||||
let result = "";
|
||||
let inTag = false;
|
||||
|
||||
for (const char of html) {
|
||||
if (char === "<") {
|
||||
inTag = true;
|
||||
} else if (char === ">") {
|
||||
inTag = false;
|
||||
} else if (!inTag) {
|
||||
result += char;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeHtml(html: string): string {
|
||||
if (!html || typeof html !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
let cleanText = removeHtmlTags(html);
|
||||
|
||||
cleanText = decodeHtmlEntities(cleanText);
|
||||
|
||||
cleanText = removeZalgoText(cleanText);
|
||||
|
||||
cleanText = cleanText.replaceAll(/\s+/g, " ").trim();
|
||||
|
||||
if (!cleanText || cleanText.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return cleanText;
|
||||
}
|
||||
|
||||
export function stripHtml(html: string): string {
|
||||
if (!html || typeof html !== "string") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = html;
|
||||
let cleanText = tempDiv.textContent || tempDiv.innerText || "";
|
||||
|
||||
cleanText = removeZalgoText(cleanText);
|
||||
|
||||
return cleanText;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { format } from "date-fns";
|
||||
import { AchievementNotificationInfo } from "@types";
|
||||
|
||||
export * from "./constants";
|
||||
export * from "./html-sanitizer";
|
||||
|
||||
export class UserNotLoggedInError extends Error {
|
||||
constructor() {
|
||||
@@ -123,6 +124,7 @@ export const getDownloadersForUri = (uri: string) => {
|
||||
Downloader.Hydra,
|
||||
Downloader.TorBox,
|
||||
Downloader.RealDebrid,
|
||||
Downloader.AllDebrid,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -175,3 +175,11 @@ export interface SeedingStatus {
|
||||
status: DownloadStatus;
|
||||
uploadSpeed: number;
|
||||
}
|
||||
|
||||
/* All-Debrid */
|
||||
export interface AllDebridUser {
|
||||
username: string;
|
||||
email: string;
|
||||
isPremium: boolean;
|
||||
premiumUntil: string;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface ShopAssets {
|
||||
libraryImageUrl: string;
|
||||
logoImageUrl: string;
|
||||
logoPosition: string | null;
|
||||
coverImageUrl: string;
|
||||
coverImageUrl: string | null;
|
||||
}
|
||||
|
||||
export type ShopDetails = SteamAppDetails & {
|
||||
@@ -182,6 +182,7 @@ export interface UserDetails {
|
||||
bio: string;
|
||||
featurebaseJwt: string;
|
||||
subscription: Subscription | null;
|
||||
karma: number;
|
||||
quirks?: {
|
||||
backupsPerGameLimit: number;
|
||||
};
|
||||
@@ -202,6 +203,7 @@ export interface UserProfile {
|
||||
currentGame: UserProfileCurrentGame | null;
|
||||
bio: string;
|
||||
hasActiveSubscription: boolean;
|
||||
karma: number;
|
||||
quirks: {
|
||||
backupsPerGameLimit: number;
|
||||
};
|
||||
@@ -233,7 +235,26 @@ export interface DownloadSourceValidationResult {
|
||||
export interface GameStats {
|
||||
downloadCount: number;
|
||||
playerCount: number;
|
||||
assets: ShopAssets | null;
|
||||
averageScore: number | null;
|
||||
reviewCount: number;
|
||||
}
|
||||
|
||||
export interface GameReview {
|
||||
id: string;
|
||||
reviewHtml: string;
|
||||
score: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
isBlocked: boolean;
|
||||
hasUpvoted: boolean;
|
||||
hasDownvoted: boolean;
|
||||
user: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
profileImageUrl: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TrendingGame extends ShopAssets {
|
||||
|
||||
@@ -95,9 +95,11 @@ export type AchievementCustomNotificationPosition =
|
||||
|
||||
export interface UserPreferences {
|
||||
downloadsPath?: string | null;
|
||||
ggDealsApiKey?: string | null;
|
||||
language?: string;
|
||||
realDebridApiToken?: string | null;
|
||||
torBoxApiToken?: string | null;
|
||||
allDebridApiKey?: string | null;
|
||||
preferQuitInsteadOfHiding?: boolean;
|
||||
runAtStartup?: boolean;
|
||||
startMinimized?: boolean;
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"src/locales/index.ts",
|
||||
"src/shared/**/*",
|
||||
"src/stories/**/*",
|
||||
"src/types/**/*",
|
||||
".storybook/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
|
||||
446
yarn.lock
446
yarn.lock
@@ -2073,6 +2073,11 @@
|
||||
redux-thunk "^3.1.0"
|
||||
reselect "^5.1.0"
|
||||
|
||||
"@remirror/core-constants@3.0.0":
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@remirror/core-constants/-/core-constants-3.0.0.tgz#96fdb89d25c62e7b6a5d08caf0ce5114370e3b8f"
|
||||
integrity sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==
|
||||
|
||||
"@remix-run/router@1.19.2":
|
||||
version "1.19.2"
|
||||
resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.19.2.tgz#0c896535473291cb41f152c180bedd5680a3b273"
|
||||
@@ -2840,6 +2845,201 @@
|
||||
dependencies:
|
||||
uint8-util "^2.2.5"
|
||||
|
||||
"@tiptap/core@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.6.2.tgz#abda4116e4a39779fca7070e316b9ed9fdcded7e"
|
||||
integrity sha512-XKZYrCVFsyQGF6dXQR73YR222l/76wkKfZ+2/4LCrem5qtcOarmv5pYxjUBG8mRuBPskTTBImSFTeQltJIUNCg==
|
||||
|
||||
"@tiptap/extension-blockquote@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-3.6.2.tgz#01b589565c87a691e586e189ddcbcdc5f35618fc"
|
||||
integrity sha512-TSl41UZhi3ugJMDaf91CA4F5NeFylgTSm6GqnZAHOE6IREdCpAK3qej2zaW3EzfpzxW7sRGLlytkZRvpeyjgJA==
|
||||
|
||||
"@tiptap/extension-bold@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.6.2.tgz#ed721961daf3210c7ba4433a5aeae981043c2d77"
|
||||
integrity sha512-Q9KO8CCPCAXYqHzIw8b/ookVmrfqfCg2cyh9h9Hvw6nhO4LOOnJMcGVmWsrpFItbwCGMafI5iY9SbSj7RpCyuw==
|
||||
|
||||
"@tiptap/extension-bubble-menu@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.6.2.tgz#237d84f217c8da52c0bc5265a36557fb27d64eaf"
|
||||
integrity sha512-OF5CxCmYExcXZjcectwAeujSeDZ4IltPy+SsqBZLbQRDts9PQhzv5azGDvYdL2eMMkT3yhO2gWkXxSHMxI3O6w==
|
||||
dependencies:
|
||||
"@floating-ui/dom" "^1.0.0"
|
||||
|
||||
"@tiptap/extension-bullet-list@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.6.2.tgz#be20b6c795c53bc0d199bdc4dd9f01b6270a1bee"
|
||||
integrity sha512-Y5Uhir+za7xMm6RAe592aNNlLvCayVSQt2HfSckOr+c/v/Zd2bFUHv0ef6l/nUzUhDBs32Bg9SvfWx/yyMyNEw==
|
||||
|
||||
"@tiptap/extension-code-block@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-3.6.2.tgz#cb3f6f607dcfb36e3eff25255fdcfdedfb3940a7"
|
||||
integrity sha512-5jfoiQ/3AUrIyuVU1NmEXar6sZFnY7wDFf3ZU2zpcBUG++yg/CmpOe5bXpoolczhl58cM/jyBG5gumQjyOxLNg==
|
||||
|
||||
"@tiptap/extension-code@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.6.2.tgz#5c6500d748fd4f52ddbe01ff114d4933c7a09e8f"
|
||||
integrity sha512-U6jilbcpCxtLZAgJrTapXzzVJTXnS78kJITFSOLyGCTyGSm6PXatQ4hnaxVGmNet66GySONGjhwAVZ8+l94Rwg==
|
||||
|
||||
"@tiptap/extension-document@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.6.2.tgz#5c3f3a85d12868f5d4e6d6d258b8fa0b8000b778"
|
||||
integrity sha512-4qg3KWL3aO1M7hfDpZR6/vSo7Cfqr3McyGUfqb/BXqYDW1DwT8jJkDTcHrGU7WUKRlWgoyPyzM8pZiGlP0uQHg==
|
||||
|
||||
"@tiptap/extension-dropcursor@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.6.2.tgz#22a64a4da25ac17cf0cd33e1e924762000152817"
|
||||
integrity sha512-6R5sma/i2TKd5h9OpIcy3a0wOGp5BNT/zIgnE/1HTmKi40eNcCAVe8sxd6+iWA5ETONP1E48kDy4hqA5ZzZCiQ==
|
||||
|
||||
"@tiptap/extension-floating-menu@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.6.2.tgz#cc9c97cdd5fa55407631d3135e00ca8051516444"
|
||||
integrity sha512-ym7YMKGY3QhFUKUS6JYOwtdi8s2PeGmOhu7TwI9/U0LmGbELeKJBJl2BP1yB+Sjpv25pVL++CwJQ6dsrjDlZ8g==
|
||||
|
||||
"@tiptap/extension-gapcursor@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.6.2.tgz#790c94d20a5b8ded4c0d38960254d24704a2bc08"
|
||||
integrity sha512-gXg+EvUKlv3ZO1GxKkRmAsi/V4yyA8AzLW6ppOcYrM2CKf6epmPaVRgAjdwHCA6cm3QuCBJyWeGTCAjhjNakhw==
|
||||
|
||||
"@tiptap/extension-hard-break@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.6.2.tgz#3c379d9104cd7d9e942277f22ba62c57fae267ad"
|
||||
integrity sha512-ncuPBHhGY58QjluJvEH6vXotaa1QZ/vphXBGAr55kiATZwMIEHgwh2Hgc6AiFTcw057gabGn6jNFDfRB+HjbmA==
|
||||
|
||||
"@tiptap/extension-heading@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-3.6.2.tgz#3884c309de60c9d61f1bb60c521410b3a0d88ed7"
|
||||
integrity sha512-JQ2yjwXGAiwGc+MhS1mULBr354MHfmWqVDQLRg8ey6LkdXggTDDJ1Ni3GrUS7B5YcA/ICdhr4krXaQpNkT5Syw==
|
||||
|
||||
"@tiptap/extension-horizontal-rule@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.6.2.tgz#f5680b3209bc48bf8635f3674355bd3d47f15622"
|
||||
integrity sha512-3TlPqedPDM9QkRTUPhOTxNxQVPSsBwlsuLrAZOgyM1y871Xi7M1DFX0h9LLXuqzPndYzUY16NjrfBGFJX+O56w==
|
||||
|
||||
"@tiptap/extension-italic@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.6.2.tgz#ea314f5e723499c9e7a1021ad7836693db9c653c"
|
||||
integrity sha512-46zYKqM3o9w1A2G9hWr0ERGbJpqIncoH45XIfLdAI6ZldZVVf+NeXMGwjOPf4+03cZ5/emk3MRTnVp9vF4ToIg==
|
||||
|
||||
"@tiptap/extension-link@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.6.2.tgz#5577d100cd3b735247db327b15d91de025cc76b6"
|
||||
integrity sha512-3yiRDWa187h30e6iUOJeejZLsbzbJthLfBwTeJGx7pHh7RngsEW82npBRuqLoI3udhJGTkXbzwAFZ9qOGOjl1Q==
|
||||
dependencies:
|
||||
linkifyjs "^4.3.2"
|
||||
|
||||
"@tiptap/extension-list-item@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-3.6.2.tgz#705f782a872e4bbb6f0e125fe277c45aeefe8161"
|
||||
integrity sha512-ma/D2GKylpNB04FfNI3tDMY+C9nz7Yk85H21YTIGv8QL5KlDK97L6orydmx6IVRc2nNMZQVitBIEKDOXcczX9w==
|
||||
|
||||
"@tiptap/extension-list-keymap@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.6.2.tgz#f14e173325b443a89dbbca7f418b76ec3d5c9a21"
|
||||
integrity sha512-1kl/lggH+LL/FUwcSx8p761ebk9L5ZGK06mGyDDU9XiGLS310CktZYLnpEuFgn/oMPbRHo26oNl9SXLn1/U53A==
|
||||
|
||||
"@tiptap/extension-list@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.6.2.tgz#beb4d965f48085fa7f69197e10109cde8c175046"
|
||||
integrity sha512-ZLaEHGVq4eL26hZZFE9e7RArk2rEjcVstN/YTRTKElTnLaf58kLTKN3nlgy1PWGwzfWGUuXURBuEBLaq5l6djg==
|
||||
|
||||
"@tiptap/extension-ordered-list@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.6.2.tgz#43b83757f67264ff0050c03825e780da43680c1d"
|
||||
integrity sha512-KdJ5MLIw19N+XiqQ2COXGtaq9TzUbtlLE5dgYCJQ2EumeZKIGELvUnHjrnIB9gH/gRlMs+hprLTh23xVUDJovg==
|
||||
|
||||
"@tiptap/extension-paragraph@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.6.2.tgz#d6cc89cdc369e463dd7dd4eb9121718441c984a0"
|
||||
integrity sha512-jeJWj2xKib3392iHQEcB7wYZ30dUgXuwqpCTwtN9eANor+Zvv6CpDKBs1R2al6BYFbIJCgKeTulqxce0yoC80g==
|
||||
|
||||
"@tiptap/extension-strike@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.6.2.tgz#2dab3f253a4ecfd525c5609ab5edb9325a6364c2"
|
||||
integrity sha512-976u5WaioIN/0xCjl/UIEypmzACzxgVz6OGgfIsYyreMUiPjhhgzXb0A/2Po5p3nZpKcaMcxifOdhqdw+lDpIQ==
|
||||
|
||||
"@tiptap/extension-text@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.6.2.tgz#77313173a9f91208e40d298bc2d40b39371b8fca"
|
||||
integrity sha512-fFSUEv1H3lM92yr6jZdELk0gog8rPTK5hTf08kP8RsY8pA80Br1ADVenejrMV4UNTmT1JWTXGBGhMqfQFHUvAQ==
|
||||
|
||||
"@tiptap/extension-underline@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.6.2.tgz#9f0dfb9722bd3d0cd144fc955bcb94a3fcf5eac2"
|
||||
integrity sha512-IrG6vjxTMI2EeyhZCtx0sNTEu83PsAvzIh4vxmG1fUi/RYokks+sFbgGMuq0jtO96iVNEszlpAC/vaqfxFJwew==
|
||||
|
||||
"@tiptap/extensions@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.6.2.tgz#591fbd5b9fa41f98f69dbd7d21d5d38a2241d94b"
|
||||
integrity sha512-tg7/DgaI6SpkeawryapUtNoBxsJUMJl3+nSjTfTvsaNXed+BHzLPsvmPbzlF9ScrAbVEx8nj6CCkneECYIQ4CQ==
|
||||
|
||||
"@tiptap/pm@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.6.2.tgz#2121d4917f92d11229529a26955a7033aa8a8843"
|
||||
integrity sha512-g+NXjqjbj6NfHOMl22uNWVYIu8oCq7RFfbnpohPMsSKJLaHYE8mJR++7T6P5R9FoqhIFdwizg1jTpwRU5CHqXQ==
|
||||
dependencies:
|
||||
prosemirror-changeset "^2.3.0"
|
||||
prosemirror-collab "^1.3.1"
|
||||
prosemirror-commands "^1.6.2"
|
||||
prosemirror-dropcursor "^1.8.1"
|
||||
prosemirror-gapcursor "^1.3.2"
|
||||
prosemirror-history "^1.4.1"
|
||||
prosemirror-inputrules "^1.4.0"
|
||||
prosemirror-keymap "^1.2.2"
|
||||
prosemirror-markdown "^1.13.1"
|
||||
prosemirror-menu "^1.2.4"
|
||||
prosemirror-model "^1.24.1"
|
||||
prosemirror-schema-basic "^1.2.3"
|
||||
prosemirror-schema-list "^1.5.0"
|
||||
prosemirror-state "^1.4.3"
|
||||
prosemirror-tables "^1.6.4"
|
||||
prosemirror-trailing-node "^3.0.0"
|
||||
prosemirror-transform "^1.10.2"
|
||||
prosemirror-view "^1.38.1"
|
||||
|
||||
"@tiptap/react@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-3.6.2.tgz#5495776c9051a60ece7522da176c9f211a67c7df"
|
||||
integrity sha512-jgG+bM/GDvI6jnqW3YyLtr/vOR6iO2ta9PYVzoWqNYIxISsMOJeRfinsIqB8l6hkiGZApn9bQji6oUXTc59fgA==
|
||||
dependencies:
|
||||
"@types/use-sync-external-store" "^0.0.6"
|
||||
fast-deep-equal "^3.1.3"
|
||||
use-sync-external-store "^1.4.0"
|
||||
optionalDependencies:
|
||||
"@tiptap/extension-bubble-menu" "^3.6.2"
|
||||
"@tiptap/extension-floating-menu" "^3.6.2"
|
||||
|
||||
"@tiptap/starter-kit@^3.6.2":
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-3.6.2.tgz#ddd5612d4836a87082254779c9f152bb51e757bc"
|
||||
integrity sha512-nPzraIx/f1cOUNqG1LSC0OTnEu3mudcN3jQVuyGh3dvdOnik7FUciJEVfHKnloAyeoijidEeiLpiGHInp2uREg==
|
||||
dependencies:
|
||||
"@tiptap/core" "^3.6.2"
|
||||
"@tiptap/extension-blockquote" "^3.6.2"
|
||||
"@tiptap/extension-bold" "^3.6.2"
|
||||
"@tiptap/extension-bullet-list" "^3.6.2"
|
||||
"@tiptap/extension-code" "^3.6.2"
|
||||
"@tiptap/extension-code-block" "^3.6.2"
|
||||
"@tiptap/extension-document" "^3.6.2"
|
||||
"@tiptap/extension-dropcursor" "^3.6.2"
|
||||
"@tiptap/extension-gapcursor" "^3.6.2"
|
||||
"@tiptap/extension-hard-break" "^3.6.2"
|
||||
"@tiptap/extension-heading" "^3.6.2"
|
||||
"@tiptap/extension-horizontal-rule" "^3.6.2"
|
||||
"@tiptap/extension-italic" "^3.6.2"
|
||||
"@tiptap/extension-link" "^3.6.2"
|
||||
"@tiptap/extension-list" "^3.6.2"
|
||||
"@tiptap/extension-list-item" "^3.6.2"
|
||||
"@tiptap/extension-list-keymap" "^3.6.2"
|
||||
"@tiptap/extension-ordered-list" "^3.6.2"
|
||||
"@tiptap/extension-paragraph" "^3.6.2"
|
||||
"@tiptap/extension-strike" "^3.6.2"
|
||||
"@tiptap/extension-text" "^3.6.2"
|
||||
"@tiptap/extension-underline" "^3.6.2"
|
||||
"@tiptap/extensions" "^3.6.2"
|
||||
"@tiptap/pm" "^3.6.2"
|
||||
|
||||
"@tokenizer/inflate@^0.2.6":
|
||||
version "0.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@tokenizer/inflate/-/inflate-0.2.7.tgz#32dd9dfc9abe457c89b3d9b760fc0690c85a103b"
|
||||
@@ -3014,6 +3214,11 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/linkify-it@^5":
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
|
||||
integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
|
||||
|
||||
"@types/lodash-es@^4.17.12":
|
||||
version "4.17.12"
|
||||
resolved "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz"
|
||||
@@ -3033,6 +3238,19 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/markdown-it@^14.0.0":
|
||||
version "14.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61"
|
||||
integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==
|
||||
dependencies:
|
||||
"@types/linkify-it" "^5"
|
||||
"@types/mdurl" "^2"
|
||||
|
||||
"@types/mdurl@^2":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd"
|
||||
integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==
|
||||
|
||||
"@types/minimatch@*":
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca"
|
||||
@@ -3125,6 +3343,11 @@
|
||||
resolved "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz"
|
||||
integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==
|
||||
|
||||
"@types/use-sync-external-store@^0.0.6":
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc"
|
||||
integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==
|
||||
|
||||
"@types/user-agents@^1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.npmjs.org/@types/user-agents/-/user-agents-1.0.4.tgz"
|
||||
@@ -4209,6 +4432,11 @@ create-require@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
|
||||
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
|
||||
|
||||
crelt@^1.0.0:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
|
||||
integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
|
||||
|
||||
cross-fetch-ponyfill@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/cross-fetch-ponyfill/-/cross-fetch-ponyfill-1.0.3.tgz#5c5524e3bd3374e71d5016c2327e416369a57527"
|
||||
@@ -6614,6 +6842,18 @@ lines-and-columns@^1.1.6:
|
||||
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
||||
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
||||
|
||||
linkify-it@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
|
||||
integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
|
||||
dependencies:
|
||||
uc.micro "^2.0.0"
|
||||
|
||||
linkifyjs@^4.3.2:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1"
|
||||
integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==
|
||||
|
||||
locate-path@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
||||
@@ -6779,6 +7019,11 @@ lru-cache@^7.7.1:
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89"
|
||||
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
|
||||
|
||||
lucide-react@^0.544.0:
|
||||
version "0.544.0"
|
||||
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.544.0.tgz#4719953c10fd53a64dd8343bb0ed16ec79f3eeef"
|
||||
integrity sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==
|
||||
|
||||
magic-string@^0.30.17:
|
||||
version "0.30.17"
|
||||
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453"
|
||||
@@ -6822,6 +7067,18 @@ make-fetch-happen@^10.2.1:
|
||||
socks-proxy-agent "^7.0.0"
|
||||
ssri "^9.0.0"
|
||||
|
||||
markdown-it@^14.0.0:
|
||||
version "14.1.0"
|
||||
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
|
||||
integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
|
||||
dependencies:
|
||||
argparse "^2.0.1"
|
||||
entities "^4.4.0"
|
||||
linkify-it "^5.0.0"
|
||||
mdurl "^2.0.0"
|
||||
punycode.js "^2.3.1"
|
||||
uc.micro "^2.1.0"
|
||||
|
||||
matcher@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/matcher/-/matcher-3.0.0.tgz#bd9060f4c5b70aa8041ccc6f80368760994f30ca"
|
||||
@@ -6844,6 +7101,11 @@ maybe-combine-errors@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/maybe-combine-errors/-/maybe-combine-errors-1.0.0.tgz#e9592832e61fc47643a92cff3c1f33e27211e5be"
|
||||
integrity sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A==
|
||||
|
||||
mdurl@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
|
||||
integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
|
||||
|
||||
meow@^12.0.1:
|
||||
version "12.1.1"
|
||||
resolved "https://registry.yarnpkg.com/meow/-/meow-12.1.1.tgz#e558dddbab12477b69b2e9a2728c327f191bace6"
|
||||
@@ -7264,6 +7526,11 @@ ora@^5.1.0:
|
||||
strip-ansi "^6.0.0"
|
||||
wcwidth "^1.0.1"
|
||||
|
||||
orderedmap@^2.0.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2"
|
||||
integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==
|
||||
|
||||
own-keys@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358"
|
||||
@@ -7499,6 +7766,160 @@ property-expr@^2.0.5:
|
||||
resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.6.tgz#f77bc00d5928a6c748414ad12882e83f24aec1e8"
|
||||
integrity sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==
|
||||
|
||||
prosemirror-changeset@^2.3.0:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz#eee3299cfabc7a027694e9abdc4e85505e9dd5e7"
|
||||
integrity sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==
|
||||
dependencies:
|
||||
prosemirror-transform "^1.0.0"
|
||||
|
||||
prosemirror-collab@^1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz#0e8c91e76e009b53457eb3b3051fb68dad029a33"
|
||||
integrity sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==
|
||||
dependencies:
|
||||
prosemirror-state "^1.0.0"
|
||||
|
||||
prosemirror-commands@^1.0.0, prosemirror-commands@^1.6.2:
|
||||
version "1.7.1"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz#d101fef85618b1be53d5b99ea17bee5600781b38"
|
||||
integrity sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==
|
||||
dependencies:
|
||||
prosemirror-model "^1.0.0"
|
||||
prosemirror-state "^1.0.0"
|
||||
prosemirror-transform "^1.10.2"
|
||||
|
||||
prosemirror-dropcursor@^1.8.1:
|
||||
version "1.8.2"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz#2ed30c4796109ddeb1cf7282372b3850528b7228"
|
||||
integrity sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==
|
||||
dependencies:
|
||||
prosemirror-state "^1.0.0"
|
||||
prosemirror-transform "^1.1.0"
|
||||
prosemirror-view "^1.1.0"
|
||||
|
||||
prosemirror-gapcursor@^1.3.2:
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz#5fa336b83789c6199a7341c9493587e249215cb4"
|
||||
integrity sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==
|
||||
dependencies:
|
||||
prosemirror-keymap "^1.0.0"
|
||||
prosemirror-model "^1.0.0"
|
||||
prosemirror-state "^1.0.0"
|
||||
prosemirror-view "^1.0.0"
|
||||
|
||||
prosemirror-history@^1.0.0, prosemirror-history@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.4.1.tgz#cc370a46fb629e83a33946a0e12612e934ab8b98"
|
||||
integrity sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==
|
||||
dependencies:
|
||||
prosemirror-state "^1.2.2"
|
||||
prosemirror-transform "^1.0.0"
|
||||
prosemirror-view "^1.31.0"
|
||||
rope-sequence "^1.3.0"
|
||||
|
||||
prosemirror-inputrules@^1.4.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz#e22bfaf1d6ea4fe240ad447c184af3d520d43c37"
|
||||
integrity sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==
|
||||
dependencies:
|
||||
prosemirror-state "^1.0.0"
|
||||
prosemirror-transform "^1.0.0"
|
||||
|
||||
prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.2:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz#c0f6ab95f75c0b82c97e44eb6aaf29cbfc150472"
|
||||
integrity sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==
|
||||
dependencies:
|
||||
prosemirror-state "^1.0.0"
|
||||
w3c-keyname "^2.2.0"
|
||||
|
||||
prosemirror-markdown@^1.13.1:
|
||||
version "1.13.2"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz#863eb3fd5f57a444e4378174622b562735b1c503"
|
||||
integrity sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==
|
||||
dependencies:
|
||||
"@types/markdown-it" "^14.0.0"
|
||||
markdown-it "^14.0.0"
|
||||
prosemirror-model "^1.25.0"
|
||||
|
||||
prosemirror-menu@^1.2.4:
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz#dea00e7b623cea89f4d76963bee22d2ac2343250"
|
||||
integrity sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==
|
||||
dependencies:
|
||||
crelt "^1.0.0"
|
||||
prosemirror-commands "^1.0.0"
|
||||
prosemirror-history "^1.0.0"
|
||||
prosemirror-state "^1.0.0"
|
||||
|
||||
prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.0:
|
||||
version "1.25.3"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.3.tgz#c657c60a361cb1e9c9f683d19118c0af50a6f7a9"
|
||||
integrity sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==
|
||||
dependencies:
|
||||
orderedmap "^2.0.0"
|
||||
|
||||
prosemirror-schema-basic@^1.2.3:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz#389ce1ec09b8a30ea9bbb92c58569cb690c2d695"
|
||||
integrity sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==
|
||||
dependencies:
|
||||
prosemirror-model "^1.25.0"
|
||||
|
||||
prosemirror-schema-list@^1.5.0:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz#5869c8f749e8745c394548bb11820b0feb1e32f5"
|
||||
integrity sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==
|
||||
dependencies:
|
||||
prosemirror-model "^1.0.0"
|
||||
prosemirror-state "^1.0.0"
|
||||
prosemirror-transform "^1.7.3"
|
||||
|
||||
prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3:
|
||||
version "1.4.3"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.3.tgz#94aecf3ffd54ec37e87aa7179d13508da181a080"
|
||||
integrity sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==
|
||||
dependencies:
|
||||
prosemirror-model "^1.0.0"
|
||||
prosemirror-transform "^1.0.0"
|
||||
prosemirror-view "^1.27.0"
|
||||
|
||||
prosemirror-tables@^1.6.4:
|
||||
version "1.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-1.8.1.tgz#896a234e3e18240b629b747a871369dae78c8a9a"
|
||||
integrity sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==
|
||||
dependencies:
|
||||
prosemirror-keymap "^1.2.2"
|
||||
prosemirror-model "^1.25.0"
|
||||
prosemirror-state "^1.4.3"
|
||||
prosemirror-transform "^1.10.3"
|
||||
prosemirror-view "^1.39.1"
|
||||
|
||||
prosemirror-trailing-node@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz#5bc223d4fc1e8d9145e4079ec77a932b54e19e04"
|
||||
integrity sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==
|
||||
dependencies:
|
||||
"@remirror/core-constants" "3.0.0"
|
||||
escape-string-regexp "^4.0.0"
|
||||
|
||||
prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.3, prosemirror-transform@^1.7.3:
|
||||
version "1.10.4"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz#56419eac14f9f56612c806ae46f9238648f3f02e"
|
||||
integrity sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==
|
||||
dependencies:
|
||||
prosemirror-model "^1.21.0"
|
||||
|
||||
prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.39.1:
|
||||
version "1.41.2"
|
||||
resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.41.2.tgz#e69ad3883bfd3c9f3c9cf6da5cee940210df0b6f"
|
||||
integrity sha512-PGS/jETmh+Qjmre/6vcG7SNHAKiGc4vKOJmHMPRmvcUl7ISuVtrtHmH06UDUwaim4NDJfZfVMl7U7JkMMETa6g==
|
||||
dependencies:
|
||||
prosemirror-model "^1.20.0"
|
||||
prosemirror-state "^1.0.0"
|
||||
prosemirror-transform "^1.1.0"
|
||||
|
||||
proxy-from-env@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
|
||||
@@ -7517,6 +7938,11 @@ pump@^3.0.0:
|
||||
end-of-stream "^1.1.0"
|
||||
once "^1.3.1"
|
||||
|
||||
punycode.js@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
|
||||
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
|
||||
|
||||
punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
@@ -7901,6 +8327,11 @@ rollup@^4.20.0:
|
||||
"@rollup/rollup-win32-x64-msvc" "4.23.0"
|
||||
fsevents "~2.3.2"
|
||||
|
||||
rope-sequence@^1.3.0:
|
||||
version "1.3.4"
|
||||
resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425"
|
||||
integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==
|
||||
|
||||
rrweb-cssom@^0.7.1:
|
||||
version "0.7.1"
|
||||
resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz#c73451a484b86dd7cfb1e0b2898df4b703183e4b"
|
||||
@@ -8902,6 +9333,11 @@ typescript@^5.3.3, typescript@^5.4.3:
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0"
|
||||
integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==
|
||||
|
||||
uc.micro@^2.0.0, uc.micro@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
|
||||
integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
|
||||
|
||||
uint8-util@^2.2.2, uint8-util@^2.2.5:
|
||||
version "2.2.5"
|
||||
resolved "https://registry.yarnpkg.com/uint8-util/-/uint8-util-2.2.5.tgz#f1a8ff800e4e10a3ac1c82ee3667c99245123896"
|
||||
@@ -9021,6 +9457,11 @@ use-sync-external-store@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9"
|
||||
integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==
|
||||
|
||||
use-sync-external-store@^1.4.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0"
|
||||
integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==
|
||||
|
||||
user-agents@^1.1.387:
|
||||
version "1.1.387"
|
||||
resolved "https://registry.yarnpkg.com/user-agents/-/user-agents-1.1.387.tgz#afc69da00b50eee7ffa17724890e755a6672b99f"
|
||||
@@ -9087,6 +9528,11 @@ void-elements@3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09"
|
||||
integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==
|
||||
|
||||
w3c-keyname@^2.2.0:
|
||||
version "2.2.8"
|
||||
resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
|
||||
integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
|
||||
|
||||
w3c-xmlserializer@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c"
|
||||
|
||||
Reference in New Issue
Block a user