Compare commits

..

22 Commits

Author SHA1 Message Date
Zamitto
ba9232b821 Merge branch 'main' into feature/accessibility-improvements 2024-11-01 09:19:02 -03:00
cj do gta sander pegando fogo aaa aaa aaa
a432306b1d Merge branch 'main' into feature/accessibility-improvements 2024-10-30 01:16:04 -03:00
cj do gta sander pegando fogo aaa aaa aaa
69d96cc290 Merge branch 'main' into feature/accessibility-improvements 2024-10-29 18:27:49 -03:00
cj-do-gta-sander
8f4919615f lint: how-long-to-beat-section.tsx 2024-10-24 14:53:36 -03:00
cj-do-gta-sander
a25a960235 feat: add aria labels to How Long to Beat and Achievements in game details sidebar 2024-10-24 14:52:54 -03:00
cj-do-gta-sander
c754710171 feat: enable tab navigation through carousel previews, replacing arrow buttons 2024-10-24 14:27:54 -03:00
cj-do-gta-sander
bf6ce2b465 feat: add ability to blur search bar with Escape key 2024-10-24 12:24:20 -03:00
cj-do-gta-sander
3adc8662dc feat: add keyboard shortcut to focus header search bar 2024-10-24 12:11:49 -03:00
cj-do-gta-sander
2a6346cb69 feat: add tooltip and screen reader support to sidebar resize button 2024-10-24 11:50:01 -03:00
cj-do-gta-sander
455016c1a7 lint: sidebar.tsx 2024-10-24 11:46:37 -03:00
cj-do-gta-sander
e0ec79b105 feat: add keyboard control for sidebar size adjustment 2024-10-24 11:45:41 -03:00
cj-do-gta-sander
ba7e4c979d feat: add tooltip and screen reader support to clear search button 2024-10-24 11:06:03 -03:00
cj-do-gta-sander
a54983c339 feat: add tabindex 0 to search bar icon for direct focus 2024-10-24 11:02:48 -03:00
cj-do-gta-sander
b754b1e052 feat: add tooltip and screen reader support to back button in header 2024-10-24 10:58:00 -03:00
cj-do-gta-sander
79763b6072 chore: remove redundancy in "View Profile" and "Game" button for screen readers 2024-10-24 10:49:01 -03:00
cj-do-gta-sander
3ff15d2d61 chore: remove redundancy in list semantic markup 2024-10-24 10:04:34 -03:00
cj-do-gta-sander
50303251a2 feat: add semantic roles and aria labels to achievement list 2024-10-24 09:57:11 -03:00
cj-do-gta-sander
012f872f60 chore: chore: remove unnecessary locale string and use locked status strings 2024-10-24 09:55:22 -03:00
cj-do-gta-sander
e9f68977fe feat: add aria label to achievement summary 2024-10-24 09:39:15 -03:00
cj-do-gta-sander
c5d4db0a1e chore: change locale param names to match with args 2024-10-24 09:37:51 -03:00
cj-do-gta-sander
3b02a3c43f chore: move aria-labels strings to their respective pages in locales 2024-10-24 09:27:39 -03:00
cj-do-gta-sander
ab7625a314 feat: add aria-label strings to locales for achievements page 2024-10-24 09:03:48 -03:00
98 changed files with 7721 additions and 1679 deletions

View File

@@ -1,4 +1,5 @@
MAIN_VITE_API_URL=API_URL
MAIN_VITE_AUTH_URL=AUTH_URL
MAIN_VITE_STEAMGRIDDB_API_KEY=YOUR_API_KEY
RENDERER_VITE_INTERCOM_APP_ID=YOUR_APP_ID
MAIN_VITE_SENTRY_DSN=YOUR_SENTRY_DSN
SENTRY_AUTH_TOKEN=

View File

