mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-11 13:56:16 +00:00
Merge branch 'main' of https://github.com/hydralauncher/hydra into feat/custom-games-support
This commit is contained in:
@@ -54,6 +54,8 @@
|
||||
"diskusage": "^1.2.0",
|
||||
"electron-log": "^5.2.4",
|
||||
"electron-updater": "^6.6.2",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"file-type": "^20.5.0",
|
||||
"framer-motion": "^12.15.0",
|
||||
"i18next": "^23.11.2",
|
||||
@@ -63,6 +65,8 @@
|
||||
"lodash-es": "^4.17.21",
|
||||
"parse-torrent": "^11.0.18",
|
||||
"rc-virtual-list": "^3.18.3",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-i18next": "^14.1.0",
|
||||
"react-loading-skeleton": "^3.4.0",
|
||||
|
||||
26
src/locales/en/translation.json
Normal file → Executable file
26
src/locales/en/translation.json
Normal file → Executable file
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Successfully signed in"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Featured",
|
||||
"surprise_me": "Surprise me",
|
||||
"no_results": "No results found",
|
||||
"start_typing": "Starting typing to search...",
|
||||
@@ -252,6 +251,8 @@
|
||||
"download_error_not_cached_on_hydra": "This download is not available on Nimbus.",
|
||||
"game_removed_from_favorites": "Game removed from favorites",
|
||||
"game_added_to_favorites": "Game added to favorites",
|
||||
"game_removed_from_pinned": "Game removed from pinned",
|
||||
"game_added_to_pinned": "Game added to pinned",
|
||||
"automatically_extract_downloaded_files": "Automatically extract downloaded files",
|
||||
"create_start_menu_shortcut": "Create Start Menu shortcut",
|
||||
"invalid_wine_prefix_path": "Invalid Wine prefix path",
|
||||
@@ -270,7 +271,20 @@
|
||||
"backup_frozen": "Backup pinned",
|
||||
"backup_unfrozen": "Backup unpinned",
|
||||
"backup_freeze_failed": "Failed to freeze backup",
|
||||
"backup_freeze_failed_description": "You must leave at least one free slot for automatic backups"
|
||||
"backup_freeze_failed_description": "You must leave at least one free slot for automatic backups",
|
||||
"game_details": "Game Details",
|
||||
"currency_symbol": "$",
|
||||
"currency_country": "us",
|
||||
"prices": "Prices",
|
||||
"no_prices_found": "No prices found",
|
||||
"view_all_prices": "Click to view all prices",
|
||||
"retail_price": "Retail price",
|
||||
"keyshop_price": "Keyshop price",
|
||||
"historical_retail": "Historical retail",
|
||||
"historical_keyshop": "Historical keyshop",
|
||||
"language": "Language",
|
||||
"caption": "Caption",
|
||||
"audio": "Audio"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Activate Hydra",
|
||||
@@ -493,6 +507,10 @@
|
||||
"last_time_played": "Last played {{period}}",
|
||||
"activity": "Recent Activity",
|
||||
"library": "Library",
|
||||
"pinned": "Pinned",
|
||||
"achievements_earned": "Achievements earned",
|
||||
"played_recently": "Played recently",
|
||||
"playtime": "Playtime",
|
||||
"total_play_time": "Total playtime",
|
||||
"manual_playtime_tooltip": "This playtime has been manually updated",
|
||||
"no_recent_activity_title": "Hmmm… nothing here",
|
||||
@@ -569,7 +587,9 @@
|
||||
"show_achievements_on_profile": "Show your achievements on your profile",
|
||||
"show_points_on_profile": "Show your earned points on your profile",
|
||||
"error_adding_friend": "Could not send friend request. Please check friend code",
|
||||
"friend_code_length_error": "Friend code must have 8 characters"
|
||||
"friend_code_length_error": "Friend code must have 8 characters",
|
||||
"game_removed_from_pinned": "Game removed from pinned",
|
||||
"game_added_to_pinned": "Game added to pinned"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Achievement unlocked",
|
||||
|
||||
16
src/locales/pt-BR/translation.json
Normal file → Executable file
16
src/locales/pt-BR/translation.json
Normal file → Executable file
@@ -4,7 +4,6 @@
|
||||
"successfully_signed_in": "Autenticado com sucesso"
|
||||
},
|
||||
"home": {
|
||||
"featured": "Destaques",
|
||||
"hot": "Populares",
|
||||
"weekly": "📅 Mais baixados da semana",
|
||||
"achievements": "🏆 Pra platinar",
|
||||
@@ -207,7 +206,20 @@
|
||||
"backup_frozen": "Backup fixado",
|
||||
"backup_unfrozen": "Backup removido dos fixados",
|
||||
"backup_freeze_failed": "Falha ao fixar backup",
|
||||
"backup_freeze_failed_description": "Você deve deixar pelo menos um espaço livre para backups automáticos"
|
||||
"backup_freeze_failed_description": "Você deve deixar pelo menos um espaço livre para backups automáticos",
|
||||
"game_details": "Detalhes do Jogo",
|
||||
"currency_symbol": "R$",
|
||||
"currency_country": "br",
|
||||
"prices": "Preços",
|
||||
"no_prices_found": "Nenhum preço encontrado",
|
||||
"view_all_prices": "Clique para ver todos os preços",
|
||||
"retail_price": "Preço de lojas oficiais",
|
||||
"keyshop_price": "Preço em keyshops",
|
||||
"historical_retail": "Preço histórico de lojas oficiais",
|
||||
"historical_keyshop": "Preço histórico em keyshops",
|
||||
"language": "Idioma",
|
||||
"caption": "Legenda",
|
||||
"audio": "Áudio"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Ativação",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"hot": "Сейчас популярно",
|
||||
"start_typing": "Начинаю вводить текст...",
|
||||
"weekly": "📅 Лучшие игры недели",
|
||||
"achievements": "🏆 Игры с достижениями"
|
||||
"achievements": "🏆 Игры с достижениями",
|
||||
"already_in_library": "Уже в библиотеке"
|
||||
},
|
||||
"sidebar": {
|
||||
"catalogue": "Каталог",
|
||||
@@ -249,7 +250,23 @@
|
||||
"invalid_wine_prefix_path": "Недопустимый путь префикса Wine",
|
||||
"invalid_wine_prefix_path_description": "Путь к префиксу Wine недействителен. Пожалуйста, проверьте путь и попробуйте снова.",
|
||||
"missing_wine_prefix": "Префикс Wine необходим для создания резервной копии в Linux",
|
||||
"download_error_not_cached_on_hydra": "Эта загрузка недоступна на Nimbus."
|
||||
"download_error_not_cached_on_hydra": "Эта загрузка недоступна на Nimbus.",
|
||||
"update_playtime_success": "Время игры успешно обновлено",
|
||||
"update_playtime_error": "Не удалось обновить время игры",
|
||||
"manual_playtime_warning": "Ваши часы будут отмечены как обновленные вручную. Это действие нельзя отменить.",
|
||||
"artifact_renamed": "Резервная копия успешно переименована",
|
||||
"rename_artifact": "Переименовать резервную копию",
|
||||
"rename_artifact_description": "Переименуйте резервную копию, присвоив ей более описательное имя.",
|
||||
"artifact_name_label": "Название резервной копии",
|
||||
"artifact_name_placeholder": "Введите название для резервной копии",
|
||||
"max_length_field": "Это поле должно содержать менее {{length}} символов",
|
||||
"freeze_backup": "Закрепить, чтобы она не была перезаписана автоматическими резервными копиями",
|
||||
"unfreeze_backup": "Открепить",
|
||||
"backup_frozen": "Резервная копия закреплена",
|
||||
"backup_unfrozen": "Резервная копия откреплена",
|
||||
"backup_freeze_failed": "Не удалось закрепить резервную копию",
|
||||
"backup_freeze_failed_description": "Вы должны оставить как минимум один свободный слот для автоматических резервных копий",
|
||||
"manual_playtime_tooltip": "Это время игры было обновлено вручную"
|
||||
},
|
||||
"activation": {
|
||||
"title": "Активировать Hydra",
|
||||
@@ -425,7 +442,8 @@
|
||||
"hidden": "Скрытый",
|
||||
"test_notification": "Тестовое уведомление",
|
||||
"notification_preview": "Предварительный просмотр уведомления о достижении",
|
||||
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру"
|
||||
"enable_friend_start_game_notifications": "Когда друг начинает играть в игру",
|
||||
"enable_steam_achievements": "Включить поиск достижений Steam"
|
||||
},
|
||||
"notifications": {
|
||||
"download_complete": "Загрузка завершена",
|
||||
@@ -438,12 +456,12 @@
|
||||
"notification_achievement_unlocked_title": "Достижение разблокировано для {{game}}",
|
||||
"notification_achievement_unlocked_body": "были разблокированы {{achievement}} и другие {{count}}",
|
||||
"new_friend_request_title": "Новый запрос на добавление в друзья",
|
||||
"new_friend_request_description": "Вы получили новый запрос на добавление в друзья",
|
||||
"extraction_complete": "Распаковка завершена",
|
||||
"game_extracted": "{{title}} успешно распакован",
|
||||
"friend_started_playing_game": "{{displayName}} начал играть в игру",
|
||||
"test_achievement_notification_title": "Это тестовое уведомление",
|
||||
"test_achievement_notification_description": "Довольно круто, да?"
|
||||
"test_achievement_notification_description": "Довольно круто, да?",
|
||||
"new_friend_request_description": "{{displayName}} отправил вам запрос в друзья"
|
||||
},
|
||||
"system_tray": {
|
||||
"open": "Открыть Hydra",
|
||||
@@ -545,7 +563,9 @@
|
||||
"achievements_unlocked": "Достижения разблокированы",
|
||||
"earned_points": "Заработано очков:",
|
||||
"show_achievements_on_profile": "Покажите свои достижения в профиле",
|
||||
"show_points_on_profile": "Показывать заработанные очки в своем профиле"
|
||||
"show_points_on_profile": "Показывать заработанные очки в своем профиле",
|
||||
"error_adding_friend": "Не удалось отправить запрос в друзья. Пожалуйста, проверьте код друга",
|
||||
"friend_code_length_error": "Код друга должен содержать 8 символов"
|
||||
},
|
||||
"achievement": {
|
||||
"achievement_unlocked": "Достижение разблокировано",
|
||||
|
||||
@@ -19,6 +19,7 @@ import "./library/update-custom-game";
|
||||
import "./library/update-game-custom-assets";
|
||||
import "./library/add-game-to-favorites";
|
||||
import "./library/remove-game-from-favorites";
|
||||
import "./library/toggle-game-pin";
|
||||
import "./library/create-game-shortcut";
|
||||
import "./library/close-game";
|
||||
import "./library/delete-game-folder";
|
||||
@@ -71,6 +72,7 @@ import "./auth/sign-out";
|
||||
import "./auth/open-auth-window";
|
||||
import "./auth/get-session-hash";
|
||||
import "./user/get-user";
|
||||
import "./user/get-user-library";
|
||||
import "./user/get-blocked-users";
|
||||
import "./user/block-user";
|
||||
import "./user/unblock-user";
|
||||
|
||||
43
src/main/events/library/toggle-game-pin.ts
Normal file
43
src/main/events/library/toggle-game-pin.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { gamesSublevel, levelKeys } from "@main/level";
|
||||
import { HydraApi, logger } from "@main/services";
|
||||
import type { GameShop, UserGame } from "@types";
|
||||
|
||||
const toggleGamePin = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
pin: boolean
|
||||
) => {
|
||||
try {
|
||||
const gameKey = levelKeys.game(shop, objectId);
|
||||
|
||||
const game = await gamesSublevel.get(gameKey);
|
||||
if (!game) return;
|
||||
|
||||
if (pin) {
|
||||
const response = await HydraApi.put<UserGame>(
|
||||
`/profile/games/${shop}/${objectId}/pin`
|
||||
);
|
||||
|
||||
await gamesSublevel.put(gameKey, {
|
||||
...game,
|
||||
isPinned: pin,
|
||||
pinnedDate: new Date(response.pinnedDate!),
|
||||
});
|
||||
} else {
|
||||
await HydraApi.put(`/profile/games/${shop}/${objectId}/unpin`);
|
||||
|
||||
await gamesSublevel.put(gameKey, {
|
||||
...game,
|
||||
isPinned: pin,
|
||||
pinnedDate: null,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to update game pinned status", error);
|
||||
throw new Error(`Failed to update game pinned status: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
registerEvent("toggleGamePin", toggleGamePin);
|
||||
28
src/main/events/user/get-user-library.ts
Normal file
28
src/main/events/user/get-user-library.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { HydraApi } from "@main/services";
|
||||
import type { UserLibraryResponse } from "@types";
|
||||
|
||||
const getUserLibrary = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
userId: string,
|
||||
take: number = 12,
|
||||
skip: number = 0,
|
||||
sortBy?: string
|
||||
): Promise<UserLibraryResponse | null> => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.append("take", take.toString());
|
||||
params.append("skip", skip.toString());
|
||||
|
||||
if (sortBy) {
|
||||
params.append("sortBy", sortBy);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const baseUrl = `/users/${userId}/library`;
|
||||
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
|
||||
return HydraApi.get<UserLibraryResponse>(url).catch(() => null);
|
||||
};
|
||||
|
||||
registerEvent("getUserLibrary", getUserLibrary);
|
||||
@@ -8,6 +8,7 @@ type ProfileGame = {
|
||||
playTimeInMilliseconds: number;
|
||||
hasManuallyUpdatedPlaytime: boolean;
|
||||
isFavorite?: boolean;
|
||||
isPinned?: boolean;
|
||||
} & ShopAssets;
|
||||
|
||||
export const mergeWithRemoteGames = async () => {
|
||||
@@ -36,6 +37,7 @@ export const mergeWithRemoteGames = async () => {
|
||||
lastTimePlayed: updatedLastTimePlayed,
|
||||
playTimeInMilliseconds: updatedPlayTime,
|
||||
favorite: game.isFavorite ?? localGame.favorite,
|
||||
isPinned: game.isPinned ?? localGame.isPinned,
|
||||
});
|
||||
} else {
|
||||
await gamesSublevel.put(gameKey, {
|
||||
@@ -51,6 +53,7 @@ export const mergeWithRemoteGames = async () => {
|
||||
hasManuallyUpdatedPlaytime: game.hasManuallyUpdatedPlaytime,
|
||||
isDeleted: false,
|
||||
favorite: game.isFavorite ?? false,
|
||||
isPinned: game.isPinned ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export const uploadGamesBatch = async () => {
|
||||
shop: game.shop,
|
||||
lastTimePlayed: game.lastTimePlayed,
|
||||
isFavorite: game.favorite,
|
||||
isPinned: game.isPinned ?? false,
|
||||
};
|
||||
})
|
||||
).catch(() => {});
|
||||
|
||||
@@ -201,6 +201,8 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
ipcRenderer.invoke("addGameToFavorites", shop, objectId),
|
||||
removeGameFromFavorites: (shop: GameShop, objectId: string) =>
|
||||
ipcRenderer.invoke("removeGameFromFavorites", shop, objectId),
|
||||
toggleGamePin: (shop: GameShop, objectId: string, pinned: boolean) =>
|
||||
ipcRenderer.invoke("toggleGamePin", shop, objectId, pinned),
|
||||
updateLaunchOptions: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -424,6 +426,12 @@ contextBridge.exposeInMainWorld("electron", {
|
||||
|
||||
/* User */
|
||||
getUser: (userId: string) => ipcRenderer.invoke("getUser", userId),
|
||||
getUserLibrary: (
|
||||
userId: string,
|
||||
take?: number,
|
||||
skip?: number,
|
||||
sortBy?: string
|
||||
) => ipcRenderer.invoke("getUserLibrary", userId, take, skip, sortBy),
|
||||
blockUser: (userId: string) => ipcRenderer.invoke("blockUser", userId),
|
||||
unblockUser: (userId: string) => ipcRenderer.invoke("unblockUser", userId),
|
||||
getUserFriends: (userId: string, take: number, skip: number) =>
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.16);
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
html,
|
||||
|
||||
@@ -4,9 +4,14 @@
|
||||
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;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all ease 0.2s;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,36 @@
|
||||
|
||||
.hero {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
min-height: 280px;
|
||||
max-height: 280px;
|
||||
border-radius: 4px;
|
||||
height: 180px;
|
||||
min-height: 150px;
|
||||
border-radius: 0;
|
||||
color: #dadbe1;
|
||||
overflow: hidden;
|
||||
box-shadow: 0px 0px 15px 0px #000000;
|
||||
cursor: pointer;
|
||||
border: solid 1px globals.$border-color;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (min-width: 480px) {
|
||||
height: 220px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
height: 300px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) and (min-height: 800px) {
|
||||
height: 400px;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) and (max-height: 799px) {
|
||||
height: 300px;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
&__media {
|
||||
object-fit: cover;
|
||||
@@ -47,10 +67,42 @@
|
||||
&__content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3);
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2);
|
||||
gap: calc(globals.$spacing-unit);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 3);
|
||||
gap: calc(globals.$spacing-unit * 1.5);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3);
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
}
|
||||
|
||||
&__logo {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
width: 120px;
|
||||
|
||||
@media (min-width: 480px) {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) and (min-height: 800px) {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) and (max-height: 799px) {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export function Hero() {
|
||||
width="250px"
|
||||
alt={game.description ?? ""}
|
||||
loading="eager"
|
||||
className="hero__logo"
|
||||
/>
|
||||
<p className="hero__description">{game.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -38,14 +38,12 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
|
||||
isGameRunning: false,
|
||||
isLoading: false,
|
||||
objectId: undefined,
|
||||
gameColor: "",
|
||||
showRepacksModal: false,
|
||||
showGameOptionsModal: false,
|
||||
stats: null,
|
||||
achievements: null,
|
||||
hasNSFWContentBlocked: false,
|
||||
lastDownloadedOption: null,
|
||||
setGameColor: () => {},
|
||||
selectGameExecutable: async () => null,
|
||||
updateGame: async () => {},
|
||||
setShowGameOptionsModal: () => {},
|
||||
@@ -82,7 +80,6 @@ export function GameDetailsContextProvider({
|
||||
const [stats, setStats] = useState<GameStats | null>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [gameColor, setGameColor] = useState("");
|
||||
const [isGameRunning, setIsGameRunning] = useState(false);
|
||||
const [showRepacksModal, setShowRepacksModal] = useState(false);
|
||||
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
|
||||
@@ -292,7 +289,6 @@ export function GameDetailsContextProvider({
|
||||
isGameRunning,
|
||||
isLoading,
|
||||
objectId,
|
||||
gameColor,
|
||||
showGameOptionsModal,
|
||||
showRepacksModal,
|
||||
stats,
|
||||
@@ -300,7 +296,6 @@ export function GameDetailsContextProvider({
|
||||
hasNSFWContentBlocked,
|
||||
lastDownloadedOption,
|
||||
setHasNSFWContentBlocked,
|
||||
setGameColor,
|
||||
selectGameExecutable,
|
||||
updateGame,
|
||||
setShowRepacksModal,
|
||||
|
||||
@@ -16,14 +16,12 @@ export interface GameDetailsContext {
|
||||
isGameRunning: boolean;
|
||||
isLoading: boolean;
|
||||
objectId: string | undefined;
|
||||
gameColor: string;
|
||||
showRepacksModal: boolean;
|
||||
showGameOptionsModal: boolean;
|
||||
stats: GameStats | null;
|
||||
achievements: UserAchievement[] | null;
|
||||
hasNSFWContentBlocked: boolean;
|
||||
lastDownloadedOption: GameRepack | null;
|
||||
setGameColor: React.Dispatch<React.SetStateAction<string>>;
|
||||
selectGameExecutable: () => Promise<string | null>;
|
||||
updateGame: () => Promise<void>;
|
||||
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { darkenColor } from "@renderer/helpers";
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
import type { Badge, UserProfile, UserStats } from "@types";
|
||||
import type { Badge, UserProfile, UserStats, UserGame } from "@types";
|
||||
import { average } from "color.js";
|
||||
|
||||
import { createContext, useCallback, useEffect, useState } from "react";
|
||||
@@ -14,9 +14,12 @@ export interface UserProfileContext {
|
||||
isMe: boolean;
|
||||
userStats: UserStats | null;
|
||||
getUserProfile: () => Promise<void>;
|
||||
getUserLibraryGames: (sortBy?: string) => Promise<void>;
|
||||
setSelectedBackgroundImage: React.Dispatch<React.SetStateAction<string>>;
|
||||
backgroundImage: string;
|
||||
badges: Badge[];
|
||||
libraryGames: UserGame[];
|
||||
pinnedGames: UserGame[];
|
||||
}
|
||||
|
||||
export const DEFAULT_USER_PROFILE_BACKGROUND = "#151515B3";
|
||||
@@ -27,9 +30,12 @@ export const userProfileContext = createContext<UserProfileContext>({
|
||||
isMe: false,
|
||||
userStats: null,
|
||||
getUserProfile: async () => {},
|
||||
getUserLibraryGames: async (_sortBy?: string) => {},
|
||||
setSelectedBackgroundImage: () => {},
|
||||
backgroundImage: "",
|
||||
badges: [],
|
||||
libraryGames: [],
|
||||
pinnedGames: [],
|
||||
});
|
||||
|
||||
const { Provider } = userProfileContext;
|
||||
@@ -49,6 +55,8 @@ export function UserProfileContextProvider({
|
||||
const [userStats, setUserStats] = useState<UserStats | null>(null);
|
||||
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
|
||||
const [libraryGames, setLibraryGames] = useState<UserGame[]>([]);
|
||||
const [pinnedGames, setPinnedGames] = useState<UserGame[]>([]);
|
||||
const [badges, setBadges] = useState<Badge[]>([]);
|
||||
const [heroBackground, setHeroBackground] = useState(
|
||||
DEFAULT_USER_PROFILE_BACKGROUND
|
||||
@@ -85,8 +93,34 @@ export function UserProfileContextProvider({
|
||||
});
|
||||
}, [userId]);
|
||||
|
||||
const getUserLibraryGames = useCallback(
|
||||
async (sortBy?: string) => {
|
||||
try {
|
||||
const response = await window.electron.getUserLibrary(
|
||||
userId,
|
||||
12,
|
||||
0,
|
||||
sortBy
|
||||
);
|
||||
|
||||
if (response) {
|
||||
setLibraryGames(response.library);
|
||||
setPinnedGames(response.pinnedGames);
|
||||
} else {
|
||||
setLibraryGames([]);
|
||||
setPinnedGames([]);
|
||||
}
|
||||
} catch (error) {
|
||||
setLibraryGames([]);
|
||||
setPinnedGames([]);
|
||||
}
|
||||
},
|
||||
[userId]
|
||||
);
|
||||
|
||||
const getUserProfile = useCallback(async () => {
|
||||
getUserStats();
|
||||
getUserLibraryGames();
|
||||
|
||||
return window.electron.getUser(userId).then((userProfile) => {
|
||||
if (userProfile) {
|
||||
@@ -102,7 +136,7 @@ export function UserProfileContextProvider({
|
||||
navigate(-1);
|
||||
}
|
||||
});
|
||||
}, [navigate, getUserStats, showErrorToast, userId, t]);
|
||||
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
|
||||
|
||||
const getBadges = useCallback(async () => {
|
||||
const badges = await window.electron.getBadges();
|
||||
@@ -111,6 +145,8 @@ export function UserProfileContextProvider({
|
||||
|
||||
useEffect(() => {
|
||||
setUserProfile(null);
|
||||
setLibraryGames([]);
|
||||
setPinnedGames([]);
|
||||
setHeroBackground(DEFAULT_USER_PROFILE_BACKGROUND);
|
||||
|
||||
getUserProfile();
|
||||
@@ -124,10 +160,13 @@ export function UserProfileContextProvider({
|
||||
heroBackground,
|
||||
isMe,
|
||||
getUserProfile,
|
||||
getUserLibraryGames,
|
||||
setSelectedBackgroundImage,
|
||||
backgroundImage: getBackgroundImageUrl(),
|
||||
userStats,
|
||||
badges,
|
||||
libraryGames,
|
||||
pinnedGames,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
12
src/renderer/src/declaration.d.ts
vendored
12
src/renderer/src/declaration.d.ts
vendored
@@ -37,6 +37,7 @@ import type {
|
||||
ShopDetailsWithAssets,
|
||||
AchievementCustomNotificationPosition,
|
||||
AchievementNotificationInfo,
|
||||
UserLibraryResponse,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
import type disk from "diskusage";
|
||||
@@ -157,6 +158,11 @@ declare global {
|
||||
shop: GameShop,
|
||||
objectId: string
|
||||
) => Promise<void>;
|
||||
toggleGamePin: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
pinned: boolean
|
||||
) => Promise<void>;
|
||||
updateLaunchOptions: (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
@@ -320,6 +326,12 @@ declare global {
|
||||
|
||||
/* User */
|
||||
getUser: (userId: string) => Promise<UserProfile | null>;
|
||||
getUserLibrary: (
|
||||
userId: string,
|
||||
take?: number,
|
||||
skip?: number,
|
||||
sortBy?: string
|
||||
) => Promise<UserLibraryResponse>;
|
||||
blockUser: (userId: string) => Promise<void>;
|
||||
unblockUser: (userId: string) => Promise<void>;
|
||||
getUserFriends: (
|
||||
|
||||
27
src/renderer/src/hooks/use-section-collapse.ts
Normal file
27
src/renderer/src/hooks/use-section-collapse.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface SectionCollapseState {
|
||||
pinned: boolean;
|
||||
library: boolean;
|
||||
}
|
||||
|
||||
export function useSectionCollapse() {
|
||||
const [collapseState, setCollapseState] = useState<SectionCollapseState>({
|
||||
pinned: false,
|
||||
library: false,
|
||||
});
|
||||
|
||||
const toggleSection = useCallback((section: keyof SectionCollapseState) => {
|
||||
setCollapseState((prevState) => ({
|
||||
...prevState,
|
||||
[section]: !prevState[section],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
collapseState,
|
||||
toggleSection,
|
||||
isPinnedCollapsed: collapseState.pinned,
|
||||
isLibraryCollapsed: collapseState.library,
|
||||
};
|
||||
}
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
import { LockIcon, PersonIcon, TrophyIcon } from "@primer/octicons-react";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import type { ComparedAchievements } from "@types";
|
||||
import { average } from "color.js";
|
||||
import Color from "color";
|
||||
import { Link } from "@renderer/components";
|
||||
import { ComparedAchievementList } from "./compared-achievement-list";
|
||||
import { AchievementList } from "./achievement-list";
|
||||
@@ -119,15 +117,8 @@ export function AchievementsContent({
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isHeaderStuck, setIsHeaderStuck] = useState(false);
|
||||
|
||||
const {
|
||||
gameTitle,
|
||||
objectId,
|
||||
shop,
|
||||
shopDetails,
|
||||
achievements,
|
||||
gameColor,
|
||||
setGameColor,
|
||||
} = useContext(gameDetailsContext);
|
||||
const { gameTitle, objectId, shop, shopDetails, achievements } =
|
||||
useContext(gameDetailsContext);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -136,22 +127,6 @@ export function AchievementsContent({
|
||||
dispatch(setHeaderTitle(gameTitle));
|
||||
}, [dispatch, gameTitle]);
|
||||
|
||||
const handleHeroLoad = async () => {
|
||||
const output = await average(
|
||||
shopDetails?.assets?.libraryHeroImageUrl ?? "",
|
||||
{
|
||||
amount: 1,
|
||||
format: "hex",
|
||||
}
|
||||
);
|
||||
|
||||
const backgroundColor = output
|
||||
? (new Color(output).darken(0.7).toString() as string)
|
||||
: "";
|
||||
|
||||
setGameColor(backgroundColor);
|
||||
};
|
||||
|
||||
const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
|
||||
const heroHeight = heroRef.current?.clientHeight ?? 150;
|
||||
|
||||
@@ -191,7 +166,6 @@ export function AchievementsContent({
|
||||
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
|
||||
className="achievements-content__achievements-list__image"
|
||||
alt={gameTitle}
|
||||
onLoad={handleHeroLoad}
|
||||
/>
|
||||
|
||||
<section
|
||||
@@ -199,12 +173,7 @@ export function AchievementsContent({
|
||||
onScroll={onScroll}
|
||||
className="achievements-content__achievements-list__section"
|
||||
>
|
||||
<div
|
||||
className="achievements-content__achievements-list__section__container"
|
||||
style={{
|
||||
background: `linear-gradient(0deg, #151515 0%, ${gameColor} 100%)`,
|
||||
}}
|
||||
>
|
||||
<div className="achievements-content__achievements-list__section__container">
|
||||
<div
|
||||
ref={heroRef}
|
||||
className="achievements-content__achievements-list__section__container__hero"
|
||||
|
||||
@@ -9,29 +9,39 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__media {
|
||||
&__viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
transition: translate 0.3s ease-in-out;
|
||||
border-radius: 4px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
&__animation-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
&__container-inner {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__slide {
|
||||
flex: 0 0 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__media {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
width: 100%;
|
||||
padding: globals.$spacing-unit 0;
|
||||
@@ -68,6 +78,7 @@
|
||||
border-radius: 4px;
|
||||
border: solid 1px globals.$border-color;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
@@ -83,49 +94,73 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__play-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
transition: all 0.2s ease;
|
||||
pointer-events: none;
|
||||
|
||||
.gallery-slider__preview-button:hover & {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
position: absolute;
|
||||
align-self: center;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
cursor: pointer;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
transition: all 0.2s ease-in-out;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
border-radius: 50%;
|
||||
color: globals.$muted-color;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
&--left {
|
||||
left: 0;
|
||||
margin-left: globals.$spacing-unit;
|
||||
transform: translateX(calc(-1 * (48px + globals.$spacing-unit)));
|
||||
left: globals.$spacing-unit;
|
||||
transform: translateY(-50%) translateX(-100px);
|
||||
opacity: 0;
|
||||
|
||||
&.gallery-slider__button--visible {
|
||||
transform: translateX(0);
|
||||
.gallery-slider__viewport:hover & {
|
||||
transform: translateY(-50%) translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--right {
|
||||
right: 0;
|
||||
margin-right: globals.$spacing-unit;
|
||||
transform: translateX(calc(48px + globals.$spacing-unit));
|
||||
right: globals.$spacing-unit;
|
||||
transform: translateY(-50%) translateX(100px);
|
||||
opacity: 0;
|
||||
|
||||
&.gallery-slider__button--visible {
|
||||
transform: translateX(0);
|
||||
.gallery-slider__viewport:hover & {
|
||||
transform: translateY(-50%) translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +1,144 @@
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useContext, useCallback, useMemo, useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
ChevronLeftIcon,
|
||||
PlayIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
import "./gallery-slider.scss";
|
||||
|
||||
export function GallerySlider() {
|
||||
const { shopDetails } = useContext(gameDetailsContext);
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const mediaContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { t } = useTranslation("game_details");
|
||||
|
||||
const hasScreenshots = shopDetails && shopDetails.screenshots?.length;
|
||||
const hasMovies = shopDetails && shopDetails.movies?.length;
|
||||
|
||||
const mediaCount = useMemo(() => {
|
||||
if (!shopDetails) return 0;
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
if (shopDetails.screenshots && shopDetails.movies) {
|
||||
return shopDetails.screenshots.length + shopDetails.movies.length;
|
||||
} else if (shopDetails.movies) {
|
||||
return shopDetails.movies.length;
|
||||
} else if (shopDetails.screenshots) {
|
||||
return shopDetails.screenshots.length;
|
||||
}
|
||||
const scrollPrev = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollPrev();
|
||||
}, [emblaApi]);
|
||||
|
||||
return 0;
|
||||
}, [shopDetails]);
|
||||
const scrollNext = useCallback(() => {
|
||||
if (emblaApi) emblaApi.scrollNext();
|
||||
}, [emblaApi]);
|
||||
|
||||
const [mediaIndex, setMediaIndex] = useState(0);
|
||||
const [showArrows, setShowArrows] = useState(false);
|
||||
const scrollTo = useCallback(
|
||||
(index: number) => {
|
||||
if (emblaApi) emblaApi.scrollTo(index);
|
||||
},
|
||||
[emblaApi]
|
||||
);
|
||||
|
||||
const showNextImage = () => {
|
||||
setMediaIndex((index: number) => {
|
||||
if (index === mediaCount - 1) return 0;
|
||||
const scrollToPreview = useCallback(
|
||||
(index: number, event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
scrollTo(index);
|
||||
|
||||
return index + 1;
|
||||
});
|
||||
};
|
||||
const button = event.currentTarget;
|
||||
const previewContainer = button.parentElement;
|
||||
|
||||
const showPrevImage = () => {
|
||||
setMediaIndex((index: number) => {
|
||||
if (index === 0) return mediaCount - 1;
|
||||
if (previewContainer) {
|
||||
const containerRect = previewContainer.getBoundingClientRect();
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
|
||||
return index - 1;
|
||||
});
|
||||
};
|
||||
const isOffScreenLeft = buttonRect.left < containerRect.left;
|
||||
const isOffScreenRight = buttonRect.right > containerRect.right;
|
||||
|
||||
useEffect(() => {
|
||||
setMediaIndex(0);
|
||||
}, [shopDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasMovies && mediaContainerRef.current) {
|
||||
mediaContainerRef.current.childNodes.forEach((node, index) => {
|
||||
if (node instanceof HTMLVideoElement) {
|
||||
if (index !== mediaIndex) {
|
||||
node.pause();
|
||||
}
|
||||
if (isOffScreenLeft || isOffScreenRight) {
|
||||
button.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[scrollTo]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
let isInitialLoad = true;
|
||||
|
||||
const onSelect = () => {
|
||||
const newIndex = emblaApi.selectedScrollSnap();
|
||||
setSelectedIndex(newIndex);
|
||||
|
||||
if (!isInitialLoad) {
|
||||
const videos = document.querySelectorAll(".gallery-slider__media");
|
||||
videos.forEach((video) => {
|
||||
if (video instanceof HTMLVideoElement) {
|
||||
video.pause();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isInitialLoad = false;
|
||||
};
|
||||
|
||||
emblaApi.on("select", onSelect);
|
||||
onSelect();
|
||||
|
||||
return () => {
|
||||
emblaApi.off("select", onSelect);
|
||||
};
|
||||
}, [emblaApi]);
|
||||
|
||||
const mediaItems = useMemo(() => {
|
||||
const items: Array<{
|
||||
id: string;
|
||||
type: "video" | "image";
|
||||
src?: string;
|
||||
poster?: string;
|
||||
videoSrc?: string;
|
||||
alt: string;
|
||||
}> = [];
|
||||
|
||||
if (shopDetails?.movies) {
|
||||
shopDetails.movies.forEach((video, index) => {
|
||||
items.push({
|
||||
id: String(video.id),
|
||||
type: "video",
|
||||
poster: video.thumbnail,
|
||||
videoSrc: video.mp4.max.startsWith("http://")
|
||||
? video.mp4.max.replace("http://", "https://")
|
||||
: video.mp4.max,
|
||||
alt: t("video", { number: String(index + 1) }),
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [hasMovies, mediaContainerRef, mediaIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
const totalWidth = container.scrollWidth - container.clientWidth;
|
||||
const itemWidth = totalWidth / (mediaCount - 1);
|
||||
const scrollLeft = mediaIndex * itemWidth;
|
||||
container.scrollLeft = scrollLeft;
|
||||
if (shopDetails?.screenshots) {
|
||||
shopDetails.screenshots.forEach((image, index) => {
|
||||
items.push({
|
||||
id: String(image.id),
|
||||
type: "image",
|
||||
src: image.path_full,
|
||||
alt: t("screenshot", { number: String(index + 1) }),
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [shopDetails, mediaIndex, mediaCount]);
|
||||
|
||||
return items;
|
||||
}, [shopDetails, t]);
|
||||
|
||||
const previews = useMemo(() => {
|
||||
const screenshotPreviews =
|
||||
shopDetails?.screenshots?.map(({ id, path_thumbnail }) => ({
|
||||
id,
|
||||
thumbnail: path_thumbnail,
|
||||
type: "image" as const,
|
||||
})) ?? [];
|
||||
|
||||
if (shopDetails?.movies) {
|
||||
const moviePreviews = shopDetails.movies.map(({ id, thumbnail }) => ({
|
||||
id,
|
||||
thumbnail,
|
||||
type: "video" as const,
|
||||
}));
|
||||
|
||||
return [...moviePreviews, ...screenshotPreviews];
|
||||
@@ -93,96 +147,87 @@ export function GallerySlider() {
|
||||
return screenshotPreviews;
|
||||
}, [shopDetails]);
|
||||
|
||||
if (!hasScreenshots) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasScreenshots && (
|
||||
<div className="gallery-slider__container">
|
||||
<div
|
||||
onMouseEnter={() => setShowArrows(true)}
|
||||
onMouseLeave={() => setShowArrows(false)}
|
||||
className="gallery-slider__animation-container"
|
||||
ref={mediaContainerRef}
|
||||
>
|
||||
{shopDetails.movies &&
|
||||
shopDetails.movies.map((video) => (
|
||||
<div className="gallery-slider__container">
|
||||
<div className="gallery-slider__viewport" ref={emblaRef}>
|
||||
<div className="gallery-slider__container-inner">
|
||||
{mediaItems.map((item) => (
|
||||
<div key={item.id} className="gallery-slider__slide">
|
||||
{item.type === "video" ? (
|
||||
<video
|
||||
key={video.id}
|
||||
controls
|
||||
className="gallery-slider__media"
|
||||
poster={video.thumbnail}
|
||||
style={{ translate: `${-100 * mediaIndex}%` }}
|
||||
poster={item.poster}
|
||||
loop
|
||||
muted
|
||||
autoPlay
|
||||
tabIndex={-1}
|
||||
>
|
||||
<source src={video.mp4.max.replace("http", "https")} />
|
||||
<source src={item.videoSrc} />
|
||||
</video>
|
||||
))}
|
||||
|
||||
{hasScreenshots &&
|
||||
shopDetails.screenshots?.map((image, i) => (
|
||||
) : (
|
||||
<img
|
||||
key={image.id}
|
||||
className="gallery-slider__media"
|
||||
src={image.path_full}
|
||||
style={{ translate: `${-100 * mediaIndex}%` }}
|
||||
alt={t("screenshot", { number: i + 1 })}
|
||||
src={item.src}
|
||||
alt={item.alt}
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={showPrevImage}
|
||||
type="button"
|
||||
className={`gallery-slider__button gallery-slider__button--left ${
|
||||
showArrows
|
||||
? "gallery-slider__button--visible"
|
||||
: "gallery-slider__button--hidden"
|
||||
}`}
|
||||
aria-label={t("previous_screenshot")}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ChevronLeftIcon size={36} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={showNextImage}
|
||||
type="button"
|
||||
className={`gallery-slider__button gallery-slider__button--right ${
|
||||
showArrows
|
||||
? "gallery-slider__button--visible"
|
||||
: "gallery-slider__button--hidden"
|
||||
}`}
|
||||
aria-label={t("next_screenshot")}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ChevronRightIcon size={36} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="gallery-slider__preview" ref={scrollContainerRef}>
|
||||
{previews.map((media, i) => (
|
||||
<button
|
||||
key={media.id}
|
||||
type="button"
|
||||
className={`gallery-slider__preview-button ${
|
||||
mediaIndex === i
|
||||
? "gallery-slider__preview-button--active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setMediaIndex(i)}
|
||||
aria-label={t("open_screenshot", { number: i + 1 })}
|
||||
>
|
||||
<img
|
||||
src={media.thumbnail}
|
||||
className="gallery-slider__preview-image"
|
||||
alt={t("screenshot", { number: i + 1 })}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
<button
|
||||
onClick={scrollPrev}
|
||||
type="button"
|
||||
className="gallery-slider__button gallery-slider__button--left"
|
||||
aria-label={t("previous_screenshot")}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ChevronLeftIcon size={36} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={scrollNext}
|
||||
type="button"
|
||||
className="gallery-slider__button gallery-slider__button--right"
|
||||
aria-label={t("next_screenshot")}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ChevronRightIcon size={36} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="gallery-slider__preview">
|
||||
{previews.map((media, i) => (
|
||||
<button
|
||||
key={media.id}
|
||||
type="button"
|
||||
className={`gallery-slider__preview-button ${
|
||||
selectedIndex === i
|
||||
? "gallery-slider__preview-button--active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={(e) => scrollToPreview(i, e)}
|
||||
aria-label={t("open_screenshot", { number: String(i + 1) })}
|
||||
>
|
||||
<img
|
||||
src={media.thumbnail}
|
||||
className="gallery-slider__preview-image"
|
||||
alt={t("screenshot", { number: String(i + 1) })}
|
||||
/>
|
||||
{media.type === "video" && (
|
||||
<div className="gallery-slider__play-overlay">
|
||||
<PlayIcon size={20} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,12 +183,10 @@ export function GameDetailsContent() {
|
||||
src={heroImage}
|
||||
className="game-details__hero-image"
|
||||
alt={game?.title}
|
||||
onLoad={handleHeroLoad}
|
||||
/>
|
||||
<div
|
||||
className="game-details__hero-backdrop"
|
||||
style={{
|
||||
backgroundColor: gameColor,
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,6 @@ $hero-height: 300px;
|
||||
&__wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: all ease 0.3s;
|
||||
@@ -101,8 +100,8 @@ $hero-height: 300px;
|
||||
|
||||
&__hero-image {
|
||||
width: 100%;
|
||||
height: $hero-height;
|
||||
min-height: $hero-height;
|
||||
height: calc($hero-height + 72px);
|
||||
min-height: calc($hero-height + 72px);
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
transition: all ease 0.2s;
|
||||
@@ -111,8 +110,8 @@ $hero-height: 300px;
|
||||
|
||||
@media (min-width: 1250px) {
|
||||
object-position: center;
|
||||
height: 350px;
|
||||
min-height: 350px;
|
||||
height: calc(350px + 72px);
|
||||
min-height: calc(350px + 72px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +147,6 @@ $hero-height: 300px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -156,6 +154,7 @@ $hero-height: 300px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
globals.$background-color 50%,
|
||||
@@ -166,6 +165,8 @@ $hero-height: 300px;
|
||||
&__description-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__description {
|
||||
|
||||
@@ -3,11 +3,18 @@ import {
|
||||
GearIcon,
|
||||
HeartFillIcon,
|
||||
HeartIcon,
|
||||
PinIcon,
|
||||
PinSlashIcon,
|
||||
PlayIcon,
|
||||
PlusCircleIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { Button } from "@renderer/components";
|
||||
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
|
||||
import {
|
||||
useDownload,
|
||||
useLibrary,
|
||||
useToast,
|
||||
useUserDetails,
|
||||
} from "@renderer/hooks";
|
||||
import { useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { gameDetailsContext } from "@renderer/context";
|
||||
@@ -19,6 +26,7 @@ export function HeroPanelActions() {
|
||||
useState(false);
|
||||
|
||||
const { isGameDeleting } = useDownload();
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const {
|
||||
game,
|
||||
@@ -82,6 +90,29 @@ export function HeroPanelActions() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGamePinned = async () => {
|
||||
setToggleLibraryGameDisabled(true);
|
||||
|
||||
try {
|
||||
if (game?.isPinned && objectId) {
|
||||
await window.electron.toggleGamePin(shop, objectId, false).then(() => {
|
||||
showSuccessToast(t("game_removed_from_pinned"));
|
||||
});
|
||||
} else {
|
||||
if (!objectId) return;
|
||||
|
||||
await window.electron.toggleGamePin(shop, objectId, true).then(() => {
|
||||
showSuccessToast(t("game_added_to_pinned"));
|
||||
});
|
||||
}
|
||||
|
||||
updateLibrary();
|
||||
updateGame();
|
||||
} finally {
|
||||
setToggleLibraryGameDisabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openGame = async () => {
|
||||
if (game) {
|
||||
if (game.executablePath) {
|
||||
@@ -198,6 +229,17 @@ export function HeroPanelActions() {
|
||||
{game.favorite ? <HeartFillIcon /> : <HeartIcon />}
|
||||
</Button>
|
||||
|
||||
{userDetails && (
|
||||
<Button
|
||||
onClick={toggleGamePinned}
|
||||
theme="outline"
|
||||
disabled={deleting}
|
||||
className="hero-panel-actions__action"
|
||||
>
|
||||
{game.isPinned ? <PinSlashIcon /> : <PinIcon />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => setShowGameOptionsModal(true)}
|
||||
theme="outline"
|
||||
|
||||
@@ -5,18 +5,24 @@
|
||||
height: 72px;
|
||||
min-height: 72px;
|
||||
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
|
||||
background-color: globals.$dark-background-color;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: solid 1px rgba(255, 255, 255, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all ease 0.2s;
|
||||
border-bottom: solid 1px globals.$border-color;
|
||||
position: sticky;
|
||||
overflow: hidden;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&--stuck {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export function HeroPanel() {
|
||||
|
||||
const { formatDate } = useDate();
|
||||
|
||||
const { game, repacks, gameColor } = useContext(gameDetailsContext);
|
||||
const { game, repacks } = useContext(gameDetailsContext);
|
||||
|
||||
const { lastPacket } = useDownload();
|
||||
|
||||
@@ -50,7 +50,7 @@ export function HeroPanel() {
|
||||
game?.download?.status === "paused";
|
||||
|
||||
return (
|
||||
<div style={{ backgroundColor: gameColor }} className="hero-panel">
|
||||
<div className="hero-panel">
|
||||
<div className="hero-panel__content">{getInfo()}</div>
|
||||
<div className="hero-panel__actions">
|
||||
<HeroPanelActions />
|
||||
|
||||
@@ -72,7 +72,6 @@ export function ChangeGamePlaytimeModal({
|
||||
onSuccess?.(t("update_playtime_success"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
onError?.(t("update_playtime_error"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
.sidebar-section {
|
||||
&__button {
|
||||
height: 72px;
|
||||
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2);
|
||||
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: globals.$background-color;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.game-language-section {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid globals.$border-color;
|
||||
}
|
||||
|
||||
&__header-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: globals.$muted-color;
|
||||
font-size: globals.$small-font-size;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2.5);
|
||||
|
||||
&--center {
|
||||
justify-content: flex-start;
|
||||
flex: 0 0 60px;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
transition: background-color 0.2s ease;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__cell {
|
||||
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2.5);
|
||||
font-size: globals.$body-font-size;
|
||||
color: globals.$body-color;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
|
||||
&--language {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&--center {
|
||||
justify-content: flex-start;
|
||||
flex: 0 0 60px;
|
||||
}
|
||||
}
|
||||
|
||||
&__check {
|
||||
color: globals.$body-color;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&__cross {
|
||||
color: globals.$body-color;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
&__header,
|
||||
&__cell {
|
||||
padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 0.5);
|
||||
font-size: calc(globals.$small-font-size * 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
72
src/renderer/src/pages/game-details/sidebar/game-language-section.tsx
Executable file
72
src/renderer/src/pages/game-details/sidebar/game-language-section.tsx
Executable file
@@ -0,0 +1,72 @@
|
||||
import { useContext, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckIcon, XIcon } from "@primer/octicons-react";
|
||||
import { gameDetailsContext } from "@renderer/context/game-details/game-details.context";
|
||||
import { SidebarSection } from "../sidebar-section/sidebar-section";
|
||||
import "./game-language-section.scss";
|
||||
|
||||
export function GameLanguageSection() {
|
||||
const { t } = useTranslation("game_details");
|
||||
const { shopDetails } = useContext(gameDetailsContext);
|
||||
|
||||
const languages = useMemo(() => {
|
||||
const supportedLanguages = shopDetails?.supported_languages;
|
||||
if (!supportedLanguages) return [];
|
||||
|
||||
const languagesString = supportedLanguages.split("<br>")[0];
|
||||
const languageArray = languagesString?.split(",") || [];
|
||||
|
||||
return languageArray.map((lang) => ({
|
||||
language: lang.replace("<strong>*</strong>", "").trim(),
|
||||
hasAudio: lang.includes("*"),
|
||||
}));
|
||||
}, [shopDetails?.supported_languages]);
|
||||
|
||||
if (languages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarSection title={t("language")}>
|
||||
<div className="game-language-section">
|
||||
<div className="game-language-section__header">
|
||||
<div className="game-language-section__header-item">
|
||||
<span>{t("language")}</span>
|
||||
</div>
|
||||
<div className="game-language-section__header-item game-language-section__header-item--center">
|
||||
<span>{t("caption")}</span>
|
||||
</div>
|
||||
<div className="game-language-section__header-item game-language-section__header-item--center">
|
||||
<span>{t("audio")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="game-language-section__content">
|
||||
{languages.map((lang) => (
|
||||
<div key={lang.language} className="game-language-section__row">
|
||||
<div
|
||||
className="game-language-section__cell game-language-section__cell--language"
|
||||
title={lang.language}
|
||||
>
|
||||
{lang.language}
|
||||
</div>
|
||||
<div className="game-language-section__cell game-language-section__cell--center">
|
||||
<CheckIcon size={14} className="game-language-section__check" />
|
||||
</div>
|
||||
<div className="game-language-section__cell game-language-section__cell--center">
|
||||
{lang.hasAudio ? (
|
||||
<CheckIcon
|
||||
size={14}
|
||||
className="game-language-section__check"
|
||||
/>
|
||||
) : (
|
||||
<XIcon size={14} className="game-language-section__cross" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
23
src/renderer/src/pages/game-details/sidebar/sidebar.scss
Normal file → Executable file
23
src/renderer/src/pages/game-details/sidebar/sidebar.scss
Normal file → Executable file
@@ -3,17 +3,30 @@
|
||||
.content-sidebar {
|
||||
border-left: solid 1px globals.$border-color;
|
||||
background-color: globals.$dark-background-color;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
width: 420px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 35%;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 40%;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
3
src/renderer/src/pages/game-details/sidebar/sidebar.tsx
Normal file → Executable file
3
src/renderer/src/pages/game-details/sidebar/sidebar.tsx
Normal file → Executable file
@@ -21,6 +21,7 @@ import { SidebarSection } from "../sidebar-section/sidebar-section";
|
||||
import { buildGameAchievementPath } from "@renderer/helpers";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
import "./sidebar.scss";
|
||||
import { GameLanguageSection } from "./game-language-section";
|
||||
|
||||
const achievementsPlaceholder: UserAchievement[] = [
|
||||
{
|
||||
@@ -263,6 +264,8 @@ export function Sidebar() {
|
||||
}}
|
||||
/>
|
||||
</SidebarSection>
|
||||
|
||||
<GameLanguageSection />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 3);
|
||||
padding: calc(globals.$spacing-unit * 3);
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
gap: globals.$spacing-unit;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: calc(globals.$spacing-unit * 3);
|
||||
}
|
||||
|
||||
&__buttons-list {
|
||||
@@ -27,25 +28,6 @@
|
||||
gap: globals.$spacing-unit;
|
||||
}
|
||||
|
||||
&__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
transition: all ease 0.2s;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1250px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&__card-skeleton {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
@@ -99,5 +81,26 @@
|
||||
&__title {
|
||||
display: flex;
|
||||
gap: globals.$spacing-unit;
|
||||
padding: 0 calc(globals.$spacing-unit * 3);
|
||||
}
|
||||
|
||||
&__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
gap: calc(globals.$spacing-unit * 2);
|
||||
transition: all ease 0.2s;
|
||||
padding: 0 calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 3);
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1250px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,8 +97,6 @@ export default function Home() {
|
||||
return (
|
||||
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
|
||||
<section className="home__content">
|
||||
<h2>{t("featured")}</h2>
|
||||
|
||||
<Hero />
|
||||
|
||||
<section className="home__header">
|
||||
|
||||
@@ -31,10 +31,14 @@ export function FriendsBox() {
|
||||
return (
|
||||
<div>
|
||||
<div className="friends-box__section-header">
|
||||
<h2>{t("friends")}</h2>
|
||||
{userStats && (
|
||||
<span>{numberFormatter.format(userStats.friendsCount)}</span>
|
||||
)}
|
||||
<div className="profile-content__section-title-group">
|
||||
<h2>{t("friends")}</h2>
|
||||
{userStats && (
|
||||
<span className="profile-content__section-badge">
|
||||
{numberFormatter.format(userStats.friendsCount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="friends-box__box">
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
export const sectionVariants = {
|
||||
collapsed: {
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
height: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
opacity: { duration: 0.1 },
|
||||
y: { duration: 0.1 },
|
||||
height: { duration: 0.2 },
|
||||
},
|
||||
},
|
||||
expanded: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
height: "auto",
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
opacity: { duration: 0.2, delay: 0.1 },
|
||||
y: { duration: 0.3 },
|
||||
height: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const gameCardVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
scale: 0.95,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
scale: 0.95,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const gameGridVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const chevronVariants = {
|
||||
collapsed: {
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
expanded: {
|
||||
rotate: 90,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;
|
||||
@@ -54,8 +54,74 @@
|
||||
&__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
gap: calc(globals.$spacing-unit);
|
||||
}
|
||||
|
||||
&__section-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__section-badge {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__collapse-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all ease 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&__tab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
padding: calc(globals.$spacing-unit) calc(globals.$spacing-unit * 2);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: white;
|
||||
border-bottom-color: #c9aa71;
|
||||
}
|
||||
}
|
||||
|
||||
&__games-grid {
|
||||
@@ -85,5 +151,29 @@
|
||||
@container #{globals.$app-container} (min-width: 3000px) {
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
}
|
||||
|
||||
&--drag-over {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 2px dashed rgba(255, 255, 255, 0.3);
|
||||
position: relative;
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&::before {
|
||||
content: "Drop here to " attr(data-action);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: globals.$muted-color;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,23 +3,45 @@ import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ProfileHero } from "../profile-hero/profile-hero";
|
||||
import { useAppDispatch, useFormat } from "@renderer/hooks";
|
||||
import { setHeaderTitle } from "@renderer/features";
|
||||
import { TelescopeIcon } from "@primer/octicons-react";
|
||||
import { TelescopeIcon, ChevronRightIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserGame } from "@types";
|
||||
import { LockedProfile } from "./locked-profile";
|
||||
import { ReportProfile } from "../report-profile/report-profile";
|
||||
import { FriendsBox } from "./friends-box";
|
||||
import { RecentGamesBox } from "./recent-games-box";
|
||||
import { UserStatsBox } from "./user-stats-box";
|
||||
import { UserLibraryGameCard } from "./user-library-game-card";
|
||||
import { SortOptions } from "./sort-options";
|
||||
import { useSectionCollapse } from "@renderer/hooks/use-section-collapse";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
sectionVariants,
|
||||
gameCardVariants,
|
||||
gameGridVariants,
|
||||
chevronVariants,
|
||||
GAME_STATS_ANIMATION_DURATION_IN_MS,
|
||||
} from "./profile-animations";
|
||||
import "./profile-content.scss";
|
||||
|
||||
const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;
|
||||
type SortOption = "playtime" | "achievementCount" | "playedRecently";
|
||||
|
||||
export function ProfileContent() {
|
||||
const { userProfile, isMe, userStats } = useContext(userProfileContext);
|
||||
const {
|
||||
userProfile,
|
||||
isMe,
|
||||
userStats,
|
||||
libraryGames,
|
||||
pinnedGames,
|
||||
getUserLibraryGames,
|
||||
} = useContext(userProfileContext);
|
||||
const [statsIndex, setStatsIndex] = useState(0);
|
||||
const [isAnimationRunning, setIsAnimationRunning] = useState(true);
|
||||
const [sortBy, setSortBy] = useState<SortOption>("playedRecently");
|
||||
const [prevLibraryGames, setPrevLibraryGames] = useState<UserGame[]>([]);
|
||||
const [prevPinnedGames, setPrevPinnedGames] = useState<UserGame[]>([]);
|
||||
const statsAnimation = useRef(-1);
|
||||
const { toggleSection, isPinnedCollapsed } = useSectionCollapse();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -33,6 +55,12 @@ export function ProfileContent() {
|
||||
}
|
||||
}, [userProfile, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfile) {
|
||||
getUserLibraryGames(sortBy);
|
||||
}
|
||||
}, [sortBy, getUserLibraryGames, userProfile]);
|
||||
|
||||
const handleOnMouseEnterGameCard = () => {
|
||||
setIsAnimationRunning(false);
|
||||
};
|
||||
@@ -64,6 +92,27 @@ export function ProfileContent() {
|
||||
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const gamesHaveChanged = (
|
||||
current: UserGame[],
|
||||
previous: UserGame[]
|
||||
): boolean => {
|
||||
if (current.length !== previous.length) return true;
|
||||
return current.some(
|
||||
(game, index) => game.objectId !== previous[index]?.objectId
|
||||
);
|
||||
};
|
||||
|
||||
const shouldAnimateLibrary = gamesHaveChanged(libraryGames, prevLibraryGames);
|
||||
const shouldAnimatePinned = gamesHaveChanged(pinnedGames, prevPinnedGames);
|
||||
|
||||
useEffect(() => {
|
||||
setPrevLibraryGames(libraryGames);
|
||||
}, [libraryGames]);
|
||||
|
||||
useEffect(() => {
|
||||
setPrevPinnedGames(pinnedGames);
|
||||
}, [pinnedGames]);
|
||||
|
||||
const usersAreFriends = useMemo(() => {
|
||||
return userProfile?.relation?.status === "ACCEPTED";
|
||||
}, [userProfile]);
|
||||
@@ -79,14 +128,21 @@ export function ProfileContent() {
|
||||
return <LockedProfile />;
|
||||
}
|
||||
|
||||
const hasGames = userProfile?.libraryGames.length > 0;
|
||||
const hasGames = libraryGames.length > 0;
|
||||
const hasPinnedGames = pinnedGames.length > 0;
|
||||
const hasAnyGames = hasGames || hasPinnedGames;
|
||||
|
||||
const shouldShowRightContent = hasGames || userProfile.friends.length > 0;
|
||||
const shouldShowRightContent =
|
||||
hasAnyGames || userProfile.friends.length > 0;
|
||||
|
||||
return (
|
||||
<section className="profile-content__section">
|
||||
<div className="profile-content__main">
|
||||
{!hasGames && (
|
||||
{hasAnyGames && (
|
||||
<SortOptions sortBy={sortBy} onSortChange={setSortBy} />
|
||||
)}
|
||||
|
||||
{!hasAnyGames && (
|
||||
<div className="profile-content__no-games">
|
||||
<div className="profile-content__telescope-icon">
|
||||
<TelescopeIcon size={24} />
|
||||
@@ -96,28 +152,167 @@ export function ProfileContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasGames && (
|
||||
<>
|
||||
<div className="profile-content__section-header">
|
||||
<h2>{t("library")}</h2>
|
||||
{hasAnyGames && (
|
||||
<div>
|
||||
{hasPinnedGames && (
|
||||
<div style={{ marginBottom: "2rem" }}>
|
||||
<div className="profile-content__section-header">
|
||||
<div className="profile-content__section-title-group">
|
||||
<button
|
||||
type="button"
|
||||
className="profile-content__collapse-button"
|
||||
onClick={() => toggleSection("pinned")}
|
||||
aria-label={
|
||||
isPinnedCollapsed
|
||||
? "Expand pinned section"
|
||||
: "Collapse pinned section"
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
variants={chevronVariants}
|
||||
animate={isPinnedCollapsed ? "collapsed" : "expanded"}
|
||||
>
|
||||
<ChevronRightIcon size={16} />
|
||||
</motion.div>
|
||||
</button>
|
||||
<h2>{t("pinned")}</h2>
|
||||
<span className="profile-content__section-badge">
|
||||
{pinnedGames.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{userStats && (
|
||||
<span>{numberFormatter.format(userStats.libraryCount)}</span>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence initial={true} mode="wait">
|
||||
{!isPinnedCollapsed && (
|
||||
<motion.div
|
||||
key="pinned-content"
|
||||
variants={sectionVariants}
|
||||
initial="collapsed"
|
||||
animate="expanded"
|
||||
exit="collapsed"
|
||||
layout
|
||||
>
|
||||
<motion.ul
|
||||
className="profile-content__games-grid"
|
||||
variants={
|
||||
shouldAnimatePinned ? gameGridVariants : undefined
|
||||
}
|
||||
initial={shouldAnimatePinned ? "hidden" : undefined}
|
||||
animate={shouldAnimatePinned ? "visible" : undefined}
|
||||
exit={shouldAnimatePinned ? "exit" : undefined}
|
||||
key={
|
||||
shouldAnimatePinned
|
||||
? `pinned-${sortBy}`
|
||||
: `pinned-static`
|
||||
}
|
||||
>
|
||||
{shouldAnimatePinned ? (
|
||||
<AnimatePresence mode="wait">
|
||||
{pinnedGames?.map((game, index) => (
|
||||
<motion.li
|
||||
key={game.objectId}
|
||||
variants={gameCardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={{ delay: index * 0.1 }}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
pinnedGames?.map((game) => (
|
||||
<li
|
||||
key={game.objectId}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</motion.ul>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="profile-content__games-grid">
|
||||
{userProfile?.libraryGames?.map((game) => (
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
key={game.objectId}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
{hasGames && (
|
||||
<div>
|
||||
<div className="profile-content__section-header">
|
||||
<div className="profile-content__section-title-group">
|
||||
<h2>{t("library")}</h2>
|
||||
{userStats && (
|
||||
<span className="profile-content__section-badge">
|
||||
{numberFormatter.format(userStats.libraryCount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.ul
|
||||
className="profile-content__games-grid"
|
||||
variants={
|
||||
shouldAnimateLibrary ? gameGridVariants : undefined
|
||||
}
|
||||
initial={shouldAnimateLibrary ? "hidden" : undefined}
|
||||
animate={shouldAnimateLibrary ? "visible" : undefined}
|
||||
exit={shouldAnimateLibrary ? "exit" : undefined}
|
||||
key={
|
||||
shouldAnimateLibrary
|
||||
? `library-${sortBy}`
|
||||
: `library-static`
|
||||
}
|
||||
>
|
||||
{shouldAnimateLibrary ? (
|
||||
<AnimatePresence mode="wait">
|
||||
{libraryGames?.map((game, index) => (
|
||||
<motion.li
|
||||
key={game.objectId}
|
||||
variants={gameCardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={{ delay: index * 0.1 }}
|
||||
style={{ listStyle: "none" }}
|
||||
>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
) : (
|
||||
libraryGames?.map((game) => (
|
||||
<li key={game.objectId} style={{ listStyle: "none" }}>
|
||||
<UserLibraryGameCard
|
||||
game={game}
|
||||
statIndex={statsIndex}
|
||||
onMouseEnter={handleOnMouseEnterGameCard}
|
||||
onMouseLeave={handleOnMouseLeaveGameCard}
|
||||
/>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</motion.ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -139,6 +334,13 @@ export function ProfileContent() {
|
||||
numberFormatter,
|
||||
t,
|
||||
statsIndex,
|
||||
libraryGames,
|
||||
pinnedGames,
|
||||
isPinnedCollapsed,
|
||||
toggleSection,
|
||||
shouldAnimateLibrary,
|
||||
shouldAnimatePinned,
|
||||
sortBy,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
@use "../../../scss/globals.scss";
|
||||
|
||||
.sort-options {
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
}
|
||||
|
||||
&__label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&__options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&__option {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 300;
|
||||
transition: all ease 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&__separator {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { TrophyIcon, ClockIcon, HistoryIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "./sort-options.scss";
|
||||
|
||||
type SortOption = "playtime" | "achievementCount" | "playedRecently";
|
||||
|
||||
interface SortOptionsProps {
|
||||
sortBy: SortOption;
|
||||
onSortChange: (sortBy: SortOption) => void;
|
||||
}
|
||||
|
||||
export function SortOptions({ sortBy, onSortChange }: SortOptionsProps) {
|
||||
const { t } = useTranslation("user_profile");
|
||||
|
||||
return (
|
||||
<div className="sort-options__container">
|
||||
<span className="sort-options__label">Sort by:</span>
|
||||
<div className="sort-options__options">
|
||||
<button
|
||||
className={`sort-options__option ${sortBy === "achievementCount" ? "active" : ""}`}
|
||||
onClick={() => onSortChange("achievementCount")}
|
||||
>
|
||||
<TrophyIcon size={16} />
|
||||
<span>{t("achievements_earned")}</span>
|
||||
</button>
|
||||
<span className="sort-options__separator">|</span>
|
||||
<button
|
||||
className={`sort-options__option ${sortBy === "playedRecently" ? "active" : ""}`}
|
||||
onClick={() => onSortChange("playedRecently")}
|
||||
>
|
||||
<HistoryIcon size={16} />
|
||||
<span>{t("played_recently")}</span>
|
||||
</button>
|
||||
<span className="sort-options__separator">|</span>
|
||||
<button
|
||||
className={`sort-options__option ${sortBy === "playtime" ? "active" : ""}`}
|
||||
onClick={() => onSortChange("playtime")}
|
||||
>
|
||||
<ClockIcon size={16} />
|
||||
<span>{t("playtime")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,26 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
transition: all ease 0.2s;
|
||||
cursor: grab;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
&[draggable="true"] {
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
opacity: 0.8;
|
||||
transform: scale(1.02) rotate(2deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__cover {
|
||||
@@ -65,15 +81,85 @@
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
&__actions-container {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__favorite-icon {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: solid 1px rgba(255, 255, 255, 0.15);
|
||||
border-radius: 50%;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&__pin-button {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: solid 1px rgba(255, 255, 255, 0.15);
|
||||
border-radius: 50%;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&__playtime {
|
||||
background-color: globals.$background-color;
|
||||
color: globals.$muted-color;
|
||||
border: solid 1px globals.$border-color;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border: solid 1px rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: all ease 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
&__manual-playtime {
|
||||
color: globals.$warning-color;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UserGame } from "@types";
|
||||
import HydraIcon from "@renderer/assets/icons/hydra.svg?react";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { useFormat, useToast } from "@renderer/hooks";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCallback, useContext, useState } from "react";
|
||||
import {
|
||||
@@ -9,7 +9,14 @@ import {
|
||||
formatDownloadProgress,
|
||||
} from "@renderer/helpers";
|
||||
import { userProfileContext } from "@renderer/context";
|
||||
import { ClockIcon, TrophyIcon, AlertFillIcon } from "@primer/octicons-react";
|
||||
import {
|
||||
ClockIcon,
|
||||
TrophyIcon,
|
||||
AlertFillIcon,
|
||||
HeartFillIcon,
|
||||
PinIcon,
|
||||
PinSlashIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -28,11 +35,14 @@ export function UserLibraryGameCard({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: UserLibraryGameCardProps) {
|
||||
const { userProfile } = useContext(userProfileContext);
|
||||
const { userProfile, isMe, getUserLibraryGames } =
|
||||
useContext(userProfileContext);
|
||||
const { t } = useTranslation("user_profile");
|
||||
const { numberFormatter } = useFormat();
|
||||
const { showSuccessToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [isTooltipHovered, setIsTooltipHovered] = useState(false);
|
||||
const [isPinning, setIsPinning] = useState(false);
|
||||
|
||||
const getStatsItemCount = useCallback(() => {
|
||||
let statsCount = 1;
|
||||
@@ -84,6 +94,28 @@ export function UserLibraryGameCard({
|
||||
[numberFormatter, t]
|
||||
);
|
||||
|
||||
const toggleGamePinned = async () => {
|
||||
setIsPinning(true);
|
||||
|
||||
try {
|
||||
await window.electron.toggleGamePin(
|
||||
game.shop,
|
||||
game.objectId,
|
||||
!game.isPinned
|
||||
);
|
||||
|
||||
await getUserLibraryGames();
|
||||
|
||||
if (game.isPinned) {
|
||||
showSuccessToast(t("game_removed_from_pinned"));
|
||||
} else {
|
||||
showSuccessToast(t("game_added_to_pinned"));
|
||||
}
|
||||
} finally {
|
||||
setIsPinning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<li
|
||||
@@ -98,6 +130,32 @@ export function UserLibraryGameCard({
|
||||
onClick={() => navigate(buildUserGameDetailsPath(game))}
|
||||
>
|
||||
<div className="user-library-game__overlay">
|
||||
{(game.isFavorite || isMe) && (
|
||||
<div className="user-library-game__actions-container">
|
||||
{game.isFavorite && (
|
||||
<div className="user-library-game__favorite-icon">
|
||||
<HeartFillIcon size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isMe && (
|
||||
<button
|
||||
type="button"
|
||||
className="user-library-game__pin-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleGamePinned();
|
||||
}}
|
||||
disabled={isPinning}
|
||||
>
|
||||
{game.isPinned ? (
|
||||
<PinSlashIcon size={12} />
|
||||
) : (
|
||||
<PinIcon size={12} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<small
|
||||
className="user-library-game__playtime"
|
||||
data-tooltip-place="top"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
$background-color: #1c1c1c;
|
||||
$dark-background-color: #151515;
|
||||
$background-color: #121212;
|
||||
$dark-background-color: #0d0d0d;
|
||||
|
||||
$muted-color: #c0c1c7;
|
||||
$body-color: #8e919b;
|
||||
$muted-color: #f0f1f7;
|
||||
$body-color: #d0d1d7;
|
||||
|
||||
$border-color: rgba(255, 255, 255, 0.15);
|
||||
$border-color: rgba(255, 255, 255, 0.08);
|
||||
$success-color: #1c9749;
|
||||
$danger-color: #801d1e;
|
||||
$error-color: #e11d48;
|
||||
|
||||
@@ -71,8 +71,17 @@ export type UserGame = {
|
||||
achievementCount: number;
|
||||
achievementsPointsEarnedSum: number;
|
||||
hasManuallyUpdatedPlaytime: boolean;
|
||||
isFavorite: boolean;
|
||||
isPinned: boolean;
|
||||
pinnedDate?: Date | null;
|
||||
} & ShopAssets;
|
||||
|
||||
export interface UserLibraryResponse {
|
||||
totalCount: number;
|
||||
library: UserGame[];
|
||||
pinnedGames: UserGame[];
|
||||
}
|
||||
|
||||
export interface GameRunning {
|
||||
id: string;
|
||||
title: string;
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface Game {
|
||||
executablePath?: string | null;
|
||||
launchOptions?: string | null;
|
||||
favorite?: boolean;
|
||||
isPinned?: boolean;
|
||||
pinnedDate?: Date | null;
|
||||
automaticCloudSync?: boolean;
|
||||
hasManuallyUpdatedPlaytime?: boolean;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface SteamAppDetails {
|
||||
publishers: string[];
|
||||
genres: SteamGenre[];
|
||||
movies?: SteamMovies[];
|
||||
supported_languages: string;
|
||||
screenshots?: SteamScreenshot[];
|
||||
pc_requirements: {
|
||||
minimum: string;
|
||||
|
||||
86
yarn.lock
86
yarn.lock
@@ -912,6 +912,11 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.9.2":
|
||||
version "7.28.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
|
||||
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
|
||||
|
||||
"@babel/template@^7.22.15", "@babel/template@^7.24.0":
|
||||
version "7.24.0"
|
||||
resolved "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz"
|
||||
@@ -2043,6 +2048,21 @@
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438"
|
||||
integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==
|
||||
|
||||
"@react-dnd/asap@^5.0.1":
|
||||
version "5.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488"
|
||||
integrity sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==
|
||||
|
||||
"@react-dnd/invariant@^4.0.1":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-4.0.2.tgz#b92edffca10a26466643349fac7cdfb8799769df"
|
||||
integrity sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==
|
||||
|
||||
"@react-dnd/shallowequal@^4.0.1":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz#d1b4befa423f692fa4abf1c79209702e7d8ae4b4"
|
||||
integrity sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==
|
||||
|
||||
"@reduxjs/toolkit@^2.2.3":
|
||||
version "2.2.5"
|
||||
resolved "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz"
|
||||
@@ -4451,6 +4471,15 @@ dmg-license@^1.0.11:
|
||||
smart-buffer "^4.0.2"
|
||||
verror "^1.10.0"
|
||||
|
||||
dnd-core@^16.0.1:
|
||||
version "16.0.1"
|
||||
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-16.0.1.tgz#a1c213ed08961f6bd1959a28bb76f1a868360d19"
|
||||
integrity sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==
|
||||
dependencies:
|
||||
"@react-dnd/asap" "^5.0.1"
|
||||
"@react-dnd/invariant" "^4.0.1"
|
||||
redux "^4.2.0"
|
||||
|
||||
doctrine@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
|
||||
@@ -4609,6 +4638,29 @@ electron@^32.3.3:
|
||||
"@types/node" "^20.9.0"
|
||||
extract-zip "^2.0.1"
|
||||
|
||||
embla-carousel-autoplay@^8.6.0:
|
||||
version "8.6.0"
|
||||
resolved "https://registry.yarnpkg.com/embla-carousel-autoplay/-/embla-carousel-autoplay-8.6.0.tgz#bc86c97de00d52ec34b05058736ef50af6e0d0e4"
|
||||
integrity sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==
|
||||
|
||||
embla-carousel-react@^8.6.0:
|
||||
version "8.6.0"
|
||||
resolved "https://registry.yarnpkg.com/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz#b737042a32761c38d6614593653b3ac619477bd1"
|
||||
integrity sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==
|
||||
dependencies:
|
||||
embla-carousel "8.6.0"
|
||||
embla-carousel-reactive-utils "8.6.0"
|
||||
|
||||
embla-carousel-reactive-utils@8.6.0:
|
||||
version "8.6.0"
|
||||
resolved "https://registry.yarnpkg.com/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz#607f1d8ab9921c906a555c206251b2c6db687223"
|
||||
integrity sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==
|
||||
|
||||
embla-carousel@8.6.0:
|
||||
version "8.6.0"
|
||||
resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-8.6.0.tgz#abcedff2bff36992ea8ac27cd30080ca5b6a3f58"
|
||||
integrity sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==
|
||||
|
||||
emoji-regex@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
|
||||
@@ -5735,6 +5787,13 @@ hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2:
|
||||
dependencies:
|
||||
function-bind "^1.1.2"
|
||||
|
||||
hoist-non-react-statics@^3.3.2:
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
|
||||
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
|
||||
dependencies:
|
||||
react-is "^16.7.0"
|
||||
|
||||
hosted-git-info@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224"
|
||||
@@ -7506,6 +7565,24 @@ rc-virtual-list@^3.18.3:
|
||||
rc-resize-observer "^1.0.0"
|
||||
rc-util "^5.36.0"
|
||||
|
||||
react-dnd-html5-backend@^16.0.1:
|
||||
version "16.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz#87faef15845d512a23b3c08d29ecfd34871688b6"
|
||||
integrity sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==
|
||||
dependencies:
|
||||
dnd-core "^16.0.1"
|
||||
|
||||
react-dnd@^16.0.1:
|
||||
version "16.0.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-16.0.1.tgz#2442a3ec67892c60d40a1559eef45498ba26fa37"
|
||||
integrity sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==
|
||||
dependencies:
|
||||
"@react-dnd/invariant" "^4.0.1"
|
||||
"@react-dnd/shallowequal" "^4.0.1"
|
||||
dnd-core "^16.0.1"
|
||||
fast-deep-equal "^3.1.3"
|
||||
hoist-non-react-statics "^3.3.2"
|
||||
|
||||
react-dom@^18.2.0:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
|
||||
@@ -7527,7 +7604,7 @@ react-i18next@^14.1.0:
|
||||
"@babel/runtime" "^7.23.9"
|
||||
html-parse-stringify "^3.0.1"
|
||||
|
||||
react-is@^16.13.1:
|
||||
react-is@^16.13.1, react-is@^16.7.0:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
@@ -7641,6 +7718,13 @@ redux-thunk@^3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3"
|
||||
integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==
|
||||
|
||||
redux@^4.2.0:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197"
|
||||
integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.9.2"
|
||||
|
||||
redux@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b"
|
||||
|
||||
Reference in New Issue
Block a user