@@ -2,9 +2,6 @@ name: Build
on: pull_request
env:
AWS_REGION: us-east-1
jobs:
build:
strategy:
@@ -17,59 +14,61 @@ jobs:
- name: Check out Git repository
uses: actions/checkout@v4
# - name: Install Node.js
# uses: actions/setup-node@v4
# with:
# node-version: 20.18.0
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.11.1
# - name: Install dependencies
# run: yarn
- name: Install dependencies
run: yarn
# - name: Install Python
# uses: actions/setup-python@v5
# with:
# python-version: 3.9
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: 3.9
# - name: Install dependencies
# run: pip install -r requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt
# - name: Build with cx_Freeze
# run: python torrent-client/setup.py build
- name: Build with cx_Freeze
run: python torrent-client/setup.py build
# - name: Build Linux
# if: matrix.os == 'ubuntu-latest'
# run: |
# sudo apt-get update
# sudo apt-get install -y libarchive-tools
# yarn build:linux
# env:
# MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
# MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
# MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
# MAIN_VITE_ANALYTICS_API_URL: ${{ vars.MAIN_VITE_ANALYTICS_API_URL }}
# RENDERER_VITE_INTERCOM_APP_ID: ${{ vars.RENDERER_VITE_INTERCOM_APP_ID }}
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Linux
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libarchive-tools
yarn build:linux
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# - name: Build Windows
# if: matrix.os == 'windows-latest'
# run: yarn build:win
# env:
# MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
# MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
# MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
# MAIN_VITE_ANALYTICS_API_URL: ${{ vars.MAIN_VITE_ANALYTICS_API_URL }}
# RENDERER_VITE_INTERCOM_APP_ID: ${{ vars.RENDERER_VITE_INTERCOM_APP_ID }}
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Windows
if: matrix.os == 'windows-latest'
run: yarn build:win
env:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_STAGING_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_STAGING_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_STAGING_CHECKOUT_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create artifact
id: artifact-upload-step
uses: actions/upload-artifact@v4
with:
name: Build-${{ matrix.os }}
path: |
dist/*-portable.exe
dist/*.zip
dist/*.dmg
dist/*.deb
README.md
- name: Output artifact URL
run: echo 'Artifact URL is ${{ steps.artifact-upload-step.outputs.artifact-url }}'
dist/*.rpm
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
dist/*.pacman

View File

@@ -13,7 +13,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.18.0
node-version: 20.11.1
- name: Install dependencies
run: yarn

View File

@@ -19,7 +19,7 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20.18.0
node-version: 20.11.1
- name: Install dependencies
run: yarn
@@ -45,9 +45,10 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
MAIN_VITE_ANALYTICS_API_URL: ${{ vars.MAIN_VITE_ANALYTICS_API_URL }}
RENDERER_VITE_INTERCOM_APP_ID: ${{ vars.RENDERER_VITE_INTERCOM_APP_ID }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build Windows
if: matrix.os == 'windows-latest'
run: yarn build:win
@@ -55,9 +56,10 @@ jobs:
MAIN_VITE_API_URL: ${{ vars.MAIN_VITE_API_URL }}
MAIN_VITE_AUTH_URL: ${{ vars.MAIN_VITE_AUTH_URL }}
MAIN_VITE_CHECKOUT_URL: ${{ vars.MAIN_VITE_CHECKOUT_URL }}
MAIN_VITE_ANALYTICS_API_URL: ${{ vars.MAIN_VITE_ANALYTICS_API_URL }}
RENDERER_VITE_INTERCOM_APP_ID: ${{ vars.RENDERER_VITE_INTERCOM_APP_ID }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
MAIN_VITE_SENTRY_DSN: ${{ vars.MAIN_VITE_SENTRY_DSN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create artifact
uses: actions/upload-artifact@v4
with:
@@ -75,7 +77,7 @@ jobs:
dist/*.pacman
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v1
with:
draft: true
files: |

1
.gitignore vendored
View File

@@ -9,4 +9,5 @@ out
*.log*
.env
.vite
sentry.properties
ludusavi/

View File

@@ -7,7 +7,6 @@ extraResources:
- hydra-download-manager
- seeds
- from: node_modules/create-desktop-shortcuts/src/windows.vbs
- from: resources/achievement.wav
files:
- "!**/.vscode/*"
- "!src/*"

View File

@@ -6,9 +6,16 @@ import {
externalizeDepsPlugin,
} from "electron-vite";
import react from "@vitejs/plugin-react";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin";
import svgr from "vite-plugin-svgr";
const sentryPlugin = sentryVitePlugin({
authToken: process.env.SENTRY_AUTH_TOKEN,
org: "hydra-launcher",
project: "hydra-launcher",
});
export default defineConfig(({ mode }) => {
loadEnv(mode);
@@ -28,7 +35,7 @@ export default defineConfig(({ mode }) => {
"@shared": resolve("src/shared"),
},
},
plugins: [externalizeDepsPlugin(), swcPlugin()],
plugins: [externalizeDepsPlugin(), swcPlugin(), sentryPlugin],
},
preload: {
plugins: [externalizeDepsPlugin()],
@@ -44,7 +51,7 @@ export default defineConfig(({ mode }) => {
"@shared": resolve("src/shared"),
},
},
plugins: [svgr(), react(), vanillaExtractPlugin()],
plugins: [svgr(), react(), vanillaExtractPlugin(), sentryPlugin],
},
};
});

View File

@@ -1,6 +1,6 @@
{
"name": "hydralauncher",
"version": "3.0.5",
"version": "3.0.4",
"description": "Hydra",
"main": "./out/main/index.js",
"author": "Los Broxas",
@@ -36,9 +36,9 @@
"@electron-toolkit/utils": "^3.0.0",
"@fontsource/noto-sans": "^5.0.22",
"@hookform/resolvers": "^3.9.0",
"@intercom/messenger-js-sdk": "^0.0.14",
"@primer/octicons-react": "^19.9.0",
"@reduxjs/toolkit": "^2.2.3",
"@sentry/electron": "^5.1.0",
"@vanilla-extract/css": "^1.14.2",
"@vanilla-extract/dynamic": "^2.1.1",
"@vanilla-extract/recipes": "^0.5.2",
@@ -51,25 +51,25 @@
"color.js": "^1.2.0",
"create-desktop-shortcuts": "^1.11.0",
"date-fns": "^3.6.0",
"dexie": "^4.0.9",
"dexie": "^4.0.8",
"electron-log": "^5.2.0",
"electron-updater": "^6.3.9",
"file-type": "^19.6.0",
"flexsearch": "^0.7.43",
"i18next": "^23.11.2",
"i18next-browser-languagedetector": "^7.2.1",
"icojs": "^0.19.4",
"jsdom": "^24.0.0",
"jsonwebtoken": "^9.0.2",
"knex": "^3.1.0",
"lodash-es": "^4.17.21",
"lottie-react": "^2.4.0",
"parse-torrent": "^11.0.17",
"piscina": "^4.7.0",
"piscina": "^4.5.1",
"react-hook-form": "^7.53.0",
"react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0",
"react-redux": "^9.1.1",
"react-router-dom": "^6.22.3",
"sound-play": "^1.1.0",
"sudo-prompt": "^9.2.1",
"tar": "^7.4.3",
"typeorm": "^0.3.20",
@@ -79,11 +79,12 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
"@commitlint/cli": "^19.3.0",
"@commitlint/config-conventional": "^19.2.2",
"@electron-toolkit/eslint-config-prettier": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^2.0.0",
"@electron-toolkit/eslint-config-ts": "^1.0.1",
"@electron-toolkit/tsconfig": "^1.0.1",
"@sentry/vite-plugin": "^2.20.1",
"@swc/core": "^1.4.16",
"@types/auto-launch": "^5.0.5",
"@types/color": "^3.0.6",
@@ -95,7 +96,6 @@
"@types/parse-torrent": "^5.8.7",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/sound-play": "^1.1.3",
"@types/user-agents": "^1.0.4",
"@vanilla-extract/vite-plugin": "^4.0.7",
"@vitejs/plugin-react": "^4.2.1",
@@ -110,7 +110,6 @@
"prettier": "^3.2.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass-embedded": "^1.80.6",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
"vite": "^5.0.12",

View File

@@ -1,5 +1,5 @@
libtorrent
cx_Freeze == 7.2.3
cx_Freeze
cx_Logging; sys_platform == 'win32'
pywin32; sys_platform == 'win32'
psutil

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -1,381 +0,0 @@
{
"language_name": "Български",
"app": {
"successfully_signed_in": "Успешно вписване"
},
"home": {
"featured": "Препоръчани",
"surprise_me": "Изненадай ме",
"no_results": "Не са намерени резултати",
"start_typing": "Търсене...",
"hot": "Актуално сега",
"weekly": "📅 Най-доброто от седмицата",
"achievements": "🏆 Игри, които да победите"
},
"sidebar": {
"catalogue": "Каталог",
"downloads": "Изтегляния",
"settings": "Настройки",
"my_library": "Моята библиотека",
"downloading_metadata": "{{title}} (Сваляне на метаданни…)",
"paused": "{{title}} (Пауза)",
"downloading": "{{title}} ({{percentage}} - Изтегляне…)",
"filter": "Търсене по име",
"home": "Начало",
"queued": "{{title}} (Опашка)",
"game_has_no_executable": "Играта няма избран изпълним файл",
"sign_in": "Вписване",
"friends": "Приятели",
"need_help": "Имате нужда от помощ??"
},
"header": {
"search": "Търси игри",
"home": "Начало",
"catalogue": "Каталог",
"downloads": "Изтегляния",
"search_results": "Резултати от търсене",
"settings": "Настройки",
"version_available_install": "Версия {{version}} е налична. Кликни тук, за да рестартирате и инсталирате.",
"version_available_download": "Версия {{version}} е налична. Кликни тук за изтегляне."
},
"bottom_panel": {
"no_downloads_in_progress": "Няма изтегляния в ход",
"downloading_metadata": "Сваляне на {{title}} метадата…",
"downloading": "Изтегляне на {{title}}… ({{percentage}} готово) - Остават {{eta}} - {{speed}}",
"calculating_eta": "Изтегляне на {{title}}… ({{percentage}} готово) - Изчисляване на оставащо време…",
"checking_files": "Проверка на {{title}} файловете… ({{percentage}} готово)"
},
"catalogue": {
"next_page": "Следваща страница",
"previous_page": "Предишна страница"
},
"game_details": {
"open_download_options": "Варианти за изтегляне",
"download_options_zero": "Няма варианти за изтегляне",
"download_options_one": "{{count}} варианти за изтегляне",
"download_options_other": "{{count}} варианти за изтегляне",
"updated_at": "Обновено на {{updated_at}}",
"install": "Инсталирай",
"resume": "Продължи",
"pause": "Пауза",
"cancel": "Отказ",
"remove": "Премахни",
"space_left_on_disk": "{{space}} място на диска",
"eta": "Заклчение {{eta}}",
"calculating_eta": "Калкулиране на оставащо време…",
"downloading_metadata": "Изтегляне на метадата…",
"filter": "Филтрирай repacks",
"requirements": "Състемни изисквания",
"minimum": "Минимални",
"recommended": "Препоръчителни",
"paused": "Паузирано",
"release_date": "Издадено на {{date}}",
"publisher": "Публикувано от {{publisher}}",
"hours": "часове",
"minutes": "минути",
"amount_hours": "{{amount}} часа",
"amount_minutes": "{{amount}} минути",
"accuracy": "{{accuracy}}% точност",
"add_to_library": "Добави в библиотеката",
"remove_from_library": "Премахни от библиотеката",
"no_downloads": "Няма налични изтегляния",
"play_time": "Играно {{amount}}",
"last_time_played": "Последно играно {{period}}",
"not_played_yet": "Не сте играли {{title}} все още",
"next_suggestion": "Следващо предложение",
"play": "Пускане",
"deleting": "Изтриване на инсталация…",
"close": "Затвори",
"playing_now": "Играй сега",
"change": "Промяна",
"repacks_modal_description": "Избери repack който искаш да изтеглиш",
"select_folder_hint": "За да промените стандартната папка отидете в <0>Настройки</0>",
"download_now": "Изтегли сега",
"no_shop_details": "Не може да се извлекат данни за магазина.",
"download_options": "Опции за сваляне",
"download_path": "Път за сваляне",
"previous_screenshot": "Предишна снимка",
"next_screenshot": "Следваща снимка",
"screenshot": "Снимка {{number}}",
"open_screenshot": "Отвори снимки {{number}}",
"download_settings": "Настройки за сваляне",
"downloader": "Downloader",
"select_executable": "Избери",
"no_executable_selected": "Няма избран стартиращ файл",
"open_folder": "Отвори папка",
"open_download_location": "Виж свалените файлове",
"create_shortcut": "Пряк път на Десктопа",
"remove_files": "Премахни файловете",
"remove_from_library_title": "Сигурен ли си?",
"remove_from_library_description": "Това ще премахне {{game}} от Библиотеката",
"options": "Опции",
"executable_section_title": "Стартиращ файл",
"executable_section_description": "Пътят на файла, който ще се изпълни, когато се щракне върху \"Играй\"",
"downloads_secion_title": "Свалени",
"downloads_section_description": "Вижте актуализации или други версии на тази игра",
"danger_zone_section_title": "Опасна зона",
"danger_zone_section_description": "Премахнете тази игра от библиотеката си или от файловете, изтеглени от Hydra",
"download_in_progress": "Изтегляне в ход",
"download_paused": "Изтеглянето е паузирано",
"last_downloaded_option": "Опция от последно изтегляне",
"create_shortcut_success": "Прекият път е създаден успешно",
"create_shortcut_error": "Грешка при създаването на пряк път",
"nsfw_content_title": "Тази игра съдържа неподходящо съдържание",
"nsfw_content_description": "{{title}} съдържа съдържание, което може да не е подходящо за всички възрасти. Сигурни ли сте, че искате да продължите?",
"allow_nsfw_content": "Продължи",
"refuse_nsfw_content": "Назад",
"stats": "Статистики",
"download_count": "Сваляния",
"player_count": "Активни играчи",
"download_error": "Тази опция за изтегляне не е налична",
"download": "Свали",
"executable_path_in_use": "Изпълнимият файл вече се използва от \"{{game}}\"",
"warning": "Внимание:",
"hydra_needs_to_remain_open": "за това изтегляне, Hydra трябва да остане отворена, когато е завършено. Ако Hydra се затвори преди завършването, ще загубите напредъка си..",
"achievements": "Постижения",
"achievements_count": "Постижения {{unlockedCount}}/{{achievementsCount}}",
"cloud_save": "Запазване в облака",
"cloud_save_description": "Запазете напредъка си в облака и продължете да играете на всяко устройство",
"backups": "Резервни копия",
"install_backup": "Инсталирай",
"delete_backup": "Изтрий",
"create_backup": "Ново копие",
"last_backup_date": "Последно копие от {{date}}",
"no_backup_preview": "Не бяха намерени запазени игри за това заглавие",
"restoring_backup": "Възстановяване на резервно копие ({{progress}} готово)…",
"uploading_backup": "Качване на резервно копие…",
"no_backups": "Все още не сте създали резервни копия за тази игра",
"backup_uploaded": "Качено резервно копие",
"backup_deleted": "Изтрито резервно копие",
"backup_restored": "Възстановен бекъп",
"see_all_achievements": "Вижте всички постижения",
"sign_in_to_see_achievements": "Влезте, за да видите постиженията",
"mapping_method_automatic": "Автоматично",
"mapping_method_manual": "Ръчно",
"mapping_method_label": "Метод на картографиране",
"files_automatically_mapped": "Автоматично картографиране на файлове",
"no_backups_created": "Не са създадени резервни копия за тази игра",
"manage_files": "Управление на файлове",
"loading_save_preview": "Търсене на запазени игри…",
"wine_prefix": "Wine Префикс",
"wine_prefix_description": "Wine prefix използван за тази игра",
"no_download_option_info": "Няма налични данни",
"backup_deletion_failed": "Неуспешно изтриване на резервно копие",
"max_number_of_artifacts_reached": "Достигнат максимален брой резервни копия за тази игра",
"achievements_not_sync": "Постиженията ви не са синхронизирани",
"manage_files_description": "Управлявайте кои файлове ще бъдат архивирани и възстановени",
"select_folder": "Избери папка",
"backup_from": "Резервно копие от {{date}}",
"custom_backup_location_set": "Задаване на персонализирано местоположение за архивиране"
},
"activation": {
"title": "Активирай Hydra",
"installation_id": "Идентификатор на инсталацията:",
"enter_activation_code": "Въведете кода за активиране",
"message": "Ако не знаете къде да попитате за това, значи не трябва да го имате..",
"activate": "Активирай",
"loading": "Зареждане…"
},
"downloads": {
"resume": "Продължи",
"pause": "Пауза",
"eta": "Conclusion {{eta}}",
"paused": "Паузирано",
"verifying": "Проверка…",
"completed": "Готово",
"removed": "Не е изтеглен",
"cancel": "Отказ",
"filter": "Филтриране на изтеглени игри",
"remove": "Премахни",
"downloading_metadata": "Изтегляне на метаданни…",
"deleting": "Изтриване на инсталатора…",
"delete": "Премахване на инсталатора",
"delete_modal_title": "Сигурени ли сте?",
"delete_modal_description": "Това ще премахне всички инсталационни файлове от компютъра ви.",
"install": "Инсталирай",
"download_in_progress": "В процес на изпълнение",
"queued_downloads": "Изтеглени файлове в опашката",
"downloads_completed": "Приключени",
"queued": "В опашка",
"no_downloads_title": "Толкова е празно",
"no_downloads_description": "Все още не сте изтеглили нищо с Hydra, но никога не е късно да започнете..",
"checking_files": "Проверка на файлове…"
},
"settings": {
"downloads_path": "Инсталационен път",
"change": "Актуализиране",
"notifications": "Известия",
"enable_download_notifications": "Когато изтеглянето е завършено",
"enable_repack_list_notifications": "Когато се добави нов repack",
"real_debrid_api_token_label": "Real-Debrid API токен",
"quit_app_instead_hiding": "Не скривайте Hydra при затваряне",
"launch_with_system": "Стартиране на Hydra при стартиране на системата",
"general": "Общ",
"behavior": "Поведение",
"download_sources": "Източници за изтегляне",
"language": "Език",
"real_debrid_api_token": "API Токен",
"enable_real_debrid": "Включи Real-Debrid",
"real_debrid_description": "Real-Debrid е неограничен даунлоудър, който ви позволява бързо да изтегляте файлове, ограничени само от скоростта на интернет..",
"real_debrid_invalid_token": "Невалиден API токен",
"real_debrid_api_token_hint": "Вземете своя API токен <0>тук</0>",
"real_debrid_free_account_error": "Акаунтът \"{{username}}\" е безплатен акаунт. Моля абонирай се за Real-Debrid",
"real_debrid_linked_message": "Акаунтът \"{{username}}\" е свързан",
"save_changes": "Запази промените",
"changes_saved": "Промените са успешно запазни",
"download_sources_description": "Hydra ще извлича връзките за изтегляне от тези източници. URL адресът на източника трябва да е директна връзка към .json файл, съдържащ връзките за изтегляне.",
"validate_download_source": "Валидиране",
"remove_download_source": "Премахни",
"add_download_source": "Добави източник",
"download_count_zero": "Няма опции за сваляне",
"download_count_one": "{{countFormatted}} опции за сваляне",
"download_count_other": "{{countFormatted}} опции за сваляне",
"download_source_url": "URL адрес на източника за изтегляне",
"add_download_source_description": "Вмъкнете URL адреса на файла .json",
"download_source_up_to_date": "Актуален",
"download_source_errored": "Сгрешен",
"sync_download_sources": "Синхронизирай източниците",
"removed_download_source": "Източника за сваляне е премахнат",
"added_download_source": "Добавен източник за сваляне",
"download_sources_synced": "Всички източници за сваляне са синхронизирани",
"insert_valid_json_url": "Добавете ваиден JSON линк",
"found_download_option_zero": "Няма намерени опции за сваляне",
"found_download_option_one": "Намерени {{countFormatted}} опции за сваляне",
"found_download_option_other": "Намерени {{countFormatted}} опции за сваляне",
"import": "Внеси",
"public": "Публичен",
"private": "Личен",
"friends_only": "Само за приятели",
"privacy": "Поверителност",
"profile_visibility": "Видимост на профила",
"profile_visibility_description": "Изберете кой може да вижда вашия профил и библиотека",
"required_field": "Това поле е задължително",
"source_already_exists": "Този източник вече е добавен",
"must_be_valid_url": "Източникът трябва да е валиден URL адрес.",
"blocked_users": "Блокирани потребители",
"user_unblocked": "Потребителят е бил деблокиран",
"enable_achievement_notifications": "Когато е отключено постижение",
"launch_minimized": "Стартиране на Hydra минимизирано",
"disable_nsfw_alert": "Деактивиране на предупреждението NSFW"
},
"notifications": {
"download_complete": "Изтеглянето е завършено",
"game_ready_to_install": "{{title}} е готово за инсталиране",
"repack_list_updated": "Repack лист е обновен",
"repack_count_one": "{{count}} repack е добавен",
"repack_count_other": "{{count}} repacks добавени",
"new_update_available": "Версия {{version}} е налична",
"restart_to_install_update": "Рестартирайте Hydra, за да инсталирате актуализацията",
"notification_achievement_unlocked_title": "Отключено постижение за {{game}}",
"notification_achievement_unlocked_body": "{{achievement}} и други {{count}} са отклщчени"
},
"system_tray": {
"open": "Отвори Hydra",
"quit": "Изход"
},
"game_card": {
"no_downloads": "Няма налични изтегляния"
},
"binary_not_found_modal": {
"title": "Не инсталирани програми",
"description": "Wine или Lutris изпълними файлове не бяха открити на вашата система",
"instructions": "Проверете правилния начин за инсталиране на някоя от тях на вашата дистрибуция на Linux, за да може играта да работи нормално"
},
"modal": {
"close": "Бутон за затваряне"
},
"forms": {
"toggle_password_visibility": "Превключване на видимостта на паролата"
},
"user_profile": {
"amount_hours": "{{amount}} часове",
"amount_minutes": "{{amount}} минути",
"last_time_played": "Последно играно {{period}}",
"activity": "Скорошна активност",
"library": "Библиотека",
"total_play_time": "Общо време за игра: {{amount}}",
"no_recent_activity_title": "Хмм… няма нищо тук",
"no_recent_activity_description": "Не сте играли игри напоследък. Време е да промените това.!",
"display_name": "Показване на името",
"saving": "Запазване",
"save": "Запис",
"edit_profile": "Редактиране на профила",
"saved_successfully": "Запазено успешно",
"try_again": "Моля, опитайте пак",
"sign_out_modal_title": "Сигурни ли сте?",
"cancel": "Отказ",
"successfully_signed_out": "Успешно се отписахте",
"sign_out": "Отписване",
"playing_for": "В игра от {{amount}}",
"sign_out_modal_text": "Вашата библиотека е свързана с текущата ви сметка. Когато се отпишете, библиотеката ви вече няма да е видима и напредъкът няма да бъде запазен. Продължете с отписването?",
"add_friends": "Добави приятели",
"add": "Добави",
"friend_code": "Приятелски код",
"see_profile": "Виж профила",
"sending": "Изпращане",
"friend_request_sent": "Изпратена покана за приятелство",
"friends": "Приятели",
"friends_list": "Списък с приятели",
"user_not_found": "Не е намерен потребител",
"block_user": "Блокирай потребител",
"add_friend": "Добави приятел",
"request_sent": "Изпратена покана",
"request_received": "Получена покана",
"accept_request": "Приеми поканата",
"ignore_request": "Игнирирай поканата",
"cancel_request": "Откажи поканата",
"undo_friendship": "Отмяна на приятелството",
"request_accepted": "Поканата е приета",
"user_blocked_successfully": "Потребителят е блокиран успешно",
"user_block_modal_text": "Това ще блокира {{displayName}}",
"blocked_users": "Блокирани потребители",
"unblock": "Отблокирай",
"no_friends_added": "Не сте добавили приятели",
"pending": "Чакащо",
"no_pending_invites": "Нямате чакащи покани",
"no_blocked_users": "Нямате блокирани потребители",
"friend_code_copied": "Приятелския код е копиран",
"undo_friendship_modal_text": "Това ще отмени приятелството ви с {{displayName}}",
"privacy_hint": "За да настроите кой може да вижда това, отидете в <0>Настройки</0>",
"locked_profile": "Този профил е личен",
"image_process_failure": "Грешка при обработката на изображението",
"required_field": "Това поле е задължително",
"displayname_min_length": "Името трябва да е дълго поне 3 символа",
"displayname_max_length": "Името трябва да е с дължина не повече от 50 символа.",
"report_profile": "Докладвай този профил",
"report_reason": "Защо докладвате този профил?",
"report_description": "Допълнителна информация",
"report_description_placeholder": "Допълнителна информация",
"report": "Докладвай",
"report_reason_hate": "Омразна реч",
"report_reason_sexual_content": "Сексуално съдържание",
"report_reason_violence": "Насилия",
"report_reason_spam": "Спам",
"report_reason_other": "Друго",
"profile_reported": "Профилът е докладван",
"your_friend_code": "Вашия приятелски код:",
"upload_banner": "Качи банер",
"uploading_banner": "Качване на банер…",
"background_image_updated": "Обновено фоново изображение"
},
"achievement": {
"achievement_unlocked": "Постижението е отключено",
"user_achievements": "Постиженията на {{displayName}} ",
"your_achievements": "Вашите Постижения",
"unlocked_at": "Отключено на:",
"subscription_needed": "Необходим е абонамент за Hydra Cloud, за да видите това съдържание",
"new_achievements_unlocked": "Отключени {{achievementCount}} нови постижения от {{gameCount}} игра",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} постижения",
"achievements_unlocked_for_game": "Отключени {{achievementCount}} нови постижения за {{gameTitle}}"
},
"tour": {
"subscription_tour_title": "Hydra Cloud Абонамент",
"subscribe_now": "Абонирай се сега",
"cloud_saving": "Запазване в облака",
"cloud_achievements": "Запазете постиженията си в облака",
"animated_profile_picture": "Анимирана профилна снимка",
"premium_support": "Премиум поддръжка",
"show_and_compare_achievements": "Показвайте и сравнявайте постиженията си с тези на други потребители",
"animated_profile_banner": "Анимиран профилен банер"
}
}

View File

@@ -26,7 +26,8 @@
"game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in",
"friends": "Friends",
"need_help": "Need help?"
"aria_view_profile": "View profile",
"resize_sidebar": "Resize sidebar"
},
"header": {
"search": "Search games",
@@ -36,7 +37,9 @@
"search_results": "Search results",
"settings": "Settings",
"version_available_install": "Version {{version}} available. Click here to restart and install.",
"version_available_download": "Version {{version}} available. Click here to download."
"version_available_download": "Version {{version}} available. Click here to download.",
"back": "Back",
"clear_search": "Clear search"
},
"bottom_panel": {
"no_downloads_in_progress": "No downloads in progress",
@@ -133,6 +136,7 @@
"warning": "Warning:",
"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",
"achievement": "Achievement",
"achievements_count": "Achievements {{unlockedCount}}/{{achievementsCount}}",
"cloud_save": "Cloud save",
"cloud_save_description": "Save your progress in the cloud and continue playing on any device",
@@ -255,8 +259,7 @@
"blocked_users": "Blocked users",
"user_unblocked": "User has been unblocked",
"enable_achievement_notifications": "When an achievement is unlocked",
"launch_minimized": "Launch Hydra minimized",
"disable_nsfw_alert": "Disable NSFW alert"
"launch_minimized": "Launch Hydra minimized"
},
"notifications": {
"download_complete": "Download complete",
@@ -360,13 +363,13 @@
},
"achievement": {
"achievement_unlocked": "Achievement unlocked",
"achievement_locked": "Achievement locked",
"user_achievements": "{{displayName}}'s Achievements",
"your_achievements": "Your Achievements",
"unlocked_at": "Unlocked at:",
"subscription_needed": "A Hydra Cloud subscription is required to see this content",
"new_achievements_unlocked": "Unlocked {{achievementCount}} new achievements from {{gameCount}} games",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} achievements",
"achievements_unlocked_for_game": "Unlocked {{achievementCount}} new achievements for {{gameTitle}}"
"aria_achievement_summary": "{{userDisplayName}} achievements for {{gameTitle}}, {{userAchievementCount}} unlocked of {{userTotalAchievementCount}}, {{percentage}} completed"
},
"tour": {
"subscription_tour_title": "Hydra Cloud Subscription",

View File

@@ -25,8 +25,7 @@
"queued": "{{title}} (En cola)",
"game_has_no_executable": "El juego no tiene un ejecutable seleccionado",
"sign_in": "Iniciar sesión",
"friends": "Amigos",
"need_help": "¿Necesitas ayuda?"
"friends": "Amigos"
},
"header": {
"search": "Buscar juegos",

View File

@@ -24,7 +24,6 @@ import kk from "./kk/translation.json";
import cs from "./cs/translation.json";
import nb from "./nb/translation.json";
import et from "./et/translation.json";
import bg from "./bg/translation.json";
export default {
"pt-BR": ptBR,
@@ -49,7 +48,6 @@ export default {
fa,
ro,
ca,
bg,
kk,
cs,
nb,

View File

@@ -26,7 +26,8 @@
"game_has_no_executable": "Jogo não possui executável selecionado",
"sign_in": "Login",
"friends": "Amigos",
"need_help": "Precisa de ajuda?"
"aria_view_profile": "Ver perfil",
"resize_sidebar": "Redimensionar barra lateral"
},
"header": {
"search": "Buscar jogos",
@@ -36,7 +37,9 @@
"settings": "Ajustes",
"home": "Início",
"version_available_install": "Versão {{version}} disponível. Clique aqui para reiniciar e instalar.",
"version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download."
"version_available_download": "Versão {{version}} disponível. Clique aqui para fazer o download.",
"back": "Voltar",
"clear_search": "Limpar busca"
},
"bottom_panel": {
"no_downloads_in_progress": "Sem downloads em andamento",
@@ -129,6 +132,7 @@
"warning": "Aviso:",
"hydra_needs_to_remain_open": "para este download, o Hydra precisa ficar aberto até a conclusão. Caso o Hydra encerre antes da conclusão, perderá seu progresso.",
"achievements": "Conquistas",
"achievement": "Conquista",
"achievements_count": "Conquistas ({{unlockedCount}}/{{achievementsCount}})",
"cloud_save": "Salvamento em nuvem",
"cloud_save_description": "Mantenha seu progresso na nuvem e continue de onde parou em qualquer dispositivo",
@@ -251,8 +255,7 @@
"blocked_users": "Usuários bloqueados",
"user_unblocked": "Usuário desbloqueado",
"enable_achievement_notifications": "Quando uma conquista é desbloqueada",
"launch_minimized": "Iniciar o Hydra minimizado",
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado"
"launch_minimized": "Iniciar o Hydra minimizado"
},
"notifications": {
"download_complete": "Download concluído",
@@ -358,13 +361,13 @@
},
"achievement": {
"achievement_unlocked": "Conquista desbloqueada",
"achievement_locked": "Conquista bloqueada",
"your_achievements": "Suas Conquistas",
"user_achievements": "Conquistas de {{displayName}}",
"unlocked_at": "Desbloqueado em:",
"subscription_needed": "Você precisa de uma assinatura Hydra Cloud para visualizar este conteúdo",
"new_achievements_unlocked": "{{achievementCount}} novas conquistas de {{gameCount}} jogos",
"achievement_progress": "{{unlockedCount}}/{{totalCount}} conquistas",
"achievements_unlocked_for_game": "Desbloqueadas {{achievementCount}} novas conquistas em {{gameTitle}}"
"aria_achievement_summary": "Conquistas de {{userDisplayName}} em {{gameTitle}}, {{userAchievementCount}} desbloqueadas de {{userTotalAchievementCount}}, {{percentage}} concluídas"
},
"tour": {
"subscription_tour_title": "Assinatura Hydra Cloud",

View File

@@ -7,7 +7,7 @@
"featured": "Рекомендованное",
"surprise_me": "Удиви меня",
"no_results": "Ничего не найдено",
"hot": "Сейчас в топе",
"hot": "Сейчас жарко",
"start_typing": "Начинаю вводить текст для поиска...",
"weekly": "📅 Лучшие игры недели"
},
@@ -24,8 +24,7 @@
"queued": "{{title}} (В очереди)",
"game_has_no_executable": "Файл запуска игры не выбран",
"sign_in": "Войти",
"friends": "Друзья",
"need_help": "Нужна помощь?"
"friends": "Друзья"
},
"header": {
"search": "Поиск",

View File

@@ -5,12 +5,12 @@ export const LUDUSAVI_MANIFEST_URL = "https://cdn.losbroxas.org/manifest.yaml";
export const defaultDownloadsPath = app.getPath("downloads");
export const isStaging = import.meta.env.MAIN_VITE_API_URL.includes("staging");
export const databaseDirectory = path.join(app.getPath("appData"), "hydra");
export const databasePath = path.join(
databaseDirectory,
isStaging ? "hydra_test.db" : "hydra.db"
import.meta.env.MAIN_VITE_API_URL.includes("staging")
? "hydra_test.db"
: "hydra.db"
);
export const logsPath = path.join(app.getPath("appData"), "hydra", "logs");
@@ -19,10 +19,6 @@ export const seedsPath = app.isPackaged
? path.join(process.resourcesPath, "seeds")
: path.join(__dirname, "..", "..", "seeds");
export const achievementSoundPath = app.isPackaged
? path.join(process.resourcesPath, "achievement.wav")
: path.join(__dirname, "..", "..", "resources", "achievement.wav");
export const backupsPath = path.join(app.getPath("userData"), "Backups");
export const appVersion = app.getVersion() + (isStaging ? "-staging" : "");
export const appVersion = app.getVersion();

View File

@@ -38,9 +38,6 @@ export class UserPreferences {
@Column("boolean", { default: false })
startMinimized: boolean;
@Column("boolean", { default: false })
disableNsfwAlert: boolean;
@CreateDateColumn()
createdAt: Date;

View File

@@ -1,4 +1,5 @@
import jwt from "jsonwebtoken";
import * as Sentry from "@sentry/electron/main";
import { userAuthRepository } from "@main/repository";
import { registerEvent } from "../register-event";
@@ -9,6 +10,8 @@ const getSessionHash = async (_event: Electron.IpcMainInvokeEvent) => {
if (!auth) return null;
const payload = jwt.decode(auth.accessToken) as jwt.JwtPayload;
Sentry.setContext("sessionId", payload.sessionId);
return payload.sessionId;
};

View File

@@ -1,4 +1,5 @@
import { registerEvent } from "../register-event";
import * as Sentry from "@sentry/electron/main";
import {
DownloadManager,
HydraApi,
@@ -28,6 +29,9 @@ const signOut = async (_event: Electron.IpcMainInvokeEvent) => {
gamesPlaytime.clear();
});
/* Removes user from Sentry */
Sentry.setUser(null);
/* Cancels any ongoing downloads */
DownloadManager.cancelDownload();

View File

@@ -1,16 +1,10 @@
import { shell } from "electron";
import { registerEvent } from "../register-event";
import {
userAuthRepository,
userPreferencesRepository,
} from "@main/repository";
import { userAuthRepository } from "@main/repository";
import { HydraApi } from "@main/services";
const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => {
const [userAuth, userPreferences] = await Promise.all([
userAuthRepository.findOne({ where: { id: 1 } }),
userPreferencesRepository.findOne({ where: { id: 1 } }),
]);
const userAuth = await userAuthRepository.findOne({ where: { id: 1 } });
if (!userAuth) {
return;
@@ -22,7 +16,6 @@ const openCheckout = async (_event: Electron.IpcMainInvokeEvent) => {
const params = new URLSearchParams({
token: paymentToken,
lng: userPreferences?.language || "en",
});
shell.openExternal(

View File

@@ -1,5 +1,5 @@
import { registerEvent } from "../register-event";
import parseTorrent from "parse-torrent";
import type { StartGameDownloadPayload } from "@types";
import { DownloadManager, HydraApi, logger } from "@main/services";
@@ -9,7 +9,6 @@ import { createGame } from "@main/services/library-sync";
import { steamUrlBuilder } from "@shared";
import { dataSource } from "@main/data-source";
import { DownloadQueue, Game } from "@main/entity";
import { HydraAnalytics } from "@main/services/hydra-analytics";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
@@ -91,17 +90,6 @@ const startGameDownload = async (
logger.error("Failed to create game download", err);
});
if (uri.startsWith("magnet:")) {
try {
const { infoHash } = await parseTorrent(payload.uri);
if (infoHash) {
HydraAnalytics.postDownload(infoHash).catch(() => {});
}
} catch (err) {
logger.error("Failed to parse torrent", err);
}
}
await DownloadManager.cancelDownload(updatedGame!.id);
await DownloadManager.startDownload(updatedGame!);

View File

@@ -1,4 +1,5 @@
import { app, BrowserWindow, net, protocol } from "electron";
import { init } from "@sentry/electron/main";
import updater from "electron-updater";
import i18n from "i18next";
import path from "node:path";
@@ -25,6 +26,12 @@ autoUpdater.logger = logger;
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit();
if (import.meta.env.MAIN_VITE_SENTRY_DSN) {
init({
dsn: import.meta.env.MAIN_VITE_SENTRY_DSN,
});
}
app.commandLine.appendSwitch("--no-sandbox");
i18n.init({
@@ -98,6 +105,7 @@ app.whenReady().then(async () => {
WindowManager.createMainWindow();
}
WindowManager.createNotificationWindow();
WindowManager.createSystemTray(userPreferences?.language || "en");
});

View File

@@ -12,7 +12,6 @@ import { CreateUserSubscription } from "./migrations/20241015235142_create_user_
import { AddBackgroundImageUrl } from "./migrations/20241016100249_add_background_image_url";
import { AddWinePrefixToGame } from "./migrations/20241019081648_add_wine_prefix_to_game";
import { AddStartMinimizedColumn } from "./migrations/20241030171454_add_start_minimized_column";
import { AddDisableNsfwAlertColumn } from "./migrations/20241106053733_add_disable_nsfw_alert_column";
export type HydraMigration = Knex.Migration & { name: string };
class MigrationSource implements Knex.MigrationSource<HydraMigration> {
@@ -29,7 +28,6 @@ class MigrationSource implements Knex.MigrationSource<HydraMigration> {
AddBackgroundImageUrl,
AddWinePrefixToGame,
AddStartMinimizedColumn,
AddDisableNsfwAlertColumn,
]);
}
getMigrationName(migration: HydraMigration): string {

View File

@@ -1,17 +0,0 @@
import type { HydraMigration } from "@main/knex-client";
import type { Knex } from "knex";
export const AddDisableNsfwAlertColumn: HydraMigration = {
name: "AddDisableNsfwAlertColumn",
up: (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.boolean("disableNsfwAlert").notNullable().defaultTo(0);
});
},
down: async (knex: Knex) => {
return knex.schema.alterTable("user_preferences", (table) => {
return table.dropColumn("disableNsfwAlert");
});
},
};

View File

@@ -13,7 +13,7 @@ import type { AchievementFile, UnlockedAchievement } from "@types";
import { achievementsLogger } from "../logger";
import { Cracker } from "@shared";
import { IsNull, Not } from "typeorm";
import { publishCombinedNewAchievementNotification } from "../notifications";
import { WindowManager } from "../window-manager";
const fileStats: Map<string, number> = new Map();
const fltFiles: Map<string, Set<string>> = new Map();
@@ -249,12 +249,11 @@ export class AchievementWatcherManager {
0
);
if (totalNewAchievements > 0) {
publishCombinedNewAchievementNotification(
totalNewAchievements,
totalNewGamesWithAchievements
);
}
WindowManager.notificationWindow?.webContents.send(
"on-combined-achievements-unlocked",
totalNewGamesWithAchievements,
totalNewAchievements
);
this.hasFinishedMergingWithRemote = true;
};

View File

@@ -8,12 +8,11 @@ import { HydraApi } from "../hydra-api";
import { getUnlockedAchievements } from "@main/events/user/get-unlocked-achievements";
import { Game } from "@main/entity";
import { achievementsLogger } from "../logger";
import { publishNewAchievementNotification } from "../notifications";
const saveAchievementsOnLocal = async (
objectId: string,
shop: GameShop,
achievements: UnlockedAchievement[],
achievements: any[],
sendUpdateEvent: boolean
) => {
return gameAchievementRepository
@@ -83,8 +82,6 @@ export const mergeAchievements = async (
};
});
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
if (
newAchievements.length &&
publishNotification &&
@@ -102,7 +99,7 @@ export const mergeAchievements = async (
);
});
})
.filter((achievement) => Boolean(achievement))
.filter((achievement) => achievement)
.map((achievement) => {
return {
displayName: achievement!.displayName,
@@ -110,15 +107,16 @@ export const mergeAchievements = async (
};
});
publishNewAchievementNotification({
achievements: achievementsInfo,
unlockedAchievementCount: mergedLocalAchievements.length,
totalAchievementCount: achievementsData.length,
gameTitle: game.title,
gameIcon: game.iconUrl,
});
WindowManager.notificationWindow?.webContents.send(
"on-achievement-unlocked",
game.objectID,
game.shop,
achievementsInfo
);
}
const mergedLocalAchievements = unlockedAchievements.concat(newAchievements);
if (game.remoteId) {
await HydraApi.put("/profile/games/achievements", {
id: game.remoteId,

View File

@@ -1,34 +0,0 @@
import { userSubscriptionRepository } from "@main/repository";
import axios from "axios";
import { appVersion } from "@main/constants";
export class HydraAnalytics {
private static instance = axios.create({
baseURL: import.meta.env.MAIN_VITE_ANALYTICS_API_URL,
headers: { "User-Agent": `Hydra Launcher v${appVersion}` },
});
private static async hasActiveSubscription() {
const userSubscription = await userSubscriptionRepository.findOne({
where: { id: 1 },
});
return (
userSubscription?.expiresAt && userSubscription.expiresAt > new Date()
);
}
static async postDownload(hash: string) {
const hasSubscription = await this.hasActiveSubscription();
return this.instance
.post("/track", {
event: "download",
attributes: {
hash,
hasSubscription,
},
})
.then((response) => response.data);
}
}

View File

@@ -112,8 +112,6 @@ export class HydraApi {
expirationTimestamp: 0,
subscription: null,
};
this.post("/auth/logout", {}, { needsAuth: false }).catch(() => {});
}
static async setupApi() {

View File

@@ -24,7 +24,6 @@ export class Ludusavi {
workerData: {
binaryPath: this.binaryPath,
},
maxThreads: 1,
});
static async getConfig() {

View File

@@ -0,0 +1,67 @@
import { Notification, nativeImage } from "electron";
import { t } from "i18next";
import { parseICO } from "icojs";
import trayIcon from "@resources/tray-icon.png?asset";
import { Game } from "@main/entity";
import { gameRepository, userPreferencesRepository } from "@main/repository";
const getGameIconNativeImage = async (gameId: number) => {
try {
const game = await gameRepository.findOne({
where: {
id: gameId,
},
});
if (!game?.iconUrl) return undefined;
const images = await parseICO(
Buffer.from(game.iconUrl.split("base64,")[1], "base64")
);
const highResIcon = images.find((image) => image.width >= 128);
if (!highResIcon) return undefined;
return nativeImage.createFromBuffer(Buffer.from(highResIcon.buffer));
} catch (err) {
return undefined;
}
};
export const publishDownloadCompleteNotification = async (game: Game) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const icon = await getGameIconNativeImage(game.id);
if (userPreferences?.downloadNotificationsEnabled) {
new Notification({
title: t("download_complete", {
ns: "notifications",
}),
body: t("game_ready_to_install", {
ns: "notifications",
title: game.title,
}),
icon,
}).show();
}
};
export const publishNotificationUpdateReadyToInstall = async (
version: string
) => {
new Notification({
title: t("new_update_available", {
ns: "notifications",
version,
}),
body: t("restart_to_install_update", {
ns: "notifications",
}),
icon: trayIcon,
}).show();
};
export const publishNewFriendRequestNotification = async () => {};

View File

@@ -1,146 +0,0 @@
import { Notification, app } from "electron";
import { t } from "i18next";
import trayIcon from "@resources/tray-icon.png?asset";
import { Game } from "@main/entity";
import { userPreferencesRepository } from "@main/repository";
import fs from "node:fs";
import axios from "axios";
import path from "node:path";
import sound from "sound-play";
import { achievementSoundPath } from "@main/constants";
import icon from "@resources/icon.png?asset";
import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger";
async function downloadImage(url: string | null) {
if (!url) return undefined;
if (!url.startsWith("http")) return undefined;
const fileName = url.split("/").pop()!;
const outputPath = path.join(app.getPath("temp"), fileName);
const writer = fs.createWriteStream(outputPath);
const response = await axios.get(url, {
responseType: "stream",
});
response.data.pipe(writer);
return new Promise<string | undefined>((resolve) => {
writer.on("finish", () => {
resolve(outputPath);
});
writer.on("error", () => {
logger.error("Failed to download image", { url });
resolve(undefined);
});
});
}
export const publishDownloadCompleteNotification = async (game: Game) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.downloadNotificationsEnabled) {
new Notification({
title: t("download_complete", {
ns: "notifications",
}),
body: t("game_ready_to_install", {
ns: "notifications",
title: game.title,
}),
icon: await downloadImage(game.iconUrl),
}).show();
}
};
export const publishNotificationUpdateReadyToInstall = async (
version: string
) => {
new Notification({
title: t("new_update_available", {
ns: "notifications",
version,
}),
body: t("restart_to_install_update", {
ns: "notifications",
}),
icon: trayIcon,
}).show();
};
export const publishNewFriendRequestNotification = async () => {};
export const publishCombinedNewAchievementNotification = async (
achievementCount,
gameCount
) => {
const options: NotificationOptions = {
title: t("achievement_unlocked", { ns: "achievement" }),
body: t("new_achievements_unlocked", {
ns: "achievement",
gameCount,
achievementCount,
}),
icon,
silent: true,
};
new Notification({
...options,
toastXml: toXmlString(options),
}).show();
if (process.platform !== "linux") {
sound.play(achievementSoundPath);
}
};
export const publishNewAchievementNotification = async (info: {
achievements: { displayName: string; iconUrl: string }[];
unlockedAchievementCount: number;
totalAchievementCount: number;
gameTitle: string;
gameIcon: string | null;
}) => {
const partialOptions =
info.achievements.length > 1
? {
title: t("achievements_unlocked_for_game", {
ns: "achievement",
gameTitle: info.gameTitle,
achievementCount: info.achievements.length,
}),
body: info.achievements.map((a) => a.displayName).join(", "),
icon: (await downloadImage(info.gameIcon)) ?? icon,
}
: {
title: t("achievement_unlocked", { ns: "achievement" }),
body: info.achievements[0].displayName,
icon: (await downloadImage(info.achievements[0].iconUrl)) ?? icon,
};
const options: NotificationOptions = {
...partialOptions,
silent: true,
progress: {
value: info.unlockedAchievementCount / info.totalAchievementCount,
valueOverride: t("achievement_progress", {
ns: "achievement",
unlockedCount: info.unlockedAchievementCount,
totalCount: info.totalAchievementCount,
}),
},
};
new Notification({
...options,
toastXml: toXmlString(options),
}).show();
if (process.platform !== "linux") {
sound.play(achievementSoundPath);
}
};

View File

@@ -1,79 +0,0 @@
export interface NotificationOptions {
title: string;
body?: string;
icon: string;
duration?: "short" | "long";
silent?: boolean;
progress?: {
status?: string;
value: number;
valueOverride: string;
};
}
function escape(string: string) {
return string.replace(/[<>&'"]/g, (match) => {
switch (match) {
case "<":
return "&lt;";
case ">":
return "&gt;";
case "&":
return "&amp;";
case "'":
return "&apos;";
case '"':
return "&quot;";
default:
return "";
}
});
}
function addAttributeOrTrim(name: string, value: string) {
return value ? `${name}="${value}" ` : "";
}
export function toXmlString(options: NotificationOptions) {
let template =
"<toast " +
`displayTimestamp="${new Date().toISOString()}" ` +
`scenario="default" ` +
`duration="${options.duration ?? "short"}" ` +
`activationType="protocol" ` +
">";
//Visual
template += `<visual><binding template="ToastGeneric">`;
if (options.icon)
template += `<image placement="appLogoOverride" src="${options.icon}" hint-crop="none"/>`;
template +=
`<text><![CDATA[${options.title}]]></text>` +
`<text><![CDATA[${options.body}]]></text>`;
//Progress bar
if (options.progress) {
template +=
"<progress " +
`value="${options.progress.value}" ` +
`status="" ` +
addAttributeOrTrim(
"valueStringOverride",
escape(options.progress.valueOverride)
) +
"/>";
}
template += "</binding></visual>";
//Actions
template += "<actions>";
template += "</actions>";
//Audio
template += "<audio " + `silent="true" ` + `loop="false" ` + "/>";
//EOF
template += "</toast>";
return template;
}

View File

@@ -4,6 +4,7 @@ import {
userAuthRepository,
userSubscriptionRepository,
} from "@main/repository";
import * as Sentry from "@sentry/electron/main";
import { UserNotLoggedInError } from "@shared";
import { logger } from "../logger";
@@ -38,6 +39,8 @@ export const getUserData = () => {
await userSubscriptionRepository.delete({ id: 1 });
}
Sentry.setUser({ id: me.id, username: me.username });
return me;
})
.catch(async (err) => {
@@ -56,7 +59,6 @@ export const getUserData = () => {
id: loggedUser.userId,
username: "",
bio: "",
email: null,
profileVisibility: "PUBLIC" as ProfileVisibility,
subscription: loggedUser.subscription
? {

View File

@@ -20,6 +20,7 @@ import UserAgent from "user-agents";
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
public static notificationWindow: Electron.BrowserWindow | null = null;
private static loadMainWindowURL(hash = "") {
// HMR for renderer base on electron-vite cli.
@@ -38,6 +39,21 @@ export class WindowManager {
}
}
private static loadNotificationWindowURL() {
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
this.notificationWindow?.loadURL(
`${process.env["ELECTRON_RENDERER_URL"]}#/achievement-notification`
);
} else {
this.notificationWindow?.loadFile(
path.join(__dirname, "../renderer/index.html"),
{
hash: "achievement-notification",
}
);
}
}
public static createMainWindow() {
if (this.mainWindow) return;
@@ -47,7 +63,7 @@ export class WindowManager {
minWidth: 1024,
minHeight: 540,
backgroundColor: "#1c1c1c",
titleBarStyle: process.platform === "linux" ? "default" : "hidden",
titleBarStyle: process.platform === "win32" ? "hidden" : "default",
...(process.platform === "linux" ? { icon } : {}),
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
@@ -85,10 +101,6 @@ export class WindowManager {
return callback(details);
}
if (details.url.includes("intercom.io")) {
return callback(details);
}
const headers = {
"access-control-allow-origin": ["*"],
"access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"],
@@ -139,6 +151,32 @@ export class WindowManager {
});
}
public static createNotificationWindow() {
this.notificationWindow = new BrowserWindow({
transparent: true,
maximizable: false,
autoHideMenuBar: true,
minimizable: false,
focusable: false,
skipTaskbar: true,
frame: false,
width: 350,
height: 104,
x: 0,
y: 0,
webPreferences: {
preload: path.join(__dirname, "../preload/index.mjs"),
sandbox: false,
},
});
this.notificationWindow.setIgnoreMouseEvents(true);
// this.notificationWindow.setVisibleOnAllWorkspaces(true, {
// visibleOnFullScreen: true,
// });
this.notificationWindow.setAlwaysOnTop(true, "screen-saver", 1);
this.loadNotificationWindowURL();
}
public static openAuthWindow() {
if (this.mainWindow) {
const authWindow = new BrowserWindow({

View File

@@ -3,8 +3,8 @@
interface ImportMetaEnv {
readonly MAIN_VITE_STEAMGRIDDB_API_KEY: string;
readonly MAIN_VITE_API_URL: string;
readonly MAIN_VITE_ANALYTICS_API_URL: string;
readonly MAIN_VITE_AUTH_URL: string;
readonly MAIN_VITE_SENTRY_DSN: string;
readonly MAIN_VITE_CHECKOUT_URL: string;
}

View File

@@ -16,25 +16,15 @@ export const backupGame = ({
preview?: boolean;
winePrefix?: string;
}) => {
return new Promise((resolve, reject) => {
const args = ["backup", title, "--api", "--force"];
const args = ["backup", title, "--api", "--force"];
if (preview) args.push("--preview");
if (backupPath) args.push("--path", backupPath);
if (winePrefix) args.push("--wine-prefix", winePrefix);
if (preview) args.push("--preview");
if (backupPath) args.push("--path", backupPath);
if (winePrefix) args.push("--wine-prefix", winePrefix);
cp.execFile(
binaryPath,
args,
(err: cp.ExecFileException | null, stdout: string) => {
if (err) {
return reject(err);
}
const result = cp.execFileSync(binaryPath, args);
return resolve(JSON.parse(stdout) as LudusaviBackup);
}
);
});
return JSON.parse(result.toString("utf-8")) as LudusaviBackup;
};
export const restoreBackup = (backupPath: string) => {

View File

@@ -51,6 +51,35 @@ contextBridge.exposeInMainWorld("electron", {
getGameStats: (objectId: string, shop: GameShop) =>
ipcRenderer.invoke("getGameStats", objectId, shop),
getTrendingGames: () => ipcRenderer.invoke("getTrendingGames"),
onAchievementUnlocked: (
cb: (
objectId: string,
shop: GameShop,
achievements?: { displayName: string; iconUrl: string }[]
) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
objectId: string,
shop: GameShop,
achievements?: { displayName: string; iconUrl: string }[]
) => cb(objectId, shop, achievements);
ipcRenderer.on("on-achievement-unlocked", listener);
return () =>
ipcRenderer.removeListener("on-achievement-unlocked", listener);
},
onCombinedAchievementsUnlocked: (
cb: (gameCount: number, achievementsCount: number) => void
) => {
const listener = (
_event: Electron.IpcRendererEvent,
gameCount: number,
achievementCount: number
) => cb(gameCount, achievementCount);
ipcRenderer.on("on-combined-achievements-unlocked", listener);
return () =>
ipcRenderer.removeListener("on-combined-achievements-unlocked", listener);
},
onUpdateAchievements: (
objectId: string,
shop: GameShop,

View File

@@ -6,7 +6,7 @@
<title>Hydra</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src *; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: *; media-src 'self' local: data: *; connect-src *; font-src *;"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: *; media-src 'self' local: data: *;"
/>
</head>
<body>

View File

@@ -126,9 +126,3 @@ export const titleBar = style({
zIndex: "4",
borderBottom: `1px solid ${vars.color.border}`,
} as ComplexStyleRule);
export const cloudText = style({
background: "linear-gradient(270deg, #16B195 50%, #3E62C0 100%)",
backgroundClip: "text",
color: "transparent",
});

View File

@@ -1,9 +1,7 @@
import { useCallback, useContext, useEffect, useRef } from "react";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import Intercom from "@intercom/messenger-js-sdk";
import {
useAppDispatch,
useAppSelector,
@@ -31,15 +29,16 @@ import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { downloadSourcesWorker } from "./workers";
import { repacksContext } from "./context";
import { logger } from "./logger";
import { SubscriptionTourModal } from "./pages/shared-modals/subscription-tour-modal";
interface TourModals {
subscriptionModal?: boolean;
}
export interface AppProps {
children: React.ReactNode;
}
Intercom({
app_id: import.meta.env.RENDERER_VITE_INTERCOM_APP_ID,
});
export function App() {
const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary, library } = useLibrary();
@@ -60,13 +59,8 @@ export function App() {
hideFriendsModal,
} = useUserDetails();
const {
userDetails,
hasActiveSubscription,
fetchUserDetails,
updateUserDetails,
clearUserDetails,
} = useUserDetails();
const { userDetails, fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails();
const dispatch = useAppDispatch();
@@ -83,6 +77,9 @@ export function App() {
const { showSuccessToast } = useToast();
const [showSubscritionTourModal, setShowSubscritionTourModal] =
useState(false);
useEffect(() => {
Promise.all([window.electron.getUserPreferences(), updateLibrary()]).then(
([preferences]) => {
@@ -128,6 +125,16 @@ export function App() {
});
}, [fetchUserDetails, syncFriendRequests, updateUserDetails, dispatch]);
useEffect(() => {
const tourModalsString = window.localStorage.getItem("tourModals") || "{}";
const tourModals = JSON.parse(tourModalsString) as TourModals;
if (!tourModals.subscriptionModal) {
setShowSubscritionTourModal(true);
}
}, []);
const onSignIn = useCallback(() => {
fetchUserDetails().then((response) => {
if (response) {
@@ -215,9 +222,7 @@ export function App() {
useEffect(() => {
new MutationObserver(() => {
const modal = document.body.querySelector(
"[role=dialog]:not([data-intercom-frame='true'])"
);
const modal = document.body.querySelector("[role=dialog]");
dispatch(toggleDraggingDisabled(Boolean(modal)));
}).observe(document.body, {
@@ -275,6 +280,14 @@ export function App() {
});
}, [indexRepacks]);
const handleCloseSubscriptionTourModal = () => {
setShowSubscritionTourModal(false);
window.localStorage.setItem(
"tourModals",
JSON.stringify({ subscriptionModal: true } as TourModals)
);
};
const handleToastClose = useCallback(() => {
dispatch(closeToast());
}, [dispatch]);
@@ -283,12 +296,7 @@ export function App() {
<>
{window.electron.platform === "win32" && (
<div className={styles.titleBar}>
<h4>
Hydra
{hasActiveSubscription && (
<span className={styles.cloudText}> Cloud</span>
)}
</h4>
<h4>Hydra</h4>
</div>
)}
@@ -299,6 +307,11 @@ export function App() {
onClose={handleToastClose}
/>
<SubscriptionTourModal
visible={showSubscritionTourModal && false}
onClose={handleCloseSubscriptionTourModal}
/>
{userDetails && (
<UserFriendModal
visible={isFriendsModalVisible}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -0,0 +1,725 @@
{
"v": "5.12.1",
"fr": 30,
"ip": 0,
"op": 60,
"w": 400,
"h": 400,
"nm": "Cloud",
"ddd": 0,
"assets": [],
"layers": [
{
"ddd": 0,
"ind": 2,
"ty": 4,
"nm": "Layer 6",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": {
"a": 1,
"k": [
{
"i": { "x": 0.667, "y": 1 },
"o": { "x": 0.333, "y": 0 },
"t": 0,
"s": [322.789, 202.565, 0],
"to": [-1.5, -0.167, 0],
"ti": [0, 0, 0]
},
{
"i": { "x": 0.667, "y": 1 },
"o": { "x": 0.333, "y": 0 },
"t": 30,
"s": [313.789, 201.565, 0],
"to": [0, 0, 0],
"ti": [-1.5, -0.167, 0]
},
{ "t": 60, "s": [322.789, 202.565, 0] }
],
"ix": 2,
"l": 2
},
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, -38.564],
[38.564, 0],
[0, 38.564],
[-38.564, 0]
],
"o": [
[0, 38.564],
[-38.564, 0],
[0, -38.564],
[38.564, 0]
],
"v": [
[69.827, 0],
[0, 69.827],
[-69.827, 0],
[0, -69.827]
],
"c": true
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Group 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 270,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 3,
"ty": 4,
"nm": "Layer 5",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": {
"a": 1,
"k": [
{
"i": { "x": 0.667, "y": 1 },
"o": { "x": 0.333, "y": 0 },
"t": 0,
"s": [243.704, 202.565, 0],
"to": [-1.667, 0, 0],
"ti": [0, 0, 0]
},
{
"i": { "x": 0.667, "y": 1 },
"o": { "x": 0.333, "y": 0 },
"t": 30,
"s": [233.704, 202.565, 0],
"to": [0, 0, 0],
"ti": [-1.667, 0, 0]
},
{ "t": 60, "s": [243.704, 202.565, 0] }
],
"ix": 2,
"l": 2
},
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, -38.564],
[38.564, 0],
[0, 38.564],
[-38.564, 0]
],
"o": [
[0, 38.564],
[-38.564, 0],
[0, -38.564],
[38.564, 0]
],
"v": [
[69.827, 0],
[0, 69.827],
[-69.827, 0],
[0, -69.827]
],
"c": true
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Group 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 270,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 4,
"ty": 4,
"nm": "Layer 4",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": {
"a": 1,
"k": [
{
"i": { "x": 0.667, "y": 1 },
"o": { "x": 0.333, "y": 0 },
"t": 0,
"s": [260.681, 151.053, 0],
"to": [1.333, -1.333, 0],
"ti": [0, 0, 0]
},
{
"i": { "x": 0.667, "y": 1 },
"o": { "x": 0.333, "y": 0 },
"t": 30,
"s": [268.681, 143.053, 0],
"to": [0, 0, 0],
"ti": [1.333, -1.333, 0]
},
{ "t": 60, "s": [260.681, 151.053, 0] }
],
"ix": 2,
"l": 2
},
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, -38.564],
[38.564, 0],
[0, 38.564],
[-38.564, 0]
],
"o": [
[0, 38.564],
[-38.564, 0],
[0, -38.564],
[38.564, 0]
],
"v": [
[69.827, 0],
[0, 69.827],
[-69.827, 0],
[0, -69.827]
],
"c": true
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Group 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 270,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 5,
"ty": 4,
"nm": "Layer 3",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": {
"a": 1,
"k": [
{
"i": { "x": 0.667, "y": 1 },
"o": { "x": 0.333, "y": 0 },
"t": 0,
"s": [162.135, 206.563, 0],
"to": [-0.833, -0.167, 0],
"ti": [0, 0, 0]
},
{
"i": { "x": 0.667, "y": 1 },
"o": { "x": 0.333, "y": 0 },
"t": 30,
"s": [157.135, 205.563, 0],
"to": [0, 0, 0],
"ti": [-0.833, -0.167, 0]
},
{ "t": 60, "s": [162.135, 206.563, 0] }
],
"ix": 2,
"l": 2
},
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, -36.66],
[36.66, 0],
[0, 36.66],
[-36.66, 0]
],
"o": [
[0, 36.66],
[-36.66, 0],
[0, -36.66],
[36.66, 0]
],
"v": [
[66.378, 0],
[0, 66.378],
[-66.378, 0],
[0, -66.378]
],
"c": true
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Group 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 270,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 6,
"ty": 4,
"nm": "Layer 2",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": {
"a": 1,
"k": [
{
"i": { "x": 0.667, "y": 1 },
"o": { "x": 0.333, "y": 0 },
"t": 0,
"s": [180.178, 132.225, 0],
"to": [-0.5, -2.333, 0],
"ti": [0, 0, 0]
},
{
"i": { "x": 0.667, "y": 1 },
"o": { "x": 0.333, "y": 0 },
"t": 30,
"s": [177.178, 118.225, 0],
"to": [0, 0, 0],
"ti": [-0.5, -2.333, 0]
},
{ "t": 60, "s": [180.178, 132.225, 0] }
],
"ix": 2,
"l": 2
},
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, -50.068],
[50.068, 0],
[0, 50.068],
[-50.068, 0]
],
"o": [
[0, 50.068],
[-50.068, 0],
[0, -50.068],
[50.068, 0]
],
"v": [
[90.655, 0],
[0, 90.655],
[-90.655, 0],
[0, -90.655]
],
"c": true
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Group 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 270,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 7,
"ty": 4,
"nm": "Layer 1",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": {
"a": 1,
"k": [
{
"i": { "x": 0.667, "y": 1 },
"o": { "x": 0.333, "y": 0 },
"t": 0,
"s": [95.756, 208.288, 0],
"to": [-1.167, 0, 0],
"ti": [0, 0, 0]
},
{
"i": { "x": 0.667, "y": 1 },
"o": { "x": 0.333, "y": 0 },
"t": 30,
"s": [88.756, 208.288, 0],
"to": [0, 0, 0],
"ti": [-1.167, 0, 0]
},
{ "t": 60, "s": [95.756, 208.288, 0] }
],
"ix": 2,
"l": 2
},
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, -35.403],
[35.403, 0],
[0, 35.403],
[-35.403, 0]
],
"o": [
[0, 35.403],
[-35.403, 0],
[0, -35.403],
[35.403, 0]
],
"v": [
[64.103, 0],
[0, 64.103],
[-64.103, 0],
[0, -64.103]
],
"c": true
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.839215686275, 0.854901960784, 0.933333333333, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Group 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 270,
"st": 0,
"ct": 1,
"bm": 0
},
{
"ddd": 0,
"ind": 8,
"ty": 3,
"nm": "Null 1",
"parent": 6,
"sr": 1,
"ks": {
"o": { "a": 0, "k": 0, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [19.822, 67.775, 0], "ix": 2, "l": 2 },
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 }
},
"ao": 0,
"ip": 0,
"op": 270,
"st": 0,
"bm": 0
}
],
"markers": [],
"props": {}
}

View File

@@ -0,0 +1,843 @@
{
"v": "4.8.0",
"meta": { "g": "LottieFiles AE 3.5.6", "a": "", "k": "", "d": "", "tc": "" },
"fr": 60,
"ip": 0,
"op": 120,
"w": 714,
"h": 678,
"nm": "Pre-comp 1",
"ddd": 0,
"assets": [
{
"id": "comp_0",
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "centro",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": {
"a": 1,
"k": [
{
"i": { "x": 0.214, "y": 1 },
"o": { "x": 0.462, "y": 0 },
"t": 0,
"s": [450, 907, 0],
"to": [0, 0, 0],
"ti": [0, 0, 0]
},
{ "t": 30, "s": [450, 1513, 0] }
],
"ix": 2
},
"a": { "a": 0, "k": [-348, -169, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0]
],
"v": [
[-348, -420],
[-348, -30]
],
"c": false
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.854901960784, 0.858823529412, 0.882352941176, 1],
"ix": 3
},
"o": { "a": 0, "k": 100, "ix": 4 },
"w": { "a": 0, "k": 77, "ix": 5 },
"lc": 2,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "Stroke 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [-348, -164], "ix": 2 },
"a": { "a": 0, "k": [-348, -156], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Shape 1",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 120,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 2,
"ty": 4,
"nm": "esquerdo",
"parent": 1,
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.298], "y": [1] },
"o": { "x": [0.448], "y": [0] },
"t": 6,
"s": [43.5]
},
{ "t": 36, "s": [-1] }
],
"ix": 10
},
"p": { "a": 0, "k": [-348.39, -36.55, 0], "ix": 2 },
"a": { "a": 0, "k": [-2, 84, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0]
],
"v": [
[-178, -102],
[-2, 84]
],
"c": false
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.854901960784, 0.858823529412, 0.882352941176, 1],
"ix": 3
},
"o": { "a": 0, "k": 100, "ix": 4 },
"w": { "a": 0, "k": 77, "ix": 5 },
"lc": 2,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "Stroke 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Shape 1",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
},
{
"ty": "tm",
"s": { "a": 0, "k": 8, "ix": 1 },
"e": { "a": 0, "k": 100, "ix": 2 },
"o": { "a": 0, "k": 0, "ix": 3 },
"m": 1,
"ix": 2,
"nm": "Trim Paths 1",
"mn": "ADBE Vector Filter - Trim",
"hd": false
}
],
"ip": 0,
"op": 120,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 3,
"ty": 4,
"nm": "direito",
"parent": 1,
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.265], "y": [1] },
"o": { "x": [0.53], "y": [0] },
"t": 6,
"s": [-43.5]
},
{ "t": 36, "s": [1] }
],
"ix": 10
},
"p": { "a": 0, "k": [-348.39, -36.55, 0], "ix": 2 },
"a": { "a": 0, "k": [-2, 84, 0], "ix": 1 },
"s": { "a": 0, "k": [-100, 100, 100], "ix": 6 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0]
],
"v": [
[-178, -102],
[-2, 84]
],
"c": false
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "tm",
"s": { "a": 0, "k": 8, "ix": 1 },
"e": { "a": 0, "k": 100, "ix": 2 },
"o": { "a": 0, "k": 0, "ix": 3 },
"m": 1,
"ix": 2,
"nm": "Trim Paths 1",
"mn": "ADBE Vector Filter - Trim",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.854901960784, 0.858823529412, 0.882352941176, 1],
"ix": 3
},
"o": { "a": 0, "k": 100, "ix": 4 },
"w": { "a": 0, "k": 77, "ix": 5 },
"lc": 2,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "Stroke 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Shape 1",
"np": 4,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 120,
"st": 0,
"bm": 0
}
]
},
{
"id": "comp_1",
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 4,
"nm": "centro",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": {
"a": 1,
"k": [
{
"i": { "x": 0.569, "y": 1 },
"o": { "x": 0.809, "y": 0 },
"t": 0,
"s": [450, 391, 0],
"to": [0, 0, 0],
"ti": [0, 0, 0]
},
{ "t": 30, "s": [450, 997, 0] }
],
"ix": 2
},
"a": { "a": 0, "k": [-348, -169, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0]
],
"v": [
[-348, -420],
[-348, -30]
],
"c": false
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.854901960784, 0.858823529412, 0.882352941176, 1],
"ix": 3
},
"o": { "a": 0, "k": 100, "ix": 4 },
"w": { "a": 0, "k": 77, "ix": 5 },
"lc": 2,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "Stroke 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [-348, -164], "ix": 2 },
"a": { "a": 0, "k": [-348, -156], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Shape 1",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 120,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 2,
"ty": 4,
"nm": "esquerdo",
"parent": 1,
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.552], "y": [1] },
"o": { "x": [0.702], "y": [0] },
"t": 0,
"s": [-1]
},
{ "t": 30, "s": [43.5] }
],
"ix": 10
},
"p": { "a": 0, "k": [-348.39, -36.55, 0], "ix": 2 },
"a": { "a": 0, "k": [-2, 84, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0]
],
"v": [
[-178, -102],
[-2, 84]
],
"c": false
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.854901960784, 0.858823529412, 0.882352941176, 1],
"ix": 3
},
"o": { "a": 0, "k": 100, "ix": 4 },
"w": { "a": 0, "k": 77, "ix": 5 },
"lc": 2,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "Stroke 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Shape 1",
"np": 3,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
},
{
"ty": "tm",
"s": { "a": 0, "k": 8, "ix": 1 },
"e": { "a": 0, "k": 100, "ix": 2 },
"o": { "a": 0, "k": 0, "ix": 3 },
"m": 1,
"ix": 2,
"nm": "Trim Paths 1",
"mn": "ADBE Vector Filter - Trim",
"hd": false
}
],
"ip": 0,
"op": 120,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 3,
"ty": 4,
"nm": "direito",
"parent": 1,
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.47], "y": [1] },
"o": { "x": [0.735], "y": [0] },
"t": 0,
"s": [1]
},
{ "t": 30, "s": [-43.5] }
],
"ix": 10
},
"p": { "a": 0, "k": [-348.39, -36.55, 0], "ix": 2 },
"a": { "a": 0, "k": [-2, 84, 0], "ix": 1 },
"s": { "a": 0, "k": [-100, 100, 100], "ix": 6 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0]
],
"v": [
[-178, -102],
[-2, 84]
],
"c": false
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "tm",
"s": { "a": 0, "k": 8, "ix": 1 },
"e": { "a": 0, "k": 100, "ix": 2 },
"o": { "a": 0, "k": 0, "ix": 3 },
"m": 1,
"ix": 2,
"nm": "Trim Paths 1",
"mn": "ADBE Vector Filter - Trim",
"hd": false
},
{
"ty": "st",
"c": {
"a": 0,
"k": [0.854901960784, 0.858823529412, 0.882352941176, 1],
"ix": 3
},
"o": { "a": 0, "k": 100, "ix": 4 },
"w": { "a": 0, "k": 77, "ix": 5 },
"lc": 2,
"lj": 1,
"ml": 4,
"bm": 0,
"nm": "Stroke 1",
"mn": "ADBE Vector Graphic - Stroke",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [0, 0], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Shape 1",
"np": 4,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 120,
"st": 0,
"bm": 0
}
]
}
],
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 0,
"nm": "seta 2",
"refId": "comp_0",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [357, -247, 0], "ix": 2 },
"a": { "a": 0, "k": [450, 960, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
},
"ao": 0,
"w": 900,
"h": 1920,
"ip": 30,
"op": 120,
"st": 30,
"bm": 0
},
{
"ddd": 0,
"ind": 2,
"ty": 0,
"nm": "seta",
"refId": "comp_1",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [357, 258, 0], "ix": 2 },
"a": { "a": 0, "k": [450, 345, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
},
"ao": 0,
"w": 900,
"h": 690,
"ip": 0,
"op": 120,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 3,
"ty": 4,
"nm": "base Outlines",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [357, 548.713, 0], "ix": 2 },
"a": { "a": 0, "k": [357.81, 129.934, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
},
"ao": 0,
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 50.043],
[0, 0],
[-21.158, 0],
[0, -21.447],
[0, 0],
[-7.049, 0],
[0, 0],
[0, 7.149],
[0, 0],
[-21.158, 0],
[0, -21.447],
[0, 0],
[49.368, 0]
],
"o": [
[-49.369, 0],
[0, 0],
[0, -21.447],
[21.158, 0],
[0, 0],
[0, 7.145],
[0, 0],
[7.053, 0],
[0, 0],
[0, -21.447],
[21.158, 0],
[0, 0],
[0, 50.043],
[0, 0]
],
"v": [
[-268.169, 129.445],
[-357.559, 38.834],
[-357.559, -90.61],
[-319.249, -129.445],
[-280.939, -90.61],
[-280.939, 38.834],
[-268.169, 51.778],
[268.169, 51.778],
[280.939, 38.834],
[280.939, -90.61],
[319.249, -129.445],
[357.559, -90.61],
[357.559, 38.834],
[268.169, 129.445]
],
"c": true
},
"ix": 2
},
"nm": "Path 1",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ty": "fl",
"c": {
"a": 0,
"k": [0.865977448108, 0.86824388691, 0.890449075138, 1],
"ix": 4
},
"o": { "a": 0, "k": 100, "ix": 5 },
"r": 1,
"bm": 0,
"nm": "Fill 1",
"mn": "ADBE Vector Graphic - Fill",
"hd": false
},
{
"ty": "tr",
"p": { "a": 0, "k": [357.809, 129.695], "ix": 2 },
"a": { "a": 0, "k": [0, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100], "ix": 3 },
"r": { "a": 0, "k": 0, "ix": 6 },
"o": { "a": 0, "k": 100, "ix": 7 },
"sk": { "a": 0, "k": 0, "ix": 4 },
"sa": { "a": 0, "k": 0, "ix": 5 },
"nm": "Transform"
}
],
"nm": "Group 1",
"np": 2,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
],
"ip": 0,
"op": 120,
"st": 0,
"bm": 0
}
],
"markers": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,928 @@
{
"v": "4.8.0",
"meta": { "g": "LottieFiles AE 3.5.6", "a": "", "k": "", "d": "", "tc": "" },
"fr": 60,
"ip": 0,
"op": 120,
"w": 300,
"h": 300,
"nm": "Comp 1",
"ddd": 0,
"assets": [
{
"id": "comp_0",
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 5,
"nm": "3",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [0]
},
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 30,
"s": [8]
},
{ "t": 60, "s": [0] }
],
"ix": 10
},
"p": { "a": 0, "k": [930, 525, 0], "ix": 2 },
"a": { "a": 0, "k": [16.605, -23.904, 0], "ix": 1 },
"s": { "a": 0, "k": [170, 170, 100], "ix": 6 }
},
"ao": 0,
"hasMask": true,
"masksProperties": [
{
"inv": false,
"mode": "a",
"pt": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"v": [
[14.987, -34.426],
[9.105, -30.309],
[9.987, -22.073],
[17.487, -16.779],
[24.105, -23.544],
[22.193, -30.603]
],
"c": true
},
"ix": 1
},
"o": { "a": 0, "k": 100, "ix": 3 },
"x": { "a": 0, "k": 0, "ix": 4 },
"nm": "Mask 1"
}
],
"ef": [
{
"ty": 21,
"nm": "Fill",
"np": 9,
"mn": "ADBE Fill",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 10,
"nm": "Fill Mask",
"mn": "ADBE Fill-0001",
"ix": 1,
"v": { "a": 0, "k": 0, "ix": 1 }
},
{
"ty": 7,
"nm": "All Masks",
"mn": "ADBE Fill-0007",
"ix": 2,
"v": { "a": 0, "k": 0, "ix": 2 }
},
{
"ty": 2,
"nm": "Color",
"mn": "ADBE Fill-0002",
"ix": 3,
"v": {
"a": 0,
"k": [0.992156863213, 0.880375564098, 0.128396704793, 1],
"ix": 3
}
},
{
"ty": 7,
"nm": "Invert",
"mn": "ADBE Fill-0006",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Horizontal Feather",
"mn": "ADBE Fill-0003",
"ix": 5,
"v": { "a": 0, "k": 0, "ix": 5 }
},
{
"ty": 0,
"nm": "Vertical Feather",
"mn": "ADBE Fill-0004",
"ix": 6,
"v": { "a": 0, "k": 0, "ix": 6 }
},
{
"ty": 0,
"nm": "Opacity",
"mn": "ADBE Fill-0005",
"ix": 7,
"v": { "a": 0, "k": 1, "ix": 7 }
}
]
}
],
"t": {
"d": {
"k": [
{
"s": {
"s": 40,
"f": "SegoeUIEmoji",
"t": "✨",
"j": 0,
"tr": 0,
"lh": 48,
"ls": 0,
"fc": [1, 1, 1]
},
"t": 0
}
]
},
"p": {},
"m": { "g": 1, "a": { "a": 0, "k": [0, 0], "ix": 2 } },
"a": []
},
"ip": 0,
"op": 123,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 2,
"ty": 5,
"nm": "2",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [0]
},
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 30,
"s": [-8]
},
{ "t": 60, "s": [0] }
],
"ix": 10
},
"p": { "a": 0, "k": [960, 540, 0], "ix": 2 },
"a": { "a": 0, "k": [31.912, -13.397, 0], "ix": 1 },
"s": { "a": 0, "k": [170, 170, 100], "ix": 6 }
},
"ao": 0,
"hasMask": true,
"masksProperties": [
{
"inv": false,
"mode": "a",
"pt": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"v": [
[31.31, -34.72],
[24.546, -22.514],
[16.605, -16.485],
[17.046, -11.338],
[21.163, -7.073],
[27.487, -0.309],
[33.663, 10.133],
[47.634, -1.926],
[51.31, -12.073]
],
"c": true
},
"ix": 1
},
"o": { "a": 0, "k": 100, "ix": 3 },
"x": { "a": 0, "k": 0, "ix": 4 },
"nm": "Mask 1"
}
],
"ef": [
{
"ty": 21,
"nm": "Fill",
"np": 9,
"mn": "ADBE Fill",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 10,
"nm": "Fill Mask",
"mn": "ADBE Fill-0001",
"ix": 1,
"v": { "a": 0, "k": 0, "ix": 1 }
},
{
"ty": 7,
"nm": "All Masks",
"mn": "ADBE Fill-0007",
"ix": 2,
"v": { "a": 0, "k": 0, "ix": 2 }
},
{
"ty": 2,
"nm": "Color",
"mn": "ADBE Fill-0002",
"ix": 3,
"v": {
"a": 0,
"k": [0.992156863213, 0.880375564098, 0.128396704793, 1],
"ix": 3
}
},
{
"ty": 7,
"nm": "Invert",
"mn": "ADBE Fill-0006",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Horizontal Feather",
"mn": "ADBE Fill-0003",
"ix": 5,
"v": { "a": 0, "k": 0, "ix": 5 }
},
{
"ty": 0,
"nm": "Vertical Feather",
"mn": "ADBE Fill-0004",
"ix": 6,
"v": { "a": 0, "k": 0, "ix": 6 }
},
{
"ty": 0,
"nm": "Opacity",
"mn": "ADBE Fill-0005",
"ix": 7,
"v": { "a": 0, "k": 1, "ix": 7 }
}
]
}
],
"t": {
"d": {
"k": [
{
"s": {
"s": 40,
"f": "SegoeUIEmoji",
"t": "✨",
"j": 0,
"tr": 0,
"lh": 48,
"ls": 0,
"fc": [1, 1, 1]
},
"t": 0
}
]
},
"p": {},
"m": { "g": 1, "a": { "a": 0, "k": [0, 0], "ix": 2 } },
"a": []
},
"ip": 0,
"op": 123,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 3,
"ty": 5,
"nm": "✨",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [0]
},
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 30,
"s": [8]
},
{ "t": 60, "s": [0] }
],
"ix": 10
},
"p": { "a": 0, "k": [935, 560, 0], "ix": 2 },
"a": { "a": 0, "k": [14.973, -6.64, 0], "ix": 1 },
"s": { "a": 0, "k": [170, 170, 100], "ix": 6 }
},
"ao": 0,
"hasMask": true,
"masksProperties": [
{
"inv": false,
"mode": "a",
"pt": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"v": [
[13.957, -17.514],
[2.928, -9.132],
[2.487, 1.603],
[14.105, 7.339],
[21.605, -0.161],
[22.193, -5.161],
[17.34, -10.014]
],
"c": true
},
"ix": 1
},
"o": { "a": 0, "k": 100, "ix": 3 },
"x": { "a": 0, "k": 0, "ix": 4 },
"nm": "Mask 1"
}
],
"ef": [
{
"ty": 21,
"nm": "Fill",
"np": 9,
"mn": "ADBE Fill",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 10,
"nm": "Fill Mask",
"mn": "ADBE Fill-0001",
"ix": 1,
"v": { "a": 0, "k": 0, "ix": 1 }
},
{
"ty": 7,
"nm": "All Masks",
"mn": "ADBE Fill-0007",
"ix": 2,
"v": { "a": 0, "k": 0, "ix": 2 }
},
{
"ty": 2,
"nm": "Color",
"mn": "ADBE Fill-0002",
"ix": 3,
"v": {
"a": 0,
"k": [0.992156863213, 0.880375564098, 0.128396704793, 1],
"ix": 3
}
},
{
"ty": 7,
"nm": "Invert",
"mn": "ADBE Fill-0006",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Horizontal Feather",
"mn": "ADBE Fill-0003",
"ix": 5,
"v": { "a": 0, "k": 0, "ix": 5 }
},
{
"ty": 0,
"nm": "Vertical Feather",
"mn": "ADBE Fill-0004",
"ix": 6,
"v": { "a": 0, "k": 0, "ix": 6 }
},
{
"ty": 0,
"nm": "Opacity",
"mn": "ADBE Fill-0005",
"ix": 7,
"v": { "a": 0, "k": 1, "ix": 7 }
}
]
}
],
"t": {
"d": {
"k": [
{
"s": {
"s": 40,
"f": "SegoeUIEmoji",
"t": "✨",
"j": 0,
"tr": 0,
"lh": 48,
"ls": 0,
"fc": [1, 1, 1]
},
"t": 0
}
]
},
"p": {},
"m": { "g": 1, "a": { "a": 0, "k": [0, 0], "ix": 2 } },
"a": []
},
"ip": 0,
"op": 123,
"st": 0,
"bm": 0
}
]
}
],
"fonts": {
"list": [
{
"fName": "SegoeUIEmoji",
"fFamily": "Segoe UI Emoji",
"fStyle": "Regular",
"ascent": 74.0234375
}
]
},
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 0,
"nm": "botão",
"refId": "comp_0",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [155, 154, 0], "ix": 2 },
"a": { "a": 0, "k": [960, 540, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
},
"ao": 0,
"ef": [
{
"ty": 25,
"nm": "Drop Shadow",
"np": 8,
"mn": "ADBE Drop Shadow",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 2,
"nm": "Shadow Color",
"mn": "ADBE Drop Shadow-0001",
"ix": 1,
"v": {
"a": 0,
"k": [1, 0.829733371735, 0.414901971817, 1],
"ix": 1
}
},
{
"ty": 0,
"nm": "Opacity",
"mn": "ADBE Drop Shadow-0002",
"ix": 2,
"v": {
"a": 1,
"k": [
{
"i": { "x": [0], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [127.5]
},
{
"i": { "x": [0], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 15,
"s": [204]
},
{
"i": { "x": [0], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 30,
"s": [127.5]
},
{
"i": { "x": [0], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 45,
"s": [204]
},
{ "t": 70, "s": [76.5] }
],
"ix": 2
}
},
{
"ty": 0,
"nm": "Direction",
"mn": "ADBE Drop Shadow-0003",
"ix": 3,
"v": { "a": 0, "k": 135, "ix": 3 }
},
{
"ty": 0,
"nm": "Distance",
"mn": "ADBE Drop Shadow-0004",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Softness",
"mn": "ADBE Drop Shadow-0005",
"ix": 5,
"v": { "a": 0, "k": 40, "ix": 5 }
},
{
"ty": 7,
"nm": "Shadow Only",
"mn": "ADBE Drop Shadow-0006",
"ix": 6,
"v": { "a": 0, "k": 0, "ix": 6 }
}
]
}
],
"w": 1920,
"h": 1080,
"ip": 0,
"op": 120,
"st": 0,
"bm": 0
}
],
"markers": [],
"chars": [
{
"ch": "✨",
"size": 40,
"style": "Regular",
"w": 137.3,
"data": {
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0.423, 1.042],
[0, 0],
[0.7, 0],
[0.293, -0.618],
[0, 0],
[1.041, -0.488],
[0, 0],
[0, -0.684],
[-0.652, -0.293],
[0, 0],
[-0.423, -1.041],
[0, 0],
[-0.716, 0],
[-0.293, 0.619],
[0, 0],
[-1.042, 0.488],
[0, 0],
[0, 0.684],
[0.618, 0.293],
[0, 0]
],
"o": [
[0, 0],
[-0.326, -0.618],
[-0.7, 0],
[0, 0],
[-0.456, 1.009],
[0, 0],
[-0.652, 0.293],
[0, 0.684],
[0, 0],
[1.074, 0.456],
[0, 0],
[0.293, 0.619],
[0.716, 0],
[0, 0],
[0.455, -1.009],
[0, 0],
[0.618, -0.293],
[0, -0.684],
[0, 0],
[-1.074, -0.455]
],
"v": [
[47.119, -68.994],
[43.799, -76.562],
[42.261, -77.49],
[40.771, -76.562],
[37.402, -68.994],
[35.156, -66.748],
[30.908, -64.893],
[29.932, -63.428],
[30.908, -61.963],
[35.156, -60.107],
[37.402, -57.861],
[40.771, -50.244],
[42.285, -49.316],
[43.799, -50.244],
[47.119, -57.861],
[49.365, -60.107],
[53.662, -61.963],
[54.59, -63.428],
[53.662, -64.893],
[49.365, -66.748]
],
"c": true
},
"ix": 2
},
"nm": "✨",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ind": 1,
"ty": "sh",
"ix": 2,
"ks": {
"a": 0,
"k": {
"i": [
[1.334, 3.223],
[0, 0],
[1.204, 0.423],
[1.204, -0.423],
[0.618, -1.237],
[0, 0],
[3.125, -1.432],
[0, 0],
[0.423, -1.221],
[-0.423, -1.221],
[-1.27, -0.618],
[0, 0],
[-1.335, -3.223],
[0, 0],
[-1.205, -0.407],
[-1.205, 0.407],
[-0.619, 1.27],
[0, 0],
[-3.125, 1.433],
[0, 0],
[-0.423, 1.221],
[0.423, 1.221],
[1.27, 0.619],
[0, 0]
],
"o": [
[0, 0],
[-0.619, -1.237],
[-1.205, -0.423],
[-1.205, 0.423],
[0, 0],
[-1.367, 3.223],
[0, 0],
[-1.27, 0.619],
[-0.423, 1.221],
[0.423, 1.221],
[0, 0],
[3.157, 1.433],
[0, 0],
[0.618, 1.27],
[1.204, 0.407],
[1.204, -0.407],
[0, 0],
[1.367, -3.223],
[0, 0],
[1.27, -0.618],
[0.423, -1.221],
[-0.423, -1.221],
[0, 0],
[-3.158, -1.432]
],
"v": [
[95.605, -50.83],
[85.498, -74.658],
[82.764, -77.148],
[79.15, -77.148],
[76.416, -74.658],
[66.357, -50.83],
[59.619, -43.848],
[46.875, -38.086],
[44.336, -35.327],
[44.336, -31.665],
[46.875, -28.906],
[59.619, -23.145],
[66.357, -16.162],
[76.416, 7.666],
[79.15, 10.181],
[82.764, 10.181],
[85.498, 7.666],
[95.605, -16.162],
[102.344, -23.145],
[115.088, -28.906],
[117.627, -31.665],
[117.627, -35.327],
[115.088, -38.086],
[102.344, -43.848]
],
"c": true
},
"ix": 2
},
"nm": "✨",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ind": 2,
"ty": "sh",
"ix": 3,
"ks": {
"a": 0,
"k": {
"i": [
[-1.367, -0.651],
[0, 0],
[0, -0.928],
[0.813, -0.423],
[0, 0],
[0.586, -1.399],
[0, 0],
[0.895, 0],
[0.391, 0.846],
[0, 0],
[1.334, 0.652],
[0, 0],
[0, 0.928],
[-0.814, 0.423],
[0, 0],
[-0.586, 1.4],
[0, 0],
[-0.896, 0],
[-0.391, -0.846],
[0, 0]
],
"o": [
[0, 0],
[0.813, 0.423],
[0, 0.928],
[0, 0],
[-1.335, 0.652],
[0, 0],
[-0.391, 0.846],
[-0.896, 0],
[0, 0],
[-0.586, -1.399],
[0, 0],
[-0.814, -0.423],
[0, -0.928],
[0, 0],
[1.334, -0.651],
[0, 0],
[0.391, -0.846],
[0.895, 0],
[0, 0],
[0.553, 1.4]
],
"v": [
[44.385, -16.943],
[49.854, -14.404],
[51.074, -12.378],
[49.854, -10.352],
[44.385, -7.812],
[41.504, -4.736],
[37.158, 5.713],
[35.229, 6.982],
[33.301, 5.713],
[28.955, -4.736],
[26.074, -7.812],
[20.605, -10.352],
[19.385, -12.378],
[20.605, -14.404],
[26.074, -16.943],
[28.955, -20.02],
[33.301, -30.469],
[35.229, -31.738],
[37.158, -30.469],
[41.504, -20.02]
],
"c": true
},
"ix": 2
},
"nm": "✨",
"mn": "ADBE Vector Shape - Group",
"hd": false
}
],
"nm": "✨",
"np": 6,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
]
},
"fFamily": "Segoe UI Emoji"
}
]
}

View File

@@ -0,0 +1,23 @@
import { style } from "@vanilla-extract/css";
import { vars } from "../../theme.css";
export const profileAvatar = style({
borderRadius: "4px",
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: vars.color.background,
border: `solid 1px ${vars.color.border}`,
cursor: "pointer",
color: vars.color.muted,
position: "relative",
});
export const profileAvatarImage = style({
height: "100%",
width: "100%",
objectFit: "cover",
overflow: "hidden",
borderRadius: "4px",
});

View File

@@ -1,21 +0,0 @@
@use "../../scss/globals.scss";
.profile-avatar {
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
background-color: globals.$background-color;
border: solid 1px globals.$border-color;
cursor: pointer;
color: globals.$muted-color;
position: relative;
&__image {
height: 100%;
width: 100%;
object-fit: cover;
overflow: hidden;
border-radius: 4px;
}
}

View File

@@ -1,6 +1,6 @@
import { PersonIcon } from "@primer/octicons-react";
import "./avatar.scss";
import * as styles from "./avatar.css";
export interface AvatarProps
extends Omit<
@@ -16,9 +16,14 @@ export interface AvatarProps
export function Avatar({ size, alt, src, ...props }: AvatarProps) {
return (
<div className="profile-avatar" style={{ width: size, height: size }}>
<div className={styles.profileAvatar} style={{ width: size, height: size }}>
{src ? (
<img className="profile-avatar__image" alt={alt} src={src} {...props} />
<img
className={styles.profileAvatarImage}
alt={alt}
src={src}
{...props}
/>
) : (
<PersonIcon size={size * 0.7} />
)}

View File

@@ -0,0 +1,13 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT } from "../../theme.css";
export const badge = style({
color: "#c0c1c7",
fontSize: "10px",
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
border: "solid 1px #c0c1c7",
borderRadius: "4px",
display: "flex",
alignItems: "center",
});

View File

@@ -1,11 +0,0 @@
@use "../../scss/globals.scss";
.badge {
color: globals.$muted-color;
font-size: 10px;
padding: calc(globals.$spacing-unit / 2) globals.$spacing-unit;
border: solid 1px globals.$muted-color;
border-radius: 4px;
display: flex;
align-items: center;
}

View File

@@ -1,6 +1,5 @@
import React from "react";
import "./badge.scss";
import * as styles from "./badge.css";
export interface BadgeProps {
children: React.ReactNode;
@@ -8,7 +7,7 @@ export interface BadgeProps {
export function Badge({ children }: BadgeProps) {
return (
<div className="badge">
<div className={styles.badge}>
<span>{children}</span>
</div>
);

View File

@@ -0,0 +1,25 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
export const bottomPanel = style({
width: "100%",
borderTop: `solid 1px ${vars.color.border}`,
backgroundColor: vars.color.background,
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT * 2}px`,
display: "flex",
alignItems: "center",
transition: "all ease 0.2s",
justifyContent: "space-between",
position: "relative",
zIndex: vars.zIndex.bottomPanel,
});
export const downloadsButton = style({
color: vars.color.body,
borderBottom: "1px solid transparent",
":hover": {
borderBottom: `1px solid ${vars.color.body}`,
cursor: "pointer",
},
});

View File

@@ -1,24 +0,0 @@
@use "../../scss/globals.scss";
.bottom-panel {
width: 100%;
border-top: solid 1px globals.$border-color;
background-color: globals.$background-color;
padding: calc(globals.$spacing-unit / 2) calc(globals.$spacing-unit * 2);
display: flex;
align-items: center;
transition: all ease 0.2s;
justify-content: space-between;
position: relative;
z-index: globals.$bottom-panel-z-index;
&__downloads-button {
color: globals.$body-color;
border-bottom: solid 1px transparent;
&:hover {
border-bottom: solid 1px globals.$body-color;
cursor: pointer;
}
}
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useDownload, useUserDetails } from "@renderer/hooks";
import "./bottom-panel.scss";
import * as styles from "./bottom-panel.css";
import { useNavigate } from "react-router-dom";
import { VERSION_CODENAME } from "@renderer/constants";
@@ -72,10 +72,10 @@ export function BottomPanel() {
]);
return (
<footer className="bottom-panel">
<footer className={styles.bottomPanel}>
<button
type="button"
className="bottom-panel__downloads-button"
className={styles.downloadsButton}
onClick={() => navigate("/downloads")}
>
<small>{status}</small>

View File

@@ -0,0 +1,69 @@
import { style, styleVariants } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
const base = style({
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
backgroundColor: vars.color.muted,
borderRadius: "8px",
border: "solid 1px transparent",
transition: "all ease 0.2s",
cursor: "pointer",
minHeight: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: `${SPACING_UNIT}px`,
":active": {
opacity: vars.opacity.active,
},
":disabled": {
opacity: vars.opacity.disabled,
cursor: "not-allowed",
},
});
export const button = styleVariants({
primary: [
base,
{
":hover": {
backgroundColor: "#DADBE1",
},
":disabled": {
backgroundColor: vars.color.muted,
},
},
],
outline: [
base,
{
backgroundColor: "transparent",
border: `solid 1px ${vars.color.border}`,
color: vars.color.muted,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
":disabled": {
backgroundColor: "transparent",
},
},
],
dark: [
base,
{
backgroundColor: vars.color.darkBackground,
color: "#c0c1c7",
},
],
danger: [
base,
{
borderColor: "transparent",
backgroundColor: "#a31533",
color: "#c0c1c7",
":hover": {
backgroundColor: "#b3203f",
},
},
],
});

View File

@@ -1,63 +0,0 @@
@use "../../scss/globals.scss";
.button {
padding: globals.$spacing-unit globals.$spacing-unit * 2;
background-color: globals.$muted-color;
border-radius: 8px;
border: solid 1px transparent;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: globals.$spacing-unit;
&:active {
opacity: globals.$active-opacity;
}
&:disabled {
opacity: globals.$disabled-opacity;
cursor: not-allowed;
}
&--primary {
&:hover {
background-color: #dadbe1;
}
&:disabled {
background-color: globals.$muted-color;
}
}
&--outline {
background-color: transparent;
border: solid 1px globals.$border-color;
color: globals.$muted-color;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
&:disabled {
background-color: transparent;
}
}
&--dark {
background-color: globals.$dark-background-color;
color: globals.$muted-color;
}
&--danger {
border-color: transparent;
background-color: globals.$danger-color;
color: globals.$muted-color;
&:hover {
background-color: #b3203f;
}
}
}

View File

@@ -1,13 +1,12 @@
import cn from "classnames";
import "./button.scss";
import * as styles from "./button.css";
export interface ButtonProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> {
theme?: "primary" | "outline" | "dark" | "danger";
theme?: keyof typeof styles.button;
}
export function Button({
@@ -19,7 +18,7 @@ export function Button({
return (
<button
type="button"
className={cn("button", `button--${theme}`, className)}
className={cn(styles.button[theme], className)}
{...props}
>
{children}

View File

@@ -65,6 +65,24 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
navigate(-1);
};
useEffect(() => {
window.onkeydown = (event: KeyboardEvent) => {
const { key, ctrlKey } = event;
if (!isFocused && ctrlKey && key === "k") {
focusInput();
}
if (isFocused && key === "Escape" && inputRef.current) {
inputRef.current.blur();
handleBlur();
}
};
return () => {
window.onkeydown = null;
};
}, [isFocused]);
return (
<>
<header
@@ -81,6 +99,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
})}
onClick={handleBackButtonClick}
disabled={location.key === "default"}
title={t("back")}
>
<ArrowLeftIcon />
</button>
@@ -100,6 +119,8 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
type="button"
className={styles.actionButton}
onClick={focusInput}
tabIndex={-1}
title={t("search")}
>
<SearchIcon />
</button>
@@ -121,6 +142,7 @@ export function Header({ onSearch, onClear, search }: HeaderProps) {
type="button"
onClick={onClear}
className={styles.actionButton}
title={t("clear_search")}
>
<XIcon />
</button>

View File

@@ -0,0 +1,18 @@
import Lottie from "lottie-react";
import downloadingAnimation from "@renderer/assets/lottie/downloading.json";
export interface DownloadIconProps {
isDownloading: boolean;
}
export function DownloadIcon({ isDownloading }: DownloadIconProps) {
return (
<Lottie
animationData={downloadingAnimation}
loop={isDownloading}
autoplay={isDownloading}
style={{ width: 16 }}
/>
);
}

View File

@@ -1,9 +1,6 @@
import {
AppsIcon,
DownloadIcon,
GearIcon,
HomeIcon,
} from "@primer/octicons-react";
import { AppsIcon, GearIcon, HomeIcon } from "@primer/octicons-react";
import { DownloadIcon } from "./download-icon";
export const routes = [
{
@@ -19,7 +16,9 @@ export const routes = [
{
path: "/downloads",
nameKey: "downloads",
render: () => <DownloadIcon />,
render: (isDownloading: boolean) => (
<DownloadIcon isDownloading={isDownloading} />
),
},
{
path: "/settings",

View File

@@ -89,6 +89,7 @@ export function SidebarProfile() {
type="button"
className={styles.profileButton}
onClick={handleProfileClick}
aria-label={t("aria_view_profile")}
>
<div className={styles.profileButtonContent}>
<Avatar

View File

@@ -13,7 +13,6 @@ export const sidebar = recipe({
borderRight: `solid 1px ${vars.color.border}`,
position: "relative",
overflow: "hidden",
justifyContent: "space-between",
},
variants: {
resizing: {
@@ -125,28 +124,3 @@ export const section = style({
flexDirection: "column",
paddingBottom: `${SPACING_UNIT}px`,
});
export const helpButton = style({
color: vars.color.muted,
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
gap: "9px",
display: "flex",
alignItems: "center",
cursor: "pointer",
borderTop: `solid 1px ${vars.color.border}`,
transition: "background-color ease 0.1s",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.15)",
},
});
export const helpButtonIcon = style({
background: "linear-gradient(0deg, #16B195 50%, #3E62C0 100%)",
width: "24px",
height: "24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
borderRadius: "50%",
});

View File

@@ -5,12 +5,7 @@ import { useLocation, useNavigate } from "react-router-dom";
import type { LibraryGame } from "@types";
import { TextField } from "@renderer/components";
import {
useDownload,
useLibrary,
useToast,
useUserDetails,
} from "@renderer/hooks";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import { routes } from "./routes";
@@ -20,9 +15,6 @@ import { buildGameDetailsPath } from "@renderer/helpers";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import { CommentDiscussionIcon } from "@primer/octicons-react";
import { show, update } from "@intercom/messenger-js-sdk";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@@ -50,21 +42,6 @@ export function Sidebar() {
return sortBy(library, (game) => game.title);
}, [library]);
const { userDetails, hasActiveSubscription } = useUserDetails();
useEffect(() => {
if (userDetails) {
update({
name: userDetails.displayName,
Username: userDetails.username,
email: userDetails.email ?? undefined,
Email: userDetails.email,
"Subscription expiration date": userDetails?.subscription?.expiresAt,
"Payment status": userDetails?.subscription?.status,
});
}
}, [userDetails, hasActiveSubscription]);
const { lastPacket, progress } = useDownload();
const { showWarningToast } = useToast();
@@ -73,6 +50,10 @@ export function Sidebar() {
updateLibrary();
}, [lastPacket?.game.id, updateLibrary]);
const isDownloading = sortedLibrary.some(
(game) => game.status === "active" && game.progress !== 1
);
const sidebarRef = useRef<HTMLElement>(null);
const cursorPos = useRef({ x: 0 });
@@ -87,6 +68,26 @@ export function Sidebar() {
sidebarRef.current?.clientWidth || SIDEBAR_INITIAL_WIDTH;
};
const handleKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = (
event
) => {
const { key } = event;
if (key === "ArrowRight") {
setSidebarWidth((prevWidth) =>
prevWidth < SIDEBAR_INITIAL_WIDTH
? SIDEBAR_INITIAL_WIDTH
: SIDEBAR_MAX_WIDTH
);
} else if (key === "ArrowLeft") {
setSidebarWidth((prevWidth) =>
prevWidth > SIDEBAR_INITIAL_WIDTH
? SIDEBAR_INITIAL_WIDTH
: SIDEBAR_MIN_WIDTH
);
}
};
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setFilteredLibrary(
sortedLibrary.filter((game) =>
@@ -189,95 +190,84 @@ export function Sidebar() {
maxWidth: sidebarWidth,
}}
>
<div
style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}
>
<SidebarProfile />
<SidebarProfile />
<div className={styles.content}>
<section className={styles.section}>
<ul className={styles.menu}>
{routes.map(({ nameKey, path, render }) => (
<li
key={nameKey}
className={styles.menuItem({
active: location.pathname === path,
})}
<div className={styles.content}>
<section className={styles.section}>
<ul className={styles.menu}>
{routes.map(({ nameKey, path, render }) => (
<li
key={nameKey}
className={styles.menuItem({
active: location.pathname === path,
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={() => handleSidebarItemClick(path)}
>
<button
type="button"
className={styles.menuItemButton}
onClick={() => handleSidebarItemClick(path)}
>
{render()}
<span>{t(nameKey)}</span>
</button>
</li>
))}
</ul>
</section>
{render(isDownloading)}
<span>{t(nameKey)}</span>
</button>
</li>
))}
</ul>
</section>
<section className={styles.section}>
<small className={styles.sectionTitle}>{t("my_library")}</small>
<section className={styles.section}>
<small className={styles.sectionTitle}>{t("my_library")}</small>
<TextField
ref={filterRef}
placeholder={t("filter")}
onChange={handleFilter}
theme="dark"
/>
<TextField
ref={filterRef}
placeholder={t("filter")}
onChange={handleFilter}
theme="dark"
/>
<ul className={styles.menu}>
{filteredLibrary.map((game) => (
<li
key={game.id}
className={styles.menuItem({
active:
location.pathname ===
`/game/${game.shop}/${game.objectID}`,
muted: game.status === "removed",
})}
<ul className={styles.menu}>
{filteredLibrary.map((game) => (
<li
key={game.id}
className={styles.menuItem({
active:
location.pathname === `/game/${game.shop}/${game.objectID}`,
muted: game.status === "removed",
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={(event) => handleSidebarGameClick(event, game)}
aria-label={game.title}
>
<button
type="button"
className={styles.menuItemButton}
onClick={(event) => handleSidebarGameClick(event, game)}
>
{game.iconUrl ? (
<img
className={styles.gameIcon}
src={game.iconUrl}
alt={game.title}
loading="lazy"
/>
) : (
<SteamLogo className={styles.gameIcon} />
)}
{game.iconUrl ? (
<img
className={styles.gameIcon}
src={game.iconUrl}
alt={game.title}
loading="lazy"
/>
) : (
<SteamLogo className={styles.gameIcon} />
)}
<span className={styles.menuItemButtonLabel}>
{getGameTitle(game)}
</span>
</button>
</li>
))}
</ul>
</section>
</div>
<span className={styles.menuItemButtonLabel}>
{getGameTitle(game)}
</span>
</button>
</li>
))}
</ul>
</section>
</div>
{hasActiveSubscription && (
<button type="button" className={styles.helpButton} onClick={show}>
<div className={styles.helpButtonIcon}>
<CommentDiscussionIcon size={14} />
</div>
<span>{t("need_help")}</span>
</button>
)}
<button
type="button"
className={styles.handle}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
title={t("resize_sidebar")}
/>
</aside>
);

View File

@@ -147,8 +147,7 @@ export function GameDetailsContextProvider({
if (
result?.content_descriptors.ids.includes(
SteamContentDescriptor.AdultOnlySexualContent
) &&
!userPreferences?.disableNsfwAlert
)
) {
setHasNSFWContentBlocked(true);
}

View File

@@ -66,6 +66,16 @@ declare global {
searchGameRepacks: (query: string) => Promise<GameRepack[]>;
getGameStats: (objectId: string, shop: GameShop) => Promise<GameStats>;
getTrendingGames: () => Promise<TrendingGame[]>;
onAchievementUnlocked: (
cb: (
objectId: string,
shop: GameShop,
achievements?: { displayName: string; iconUrl: string }[]
) => void
) => () => Electron.IpcRenderer;
onCombinedAchievementsUnlocked: (
cb: (gameCount: number, achievementCount: number) => void
) => () => Electron.IpcRenderer;
onUpdateAchievements: (
objectId: string,
shop: GameShop,

View File

@@ -10,22 +10,12 @@ export interface HowLongToBeatEntry {
updatedAt: Date;
}
export interface CatalogueCache {
id?: number;
category: string;
games: { objectId: string; shop: GameShop }[];
createdAt: Date;
updatedAt: Date;
expiresAt: Date;
}
export const db = new Dexie("Hydra");
db.version(5).stores({
db.version(4).stores({
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, createdAt, updatedAt`,
downloadSources: `++id, url, name, etag, downloadCount, status, createdAt, updatedAt`,
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
catalogueCache: `++id, category, games, createdAt, updatedAt, expiresAt`,
});
export const downloadSourcesTable = db.table("downloadSources");
@@ -34,6 +24,4 @@ export const howLongToBeatEntriesTable = db.table<HowLongToBeatEntry>(
"howLongToBeatEntries"
);
export const catalogueCacheTable = db.table<CatalogueCache>("catalogueCache");
db.open();

View File

View File

@@ -6,6 +6,8 @@ import { Provider } from "react-redux";
import LanguageDetector from "i18next-browser-languagedetector";
import { HashRouter, Route, Routes } from "react-router-dom";
import * as Sentry from "@sentry/electron/renderer";
import "@fontsource/noto-sans/400.css";
import "@fontsource/noto-sans/500.css";
import "@fontsource/noto-sans/700.css";
@@ -17,7 +19,9 @@ import { App } from "./app";
import { store } from "./store";
import resources from "@locales";
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
import "./workers";
import { RepacksContextProvider } from "./context";
import { SuspenseWrapper } from "./components";
@@ -34,6 +38,8 @@ const Achievements = React.lazy(
() => import("./pages/achievements/achievements")
);
Sentry.init({});
i18n
.use(LanguageDetector)
.use(initReactI18next)
@@ -91,6 +97,10 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
element={<SuspenseWrapper Component={Achievements} />}
/>
</Route>
<Route
path="/achievement-notification"
Component={AchievementNotification}
/>
</Routes>
</HashRouter>
</RepacksContextProvider>

View File

@@ -16,6 +16,7 @@ import { average } from "color.js";
import Color from "color";
import { Link } from "@renderer/components";
import { ComparedAchievementList } from "./compared-achievement-list";
import { TFunction } from "i18next/typescript/t";
interface UserInfo {
id: string;
@@ -39,10 +40,35 @@ interface AchievementSummaryProps {
isComparison?: boolean;
}
const ariaLabelSummary = (
t: TFunction,
gameTitle: string,
user: UserInfo
): string => {
return t("aria_achievement_summary", {
userDisplayName: user.displayName,
gameTitle: gameTitle,
userAchievementCount: user.unlockedAchievementCount,
userTotalAchievementCount: user.totalAchievementCount,
percentage: formatDownloadProgress(
user.unlockedAchievementCount / user.totalAchievementCount
),
});
};
const ariaLabelAchievement = (
t: TFunction,
achievement: UserAchievement
): string => {
return `${
achievement.unlocked ? t("achievement_unlocked") : t("achievement_locked")
}, ${achievement.displayName}, ${achievement.description}`;
};
function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
const { t } = useTranslation("achievement");
const { userDetails, hasActiveSubscription } = useUserDetails();
const { handleClickOpenCheckout } = useContext(gameDetailsContext);
const { handleClickOpenCheckout, gameTitle } = useContext(gameDetailsContext);
const getProfileImage = (
user: Pick<UserInfo, "profileImageUrl" | "displayName">
@@ -124,6 +150,8 @@ function AchievementSummary({ user, isComparison }: AchievementSummaryProps) {
alignItems: "center",
padding: `${SPACING_UNIT}px`,
}}
role="region"
aria-label={ariaLabelSummary(t, gameTitle, user)}
>
{getProfileImage(user)}
<div
@@ -178,7 +206,12 @@ function AchievementList({ achievements }: AchievementListProps) {
return (
<ul className={styles.list}>
{achievements.map((achievement, index) => (
<li key={index} className={styles.listItem} style={{ display: "flex" }}>
<li
key={index}
className={styles.listItem}
style={{ display: "flex" }}
aria-label={ariaLabelAchievement(t, achievement)}
>
<img
className={styles.listItemImage({
unlocked: achievement.unlocked,
@@ -273,6 +306,7 @@ export function AchievementsContent({
src={steamUrlBuilder.libraryHero(objectId)}
style={{ display: "none" }}
alt={gameTitle}
className={styles.heroImage}
onLoad={handleHeroLoad}
/>

View File

@@ -23,6 +23,31 @@ export const hero = style({
flexDirection: "column",
position: "relative",
transition: "all ease 0.2s",
"@media": {
"(min-width: 1250px)": {
height: "350px",
minHeight: "350px",
},
},
});
export const heroImage = style({
width: "100%",
height: `${HERO_HEIGHT}px`,
minHeight: `${HERO_HEIGHT}px`,
objectFit: "cover",
objectPosition: "top",
transition: "all ease 0.2s",
position: "absolute",
zIndex: "0",
filter: "blur(5px)",
"@media": {
"(min-width: 1250px)": {
objectPosition: "center",
height: "350px",
minHeight: "350px",
},
},
});
export const heroContent = style({

View File

@@ -0,0 +1,44 @@
import { recipe } from "@vanilla-extract/recipes";
import { vars } from "../../../theme.css";
import { keyframes, style } from "@vanilla-extract/css";
const animationIn = keyframes({
"0%": { transform: `translateY(-240px)` },
"100%": { transform: "translateY(0)" },
});
const animationOut = keyframes({
"0%": { transform: `translateY(0)` },
"100%": { transform: "translateY(-240px)" },
});
export const container = recipe({
base: {
marginTop: "24px",
marginLeft: "24px",
animationDuration: "1.0s",
height: "60px",
display: "flex",
},
variants: {
closing: {
true: {
animationName: animationOut,
transform: "translateY(-240px)",
},
false: {
animationName: animationIn,
transform: "translateY(0)",
},
},
},
});
export const content = style({
display: "flex",
flexDirection: "row",
gap: "8px",
alignItems: "center",
background: vars.color.background,
paddingRight: "8px",
});

View File

@@ -0,0 +1,141 @@
import { useCallback, useEffect, useRef, useState } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { useTranslation } from "react-i18next";
import * as styles from "./achievement-notification.css";
interface AchievementInfo {
displayName: string;
iconUrl: string;
}
const NOTIFICATION_TIMEOUT = 4000;
export function AchievementNotification() {
const { t } = useTranslation("achievement");
const [isClosing, setIsClosing] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [achievements, setAchievements] = useState<AchievementInfo[]>([]);
const [currentAchievement, setCurrentAchievement] =
useState<AchievementInfo | null>(null);
const achievementAnimation = useRef(-1);
const closingAnimation = useRef(-1);
const visibleAnimation = useRef(-1);
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.2;
audio.play();
}, []);
useEffect(() => {
const unsubscribe = window.electron.onCombinedAchievementsUnlocked(
(gameCount, achievementCount) => {
if (gameCount === 0 || achievementCount === 0) return;
setAchievements([
{
displayName: t("new_achievements_unlocked", {
gameCount,
achievementCount,
}),
iconUrl:
"https://avatars.githubusercontent.com/u/164102380?s=400&u=01a13a7b4f0c642f7e547b8e1d70440ea06fa750&v=4",
},
]);
playAudio();
}
);
return () => {
unsubscribe();
};
}, [playAudio]);
useEffect(() => {
const unsubscribe = window.electron.onAchievementUnlocked(
(_object, _shop, achievements) => {
if (!achievements || !achievements.length) return;
setAchievements((ach) => ach.concat(achievements));
playAudio();
}
);
return () => {
unsubscribe();
};
}, [playAudio]);
const hasAchievementsPending = achievements.length > 0;
const startAnimateClosing = useCallback(() => {
cancelAnimationFrame(closingAnimation.current);
cancelAnimationFrame(visibleAnimation.current);
cancelAnimationFrame(achievementAnimation.current);
setIsClosing(true);
const zero = performance.now();
closingAnimation.current = requestAnimationFrame(
function animateClosing(time) {
if (time - zero <= 1000) {
closingAnimation.current = requestAnimationFrame(animateClosing);
} else {
setIsVisible(false);
}
}
);
}, []);
useEffect(() => {
if (hasAchievementsPending) {
setIsClosing(false);
setIsVisible(true);
let zero = performance.now();
cancelAnimationFrame(closingAnimation.current);
cancelAnimationFrame(visibleAnimation.current);
cancelAnimationFrame(achievementAnimation.current);
achievementAnimation.current = requestAnimationFrame(
function animateLock(time) {
if (time - zero > NOTIFICATION_TIMEOUT) {
zero = performance.now();
setAchievements((ach) => ach.slice(1));
}
achievementAnimation.current = requestAnimationFrame(animateLock);
}
);
} else {
startAnimateClosing();
}
}, [hasAchievementsPending]);
useEffect(() => {
if (achievements.length) {
setCurrentAchievement(achievements[0]);
}
}, [achievements]);
if (!isVisible || !currentAchievement) return null;
return (
<div className={styles.container({ closing: isClosing })}>
<div className={styles.content}>
<img
src={currentAchievement.iconUrl}
alt={currentAchievement.displayName}
style={{ flex: 1, width: "60px" }}
/>
<div>
<p>{t("achievement_unlocked")}</p>
<p>{currentAchievement.displayName}</p>
</div>
</div>
</div>
);
}

View File

@@ -83,10 +83,8 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
};
useEffect(() => {
if (visible) {
getGameBackupPreview();
}
}, [getGameBackupPreview, visible]);
getGameBackupPreview();
}, [getGameBackupPreview]);
const backupStateLabel = useMemo(() => {
if (uploadingBackup) {

View File

@@ -140,7 +140,7 @@ export function GallerySlider() {
direction: "left",
})}
aria-label={t("previous_screenshot")}
tabIndex={0}
tabIndex={-1}
>
<ChevronLeftIcon size={36} />
</button>
@@ -153,7 +153,7 @@ export function GallerySlider() {
direction: "right",
})}
aria-label={t("next_screenshot")}
tabIndex={0}
tabIndex={-1}
>
<ChevronRightIcon size={36} />
</button>
@@ -169,6 +169,7 @@ export function GallerySlider() {
})}
onClick={() => setMediaIndex(i)}
aria-label={t("open_screenshot", { number: i + 1 })}
onFocus={() => setMediaIndex(i)}
>
<img
src={media.thumbnail}

View File

@@ -11,8 +11,9 @@ import * as styles from "./game-details.css";
import { useTranslation } from "react-i18next";
import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import { steamUrlBuilder } from "@shared";
import Lottie from "lottie-react";
import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import cloudAnimation from "@renderer/assets/lottie/cloud.json";
import { useUserDetails } from "@renderer/hooks";
const HERO_ANIMATION_THRESHOLD = 25;
@@ -164,9 +165,10 @@ export function GameDetailsContent() {
position: "relative",
}}
>
<img
src={cloudIconAnimated}
alt="Cloud icon"
<Lottie
animationData={cloudAnimation}
loop={false}
autoplay
style={{ width: 26, position: "absolute", top: -3 }}
/>
</div>

View File

@@ -6,8 +6,9 @@ import type { GameRepack, GameShop, Steam250Game } from "@types";
import { Button, ConfirmationModal } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import starsIconAnimated from "@renderer/assets/icons/stars-animated.gif";
import starsAnimation from "@renderer/assets/lottie/stars.json";
import Lottie from "lottie-react";
import { useTranslation } from "react-i18next";
import { SkeletonTheme } from "react-loading-skeleton";
import { GameDetailsSkeleton } from "./game-details-skeleton";
@@ -193,15 +194,15 @@ export default function GameDetails() {
<div
style={{ width: 16, height: 16, position: "relative" }}
>
<img
src={starsIconAnimated}
alt="Stars animation"
<Lottie
animationData={starsAnimation}
style={{
width: 70,
position: "absolute",
top: -28,
left: -27,
}}
loop={false}
/>
</div>
{t("next_suggestion")}

View File

@@ -1,9 +1,4 @@
import {
DownloadIcon,
GearIcon,
PlayIcon,
PlusCircleIcon,
} from "@primer/octicons-react";
import { GearIcon, PlayIcon, PlusCircleIcon } from "@primer/octicons-react";
import { Button } from "@renderer/components";
import { useDownload, useLibrary } from "@renderer/hooks";
import { useContext, useState } from "react";
@@ -11,6 +6,7 @@ import { useTranslation } from "react-i18next";
import * as styles from "./hero-panel-actions.css";
import { gameDetailsContext } from "@renderer/context";
import { DownloadIcon } from "@renderer/components/sidebar/download-icon";
export function HeroPanelActions() {
const [toggleLibraryGameDisabled, setToggleLibraryGameDisabled] =
@@ -129,7 +125,7 @@ export function HeroPanelActions() {
disabled={isGameDownloading || !repacks.length}
className={styles.heroPanelAction}
>
<DownloadIcon />
<DownloadIcon isDownloading={false} />
{t("download")}
</Button>
);

View File

@@ -38,6 +38,9 @@ export function HowLongToBeatSection({
<li
key={category.title}
className={styles.howLongToBeatCategory}
aria-label={`${category.title}, ${getDuration(
category.duration
)}`}
>
<p
className={styles.howLongToBeatCategoryLabel}

View File

@@ -194,6 +194,10 @@ export function Sidebar() {
})}
className={styles.listItem}
title={achievement.description}
aria-label={`
${t("achievement")} ${index + 1},
${achievement.displayName}
`}
>
<img
className={styles.listItemImage({

View File

@@ -68,10 +68,3 @@ export const buttonsList = style({
padding: "0",
gap: `${SPACING_UNIT}px`,
});
export const flameIcon = style({
width: "30px",
top: "-10px",
left: "-5px",
position: "absolute",
});

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -7,28 +7,21 @@ import Skeleton, { SkeletonTheme } from "react-loading-skeleton";
import { Button, GameCard, Hero } from "@renderer/components";
import type { Steam250Game, CatalogueEntry } from "@types";
import flameIconStatic from "@renderer/assets/icons/flame-static.png";
import flameIconAnimated from "@renderer/assets/icons/flame-animated.gif";
import starsIconAnimated from "@renderer/assets/icons/stars-animated.gif";
import starsAnimation from "@renderer/assets/lottie/stars.json";
import flameAnimation from "@renderer/assets/lottie/flame.json";
import * as styles from "./home.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import Lottie, { type LottieRefCurrentProps } from "lottie-react";
import { buildGameDetailsPath } from "@renderer/helpers";
import { CatalogueCategory } from "@shared";
import { catalogueCacheTable, db } from "@renderer/dexie";
import { add } from "date-fns";
const categoryCacheDurationInSeconds = {
[CatalogueCategory.Hot]: 60 * 60 * 2,
[CatalogueCategory.Weekly]: 60 * 60 * 24,
[CatalogueCategory.Achievements]: 60 * 60 * 24,
};
export default function Home() {
const { t } = useTranslation("home");
const navigate = useNavigate();
const [animateFlame, setAnimateFlame] = useState(false);
const flameAnimationRef = useRef<LottieRefCurrentProps>(null);
const [isLoading, setIsLoading] = useState(false);
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
@@ -44,43 +37,19 @@ export default function Home() {
[CatalogueCategory.Achievements]: [],
});
const getCatalogue = useCallback(async (category: CatalogueCategory) => {
try {
const catalogueCache = await catalogueCacheTable
.where("expiresAt")
.above(new Date())
.and((cache) => cache.category === category)
.first();
const getCatalogue = useCallback((category: CatalogueCategory) => {
setCurrentCatalogueCategory(category);
setIsLoading(true);
setCurrentCatalogueCategory(category);
setIsLoading(true);
if (catalogueCache)
return setCatalogue((prev) => ({
...prev,
[category]: catalogueCache.games,
}));
const catalogue = await window.electron.getCatalogue(category);
db.transaction("rw", catalogueCacheTable, async () => {
await catalogueCacheTable.where("category").equals(category).delete();
await catalogueCacheTable.add({
category,
games: catalogue,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: add(new Date(), {
seconds: categoryCacheDurationInSeconds[category],
}),
});
window.electron
.getCatalogue(category)
.then((catalogue) => {
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
})
.catch(() => {})
.finally(() => {
setIsLoading(false);
});
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
} finally {
setIsLoading(false);
}
}, []);
const getRandomGame = useCallback(() => {
@@ -119,13 +88,13 @@ export default function Home() {
const handleMouseEnterCategory = (category: CatalogueCategory) => {
if (category === CatalogueCategory.Hot) {
setAnimateFlame(true);
flameAnimationRef?.current?.play();
}
};
const handleMouseLeaveCategory = (category: CatalogueCategory) => {
if (category === CatalogueCategory.Hot) {
setAnimateFlame(false);
flameAnimationRef?.current?.stop();
}
};
@@ -154,17 +123,17 @@ export default function Home() {
<div
style={{ width: 16, height: 16, position: "relative" }}
>
<img
src={flameIconStatic}
alt="Flame icon"
className={styles.flameIcon}
style={{ display: animateFlame ? "none" : "block" }}
/>
<img
src={flameIconAnimated}
alt="Flame animation"
className={styles.flameIcon}
style={{ display: animateFlame ? "block" : "none" }}
<Lottie
lottieRef={flameAnimationRef}
animationData={flameAnimation}
loop={false}
autoplay={false}
style={{
width: 30,
top: -10,
left: -5,
position: "absolute",
}}
/>
</div>
)}
@@ -181,10 +150,10 @@ export default function Home() {
disabled={!randomGame}
>
<div style={{ width: 16, height: 16, position: "relative" }}>
<img
src={starsIconAnimated}
alt="Stars animation"
<Lottie
animationData={starsAnimation}
style={{ width: 70, position: "absolute", top: -28, left: -27 }}
loop={false}
/>
</div>
{t("surprise_me")}
@@ -194,9 +163,10 @@ export default function Home() {
<h2 style={{ display: "flex", gap: SPACING_UNIT }}>
{currentCatalogueCategory === CatalogueCategory.Hot && (
<div style={{ width: 24, height: 24, position: "relative" }}>
<img
src={flameIconAnimated}
alt="Flame animation"
<Lottie
animationData={flameAnimation}
loop={false}
autoplay
style={{
width: 40,
top: -10,

View File

@@ -254,7 +254,6 @@ export function ProfileHero() {
if (gameRunning)
return {
...gameRunning,
objectId: gameRunning.objectID,
sessionDurationInSeconds: gameRunning.sessionDurationInMillis / 1000,
};
@@ -331,7 +330,7 @@ export function ProfileHero() {
<Link
to={buildGameDetailsPath({
...currentGame,
objectId: currentGame.objectId,
objectId: currentGame.objectID,
})}
>
{currentGame.title}

View File

@@ -18,7 +18,6 @@ export function SettingsBehavior() {
preferQuitInsteadOfHiding: false,
runAtStartup: false,
startMinimized: false,
disableNsfwAlert: false,
});
const { t } = useTranslation("settings");
@@ -29,7 +28,6 @@ export function SettingsBehavior() {
preferQuitInsteadOfHiding: userPreferences.preferQuitInsteadOfHiding,
runAtStartup: userPreferences.runAtStartup,
startMinimized: userPreferences.startMinimized,
disableNsfwAlert: userPreferences.disableNsfwAlert,
});
}
}, [userPreferences]);
@@ -88,14 +86,6 @@ export function SettingsBehavior() {
/>
</div>
)}
<CheckboxField
label={t("disable_nsfw_alert")}
checked={form.disableNsfwAlert}
onChange={() =>
handleChange({ disableNsfwAlert: !form.disableNsfwAlert })
}
/>
</>
);
}

View File

@@ -0,0 +1,75 @@
import { useTranslation } from "react-i18next";
import { Button, Modal } from "../../components";
import { SPACING_UNIT } from "../../theme.css";
export interface UserFriendsModalProps {
visible: boolean;
onClose: () => void;
}
export const SubscriptionTourModal = ({
visible,
onClose,
}: UserFriendsModalProps) => {
const { t } = useTranslation("tour");
const handleSubscribeClick = () => {
window.electron.openCheckout().finally(onClose);
};
return (
<Modal
visible={visible}
title={t("subscription_tour_title")}
onClose={onClose}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
gap: `${SPACING_UNIT * 2}px`,
justifyContent: "space-around",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: `${SPACING_UNIT * 2}px`,
}}
>
<h2>Hydra Cloud</h2>
<ul style={{ margin: "0", padding: "0" }}>
<li style={{ margin: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px` }}>
{t("cloud_saving")}
</li>
<li style={{ margin: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px` }}>
{t("cloud_achievements")}
</li>
<li style={{ margin: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px` }}>
{t("show_and_compare_achievements")}
</li>
<li style={{ margin: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px` }}>
{t("animated_profile_banner")}
</li>
<li style={{ margin: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px` }}>
{t("animated_profile_picture")}
</li>
<li style={{ margin: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px` }}>
{t("premium_support")}
</li>
</ul>
</div>
</div>
<Button onClick={handleSubscribeClick}>{t("subscribe_now")}</Button>
</div>
</Modal>
);
};

View File

@@ -101,7 +101,6 @@ export const UserFriendModalAddFriend = ({
>
{isAddingFriend ? t("sending") : t("add")}
</Button>
<Button
onClick={handleClickSeeProfile}
disabled={isAddingFriend}

View File

@@ -1,21 +0,0 @@
$background-color: #1c1c1c;
$dark-background-color: #151515;
$muted-color: #c0c1c7;
$body-color: #8e919b;
$border-color: #424244;
$success-color: #1c9749;
$danger-color: #e11d48;
$warning-color: #ffc107;
$disabled-opacity: 0.5;
$active-opacity: 0.7;
$spacing-unit: 8px;
$toast-z-index: 5;
$bottom-panel-z-index: 3;
$title-bar-z-index: 4;
$backdrop-z-index: 4;
$modal-z-index: 5;

View File

@@ -1,10 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-svgr/client" />
interface ImportMetaEnv {
readonly RENDERER_VITE_INTERCOM_APP_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -55,9 +55,6 @@ export const removeDuplicateSpaces = (name: string) =>
export const replaceDotsWithSpace = (name: string) => name.replace(/\./g, " ");
export const replaceNbspWithSpace = (name: string) =>
name.replace(new RegExp(String.fromCharCode(160), "g"), " ");
export const replaceUnderscoreWithSpace = (name: string) =>
name.replace(/_/g, " ");
@@ -72,7 +69,6 @@ export const formatName = pipe<string>(
removeSpecialEditionFromName,
replaceUnderscoreWithSpace,
replaceDotsWithSpace,
replaceNbspWithSpace,
(str) => str.replace(/DIRECTOR'S CUT/g, ""),
removeSymbolsFromName,
removeDuplicateSpaces,

View File

@@ -161,7 +161,6 @@ export interface UserPreferences {
preferQuitInsteadOfHiding: boolean;
runAtStartup: boolean;
startMinimized: boolean;
disableNsfwAlert: boolean;
}
export interface Steam250Game {
@@ -246,7 +245,6 @@ export interface Subscription {
export interface UserDetails {
id: string;
username: string;
email: string | null;
displayName: string;
profileImageUrl: string | null;
backgroundImageUrl: string | null;
@@ -259,7 +257,6 @@ export interface UserProfile {
id: string;
displayName: string;
profileImageUrl: string | null;
email: string | null;
backgroundImageUrl: string | null;
profileVisibility: ProfileVisibility;
libraryGames: UserGame[];
@@ -376,4 +373,4 @@ export interface ComparedAchievements {
export * from "./steam.types";
export * from "./real-debrid.types";
export * from "./ludusavi.types";
export * from "./how-long-to-beat.types";
export * from "./howlongtobeat.types";

1820
yarn.lock

File diff suppressed because it is too large Load Diff