Merge branch 'main' into feat/library

This commit is contained in:
Chubby Granny Chaser
2025-11-02 20:23:44 +00:00
committed by GitHub
108 changed files with 3445 additions and 2861 deletions

View File

@@ -7,7 +7,6 @@ import {
useAppSelector,
useDownload,
useLibrary,
useRepacks,
useToast,
useUserDetails,
} from "@renderer/hooks";
@@ -20,7 +19,6 @@ import {
setUserDetails,
setProfileBackground,
setGameRunning,
setIsImportingSources,
} from "@renderer/features";
import { useTranslation } from "react-i18next";
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
@@ -40,8 +38,6 @@ export function App() {
const { t } = useTranslation("app");
const { updateRepacks } = useRepacks();
const { clearDownload, setLastPacket } = useDownload();
const {
@@ -199,36 +195,6 @@ export function App() {
});
}, [dispatch, draggingDisabled]);
useEffect(() => {
(async () => {
dispatch(setIsImportingSources(true));
try {
// Initial repacks load
await updateRepacks();
// Sync all local sources (check for updates)
const newRepacksCount = await window.electron.syncDownloadSources();
if (newRepacksCount > 0) {
window.electron.publishNewRepacksNotification(newRepacksCount);
}
// Update fingerprints for sources that don't have them
await window.electron.updateMissingFingerprints();
// Update repacks AFTER all syncing and fingerprint updates are complete
await updateRepacks();
} catch (error) {
console.error("Error syncing download sources:", error);
// Still update repacks even if sync fails
await updateRepacks();
} finally {
dispatch(setIsImportingSources(false));
}
})();
}, [updateRepacks, dispatch]);
const loadAndApplyTheme = useCallback(async () => {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.code) {

View File

@@ -1,5 +1,5 @@
import { DownloadIcon, PeopleIcon } from "@primer/octicons-react";
import type { GameStats } from "@types";
import type { GameStats, ShopAssets } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg?react";
@@ -8,15 +8,15 @@ import "./game-card.scss";
import { useTranslation } from "react-i18next";
import { Badge } from "../badge/badge";
import { StarRating } from "../star-rating/star-rating";
import { useCallback, useState, useMemo } from "react";
import { useFormat, useRepacks } from "@renderer/hooks";
import { useCallback, useState } from "react";
import { useFormat } from "@renderer/hooks";
export interface GameCardProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> {
game: any;
game: ShopAssets;
}
const shopIcon = {
@@ -28,13 +28,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const [stats, setStats] = useState<GameStats | null>(null);
const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(game.objectId);
const uniqueRepackers = Array.from(
new Set(repacks.map((repack) => repack.repacker))
);
const handleHover = useCallback(() => {
if (!stats) {
window.electron.getGameStats(game.objectId, game.shop).then((stats) => {
@@ -45,15 +38,6 @@ export function GameCard({ game, ...props }: GameCardProps) {
const { numberFormatter } = useFormat();
const firstThreeRepackers = useMemo(
() => uniqueRepackers.slice(0, 3),
[uniqueRepackers]
);
const remainingCount = useMemo(
() => uniqueRepackers.length - 3,
[uniqueRepackers]
);
return (
<button
{...props}
@@ -75,18 +59,20 @@ export function GameCard({ game, ...props }: GameCardProps) {
<p className="game-card__title">{game.title}</p>
</div>
{uniqueRepackers.length > 0 ? (
{game.downloadSources.length > 0 ? (
<ul className="game-card__download-options">
{firstThreeRepackers.map((repacker) => (
<li key={repacker}>
<Badge>{repacker}</Badge>
{game.downloadSources.slice(0, 3).map((sourceName) => (
<li key={sourceName}>
<Badge>{sourceName}</Badge>
</li>
))}
{remainingCount > 0 && (
{game.downloadSources.length > 3 && (
<li>
<Badge>
+{remainingCount}{" "}
{t("game_card:available", { count: remainingCount })}
+{game.downloadSources.length - 3}{" "}
{t("game_card:available", {
count: game.downloadSources.length - 3,
})}
</Badge>
</li>
)}

View File

@@ -63,7 +63,7 @@ export function Header() {
};
const handleSearch = (value: string) => {
dispatch(setFilters({ title: value }));
dispatch(setFilters({ title: value.slice(0, 255) }));
if (!location.pathname.startsWith("/catalogue")) {
navigate("/catalogue");

View File

@@ -1,9 +1,7 @@
import React, { useId, useMemo, useState } from "react";
import React, { useId, useState } from "react";
import { EyeClosedIcon, EyeIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import cn from "classnames";
import "./text-field.scss";
export interface TextFieldProps
@@ -42,44 +40,30 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
) => {
const id = useId();
const [isFocused, setIsFocused] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const { t } = useTranslation("forms");
const showPasswordToggleButton = props.type === "password";
const inputType = useMemo(() => {
if (props.type === "password" && isPasswordVisible) return "text";
return props.type ?? "text";
}, [props.type, isPasswordVisible]);
const hintContent = useMemo(() => {
if (error)
return (
<small className="text-field-container__error-label">{error}</small>
);
if (hint) return <small>{hint}</small>;
return null;
}, [hint, error]);
const inputType =
props.type === "password" && isPasswordVisible
? "text"
: (props.type ?? "text");
const hintContent = error ? (
<small className="text-field-container__error-label">{error}</small>
) : hint ? (
<small>{hint}</small>
) : null;
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(true);
if (props.onFocus) props.onFocus(event);
props.onFocus?.(event);
};
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (event) => {
setIsFocused(false);
if (props.onBlur) props.onBlur(event);
props.onBlur?.(event);
};
const hasError = !!error;
return (
<div className="text-field-container" {...containerProps}>
{label && <label htmlFor={id}>{label}</label>}
<div className="text-field-container__text-field-wrapper">
<div
className={cn(
@@ -104,7 +88,6 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
onBlur={handleBlur}
type={inputType}
/>
{showPasswordToggleButton && (
<button
type="button"
@@ -120,14 +103,11 @@ export const TextField = React.forwardRef<HTMLInputElement, TextFieldProps>(
</button>
)}
</div>
{rightContent}
</div>
{hintContent}
</div>
);
}
);
TextField.displayName = "TextField";

View File

@@ -98,6 +98,11 @@ export function CloudSyncContextProvider({
);
const getGameArtifacts = useCallback(async () => {
if (shop === "custom") {
setArtifacts([]);
return;
}
const params = new URLSearchParams({
objectId,
shop,

View File

@@ -1,11 +1,4 @@
import {
createContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { createContext, useCallback, useEffect, useRef, useState } from "react";
import { setHeaderTitle } from "@renderer/features";
import { getSteamLanguage } from "@renderer/helpers";
@@ -13,11 +6,11 @@ import {
useAppDispatch,
useAppSelector,
useDownload,
useRepacks,
useUserDetails,
} from "@renderer/hooks";
import type {
GameRepack,
GameShop,
GameStats,
LibraryGame,
@@ -84,12 +77,7 @@ export function GameDetailsContextProvider({
const [isGameRunning, setIsGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
const { getRepacksForObjectId } = useRepacks();
const repacks = useMemo(() => {
return getRepacksForObjectId(objectId);
}, [getRepacksForObjectId, objectId]);
const [repacks, setRepacks] = useState<GameRepack[]>([]);
const { i18n } = useTranslation("game_details");
const location = useLocation();
@@ -142,10 +130,12 @@ export function GameDetailsContextProvider({
}
});
window.electron.getGameStats(objectId, shop).then((result) => {
if (abortController.signal.aborted) return;
setStats(result);
});
if (shop !== "custom") {
window.electron.getGameStats(objectId, shop).then((result) => {
if (abortController.signal.aborted) return;
setStats(result);
});
}
const assetsPromise = window.electron.getGameAssets(objectId, shop);
@@ -167,7 +157,7 @@ export function GameDetailsContextProvider({
setIsLoading(false);
});
if (userDetails) {
if (userDetails && shop !== "custom") {
window.electron
.getUnlockedAchievements(objectId, shop)
.then((achievements) => {
@@ -287,19 +277,6 @@ export function GameDetailsContextProvider({
}
}, [location]);
const lastDownloadedOption = useMemo(() => {
if (game?.download) {
const repack = repacks.find((repack) =>
repack.uris.some((uri) => uri.includes(game.download!.uri))
);
if (!repack) return null;
return repack;
}
return null;
}, [game?.download, repacks]);
useEffect(() => {
const unsubscribe = window.electron.onUpdateAchievements(
objectId,
@@ -315,6 +292,36 @@ export function GameDetailsContextProvider({
};
}, [objectId, shop, userDetails]);
useEffect(() => {
if (shop === "custom") return;
const fetchDownloadSources = async () => {
try {
const sources = await window.electron.getDownloadSources();
const params = {
take: 100,
skip: 0,
downloadSourceIds: sources.map((source) => source.id),
};
const downloads = await window.electron.hydraApi.get<GameRepack[]>(
`/games/${shop}/${objectId}/download-sources`,
{
params,
needsAuth: false,
}
);
setRepacks(downloads);
} catch (error) {
console.error("Failed to fetch download sources:", error);
}
};
fetchDownloadSources();
}, [shop, objectId]);
const getDownloadsPath = async () => {
if (userPreferences?.downloadsPath) return userPreferences.downloadsPath;
return window.electron.getDefaultDownloadsPath();
@@ -359,7 +366,7 @@ export function GameDetailsContextProvider({
stats,
achievements,
hasNSFWContentBlocked,
lastDownloadedOption,
lastDownloadedOption: null,
setHasNSFWContentBlocked,
selectGameExecutable,
updateGame,

View File

@@ -31,8 +31,6 @@ import type {
Game,
DiskUsage,
DownloadSource,
DownloadSourceValidationResult,
GameRepack,
} from "@types";
import type { AxiosProgressEvent } from "axios";
@@ -211,20 +209,12 @@ declare global {
/* Download sources */
addDownloadSource: (url: string) => Promise<DownloadSource>;
updateMissingFingerprints: () => Promise<number>;
removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>;
getDownloadSources: () => Promise<
Pick<DownloadSource, "url" | "createdAt" | "updatedAt">[]
>;
deleteDownloadSource: (id: number) => Promise<void>;
deleteAllDownloadSources: () => Promise<void>;
validateDownloadSource: (
url: string
) => Promise<DownloadSourceValidationResult>;
syncDownloadSources: () => Promise<number>;
getDownloadSourcesList: () => Promise<DownloadSource[]>;
checkDownloadSourceExists: (url: string) => Promise<boolean>;
getAllRepacks: () => Promise<GameRepack[]>;
removeDownloadSource: (
removeAll = false,
downloadSourceId?: string
) => Promise<void>;
getDownloadSources: () => Promise<DownloadSource[]>;
syncDownloadSources: () => Promise<void>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;

View File

@@ -1,21 +0,0 @@
import { createSlice } from "@reduxjs/toolkit";
export interface DownloadSourcesState {
isImporting: boolean;
}
const initialState: DownloadSourcesState = {
isImporting: false,
};
export const downloadSourcesSlice = createSlice({
name: "downloadSources",
initialState,
reducers: {
setIsImportingSources: (state, action) => {
state.isImporting = action.payload;
},
},
});
export const { setIsImportingSources } = downloadSourcesSlice.actions;

View File

@@ -6,6 +6,4 @@ export * from "./toast-slice";
export * from "./user-details-slice";
export * from "./game-running.slice";
export * from "./subscription-slice";
export * from "./repacks-slice";
export * from "./download-sources-slice";
export * from "./catalogue-search";

View File

@@ -5,5 +5,4 @@ export * from "./use-toast";
export * from "./redux";
export * from "./use-user-details";
export * from "./use-format";
export * from "./use-repacks";
export * from "./use-feature";

View File

@@ -2,6 +2,7 @@ import axios from "axios";
import { useCallback, useEffect, useState } from "react";
import { useAppDispatch } from "./redux";
import { setGenres, setTags } from "@renderer/features";
import type { DownloadSource } from "@types";
export const externalResourcesInstance = axios.create({
baseURL: import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL,
@@ -12,6 +13,7 @@ export function useCatalogue() {
const [steamPublishers, setSteamPublishers] = useState<string[]>([]);
const [steamDevelopers, setSteamDevelopers] = useState<string[]>([]);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const getSteamUserTags = useCallback(() => {
externalResourcesInstance.get("/steam-user-tags.json").then((response) => {
@@ -37,17 +39,25 @@ export function useCatalogue() {
});
}, []);
const getDownloadSources = useCallback(() => {
window.electron.getDownloadSources().then((results) => {
setDownloadSources(results.filter((source) => !!source.fingerprint));
});
}, []);
useEffect(() => {
getSteamUserTags();
getSteamGenres();
getSteamPublishers();
getSteamDevelopers();
getDownloadSources();
}, [
getSteamUserTags,
getSteamGenres,
getSteamPublishers,
getSteamDevelopers,
getDownloadSources,
]);
return { steamPublishers, steamDevelopers };
return { steamPublishers, downloadSources, steamDevelopers };
}

View File

@@ -1,26 +0,0 @@
import { setRepacks } from "@renderer/features";
import { useCallback } from "react";
import { RootState } from "@renderer/store";
import { useSelector } from "react-redux";
import { useAppDispatch } from "./redux";
export function useRepacks() {
const dispatch = useAppDispatch();
const repacks = useSelector((state: RootState) => state.repacks.value);
const getRepacksForObjectId = useCallback(
(objectId: string) => {
return repacks.filter((repack) => repack.objectIds.includes(objectId));
},
[repacks]
);
const updateRepacks = useCallback(async () => {
const repacks = await window.electron.getAllRepacks();
dispatch(
setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds)))
);
}, [dispatch]);
return { getRepacksForObjectId, updateRepacks };
}

View File

@@ -1,4 +1,8 @@
import type { CatalogueSearchResult, DownloadSource } from "@types";
import type {
CatalogueSearchResult,
CatalogueSearchPayload,
DownloadSource,
} from "@types";
import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -29,13 +33,12 @@ export default function Catalogue() {
const abortControllerRef = useRef<AbortController | null>(null);
const cataloguePageRef = useRef<HTMLDivElement>(null);
const { steamDevelopers, steamPublishers } = useCatalogue();
const { steamDevelopers, steamPublishers, downloadSources } = useCatalogue();
const { steamGenres, steamUserTags } = useAppSelector(
const { steamGenres, steamUserTags, filters, page } = useAppSelector(
(state) => state.catalogueSearch
);
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [results, setResults] = useState<CatalogueSearchResult[]>([]);
@@ -44,31 +47,46 @@ export default function Catalogue() {
const { formatNumber } = useFormat();
const { filters, page } = useAppSelector((state) => state.catalogueSearch);
const dispatch = useAppDispatch();
const { t, i18n } = useTranslation("catalogue");
const debouncedSearch = useRef(
debounce(async (filters, pageSize, offset) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
debounce(
async (
filters: CatalogueSearchPayload,
downloadSources: DownloadSource[],
pageSize: number,
offset: number
) => {
const abortController = new AbortController();
abortControllerRef.current = abortController;
const response = await window.electron.hydraApi.post<{
edges: CatalogueSearchResult[];
count: number;
}>("/catalogue/search", {
data: { ...filters, take: pageSize, skip: offset },
needsAuth: false,
});
const requestData = {
...filters,
take: pageSize,
skip: offset,
downloadSourceIds: downloadSources.map(
(downloadSource) => downloadSource.id
),
};
if (abortController.signal.aborted) return;
const response = await window.electron.hydraApi.post<{
edges: CatalogueSearchResult[];
count: number;
}>("/catalogue/search", {
data: requestData,
needsAuth: false,
});
setResults(response.edges);
setItemsCount(response.count);
setIsLoading(false);
}, 500)
if (abortController.signal.aborted) return;
setResults(response.edges);
setItemsCount(response.count);
setIsLoading(false);
},
500
)
).current;
const decodeHTML = (s: string) =>
@@ -79,18 +97,17 @@ export default function Catalogue() {
setIsLoading(true);
abortControllerRef.current?.abort();
debouncedSearch(filters, PAGE_SIZE, (page - 1) * PAGE_SIZE);
debouncedSearch(
filters,
downloadSources,
PAGE_SIZE,
(page - 1) * PAGE_SIZE
);
return () => {
debouncedSearch.cancel();
};
}, [filters, page, debouncedSearch]);
useEffect(() => {
window.electron.getDownloadSourcesList().then((sources) => {
setDownloadSources(sources.filter((source) => !!source.fingerprint));
});
}, []);
}, [filters, downloadSources, page, debouncedSearch]);
const language = i18n.language.split("-")[0];
@@ -168,7 +185,7 @@ export default function Catalogue() {
value: publisher,
})),
];
}, [filters, steamUserTags, steamGenresMapping, language, downloadSources]);
}, [filters, steamUserTags, downloadSources, steamGenresMapping, language]);
const filterSections = useMemo(() => {
return [

View File

@@ -1,6 +1,6 @@
import { Badge } from "@renderer/components";
import { buildGameDetailsPath } from "@renderer/helpers";
import { useAppSelector, useRepacks, useLibrary } from "@renderer/hooks";
import { useAppSelector, useLibrary } from "@renderer/hooks";
import { useMemo, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
@@ -23,10 +23,6 @@ export function GameItem({ game }: GameItemProps) {
const { steamGenres } = useAppSelector((state) => state.catalogueSearch);
const { getRepacksForObjectId } = useRepacks();
const repacks = getRepacksForObjectId(game.objectId);
const [isAddingToLibrary, setIsAddingToLibrary] = useState(false);
const [added, setAdded] = useState(false);
@@ -63,10 +59,6 @@ export function GameItem({ game }: GameItemProps) {
}
};
const uniqueRepackers = useMemo(() => {
return Array.from(new Set(repacks.map((repack) => repack.repacker)));
}, [repacks]);
const genres = useMemo(() => {
return game.genres?.map((genre) => {
const index = steamGenres["en"]?.findIndex(
@@ -117,8 +109,8 @@ export function GameItem({ game }: GameItemProps) {
<span className="game-item__genres">{genres.join(", ")}</span>
<div className="game-item__repackers">
{uniqueRepackers.map((repacker) => (
<Badge key={repacker}>{repacker}</Badge>
{game.downloadSources.map((sourceName) => (
<Badge key={sourceName}>{sourceName}</Badge>
))}
</div>
</div>

View File

@@ -1,3 +1,5 @@
@use "../../scss/globals.scss";
.pagination {
display: flex;
gap: 4px;
@@ -18,4 +20,31 @@
font-size: 16px;
}
}
&__page-input {
box-sizing: border-box;
width: 40px;
min-width: 40px;
max-width: 40px;
min-height: 40px;
border-radius: 8px;
border: solid 1px globals.$border-color;
background-color: transparent;
color: globals.$muted-color;
text-align: center;
font-size: 12px;
padding: 0 6px;
outline: none;
}
&__double-chevron {
display: flex;
align-items: center;
justify-content: center;
font-size: 0; // remove whitespace node width between SVGs
}
&__double-chevron > svg + svg {
margin-left: -8px; // pull the second chevron closer
}
}

View File

@@ -1,8 +1,53 @@
import { Button } from "@renderer/components/button/button";
import { ChevronLeftIcon, ChevronRightIcon } from "@primer/octicons-react";
import { useFormat } from "@renderer/hooks/use-format";
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, KeyboardEvent, RefObject } from "react";
import "./pagination.scss";
interface JumpControlProps {
isOpen: boolean;
value: string;
totalPages: number;
inputRef: RefObject<HTMLInputElement>;
onOpen: () => void;
onClose: () => void;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void;
}
function JumpControl({
isOpen,
value,
totalPages,
inputRef,
onOpen,
onClose,
onChange,
onKeyDown,
}: JumpControlProps) {
return isOpen ? (
<input
ref={inputRef}
type="text"
min={1}
max={totalPages}
inputMode="numeric"
pattern="[0-9]*"
className="pagination__page-input"
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
onBlur={onClose}
aria-label="Go to page"
/>
) : (
<Button theme="outline" className="pagination__button" onClick={onOpen}>
...
</Button>
);
}
interface PaginationProps {
page: number;
totalPages: number;
@@ -13,23 +58,104 @@ export function Pagination({
page,
totalPages,
onPageChange,
}: PaginationProps) {
}: Readonly<PaginationProps>) {
const { formatNumber } = useFormat();
const [isJumpOpen, setIsJumpOpen] = useState(false);
const [jumpValue, setJumpValue] = useState<string>("");
const jumpInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (isJumpOpen) {
setJumpValue("");
setTimeout(() => jumpInputRef.current?.focus(), 0);
}
}, [isJumpOpen, page]);
if (totalPages <= 1) return null;
const visiblePages = 3;
const isLastThree = totalPages > 3 && page >= totalPages - 2;
let startPage = Math.max(1, page - 1);
let endPage = startPage + visiblePages - 1;
if (endPage > totalPages) {
if (isLastThree) {
startPage = Math.max(1, totalPages - 2);
endPage = totalPages;
} else if (endPage > totalPages) {
endPage = totalPages;
startPage = Math.max(1, endPage - visiblePages + 1);
}
const onJumpChange = (e: ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
const digitsOnly = raw.replaceAll(/\D+/g, "");
if (digitsOnly === "") {
setJumpValue("");
return;
}
const num = Number.parseInt(digitsOnly, 10);
if (Number.isNaN(num)) {
setJumpValue("");
return;
}
if (num < 1) {
setJumpValue("1");
return;
}
if (num > totalPages) {
setJumpValue(String(totalPages));
return;
}
setJumpValue(String(num));
};
const onJumpKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
const controlKeys = [
"Backspace",
"Delete",
"Tab",
"ArrowLeft",
"ArrowRight",
"Home",
"End",
];
if (controlKeys.includes(e.key) || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === "Enter") {
const sanitized = jumpValue.replaceAll(/\D+/g, "");
if (sanitized.trim() === "") return;
const parsed = Number.parseInt(sanitized, 10);
if (Number.isNaN(parsed)) return;
const target = Math.max(1, Math.min(totalPages, parsed));
onPageChange(target);
setIsJumpOpen(false);
} else if (e.key === "Escape") {
setIsJumpOpen(false);
} else if (!/^\d$/.test(e.key)) {
e.preventDefault();
}
};
return (
<div className="pagination">
{startPage > 1 && (
<Button
theme="outline"
onClick={() => onPageChange(1)}
className="pagination__button"
>
<span className="pagination__double-chevron">
<ChevronLeftIcon />
<ChevronLeftIcon />
</span>
</Button>
)}
<Button
theme="outline"
onClick={() => onPageChange(page - 1)}
@@ -39,20 +165,25 @@ export function Pagination({
<ChevronLeftIcon />
</Button>
{page > 2 && (
{isLastThree && startPage > 1 && (
<>
<Button
theme="outline"
onClick={() => onPageChange(1)}
className="pagination__button"
disabled={page === 1}
onClick={() => onPageChange(1)}
>
{1}
{formatNumber(1)}
</Button>
<div className="pagination__ellipsis">
<span className="pagination__ellipsis-text">...</span>
</div>
<JumpControl
isOpen={isJumpOpen}
value={jumpValue}
totalPages={totalPages}
inputRef={jumpInputRef}
onOpen={() => setIsJumpOpen(true)}
onClose={() => setIsJumpOpen(false)}
onChange={onJumpChange}
onKeyDown={onJumpKeyDown}
/>
</>
)}
@@ -70,11 +201,18 @@ export function Pagination({
</Button>
))}
{page < totalPages - 1 && (
{!isLastThree && page < totalPages - 1 && (
<>
<div className="pagination__ellipsis">
<span className="pagination__ellipsis-text">...</span>
</div>
<JumpControl
isOpen={isJumpOpen}
value={jumpValue}
totalPages={totalPages}
inputRef={jumpInputRef}
onOpen={() => setIsJumpOpen(true)}
onClose={() => setIsJumpOpen(false)}
onChange={onJumpChange}
onKeyDown={onJumpKeyDown}
/>
<Button
theme="outline"
@@ -95,6 +233,19 @@ export function Pagination({
>
<ChevronRightIcon />
</Button>
{endPage < totalPages && (
<Button
theme="outline"
onClick={() => onPageChange(totalPages)}
className="pagination__button"
>
<span className="pagination__double-chevron">
<ChevronRightIcon />
<ChevronRightIcon />
</span>
</Button>
)}
</div>
);
}

View File

@@ -11,7 +11,6 @@
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin-bottom: calc(globals.$spacing-unit * 1.5);
&__info {
display: flex;

View File

@@ -2,7 +2,7 @@
.gallery-slider {
&__container {
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 1);
padding: calc(globals.$spacing-unit * 1.5) 0;
width: 100%;
display: flex;
flex-direction: column;

View File

@@ -7,11 +7,16 @@ import {
} from "@primer/octicons-react";
import useEmblaCarousel from "embla-carousel-react";
import { gameDetailsContext } from "@renderer/context";
import { useAppSelector } from "@renderer/hooks";
import "./gallery-slider.scss";
export function GallerySlider() {
const { shopDetails } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const autoplayEnabled = userPreferences?.autoplayGameTrailers !== false;
const hasScreenshots = shopDetails && shopDetails.screenshots?.length;
@@ -164,7 +169,7 @@ export function GallerySlider() {
poster={item.poster}
loop
muted
autoPlay
autoPlay={autoplayEnabled}
tabIndex={-1}
>
<source src={item.videoSrc} />

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useContext, useEffect, useMemo, useState } from "react";
import { PencilIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
@@ -17,6 +17,7 @@ import cloudIconAnimated from "@renderer/assets/icons/cloud-animated.gif";
import { useUserDetails, useLibrary } from "@renderer/hooks";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./game-details.scss";
import "./hero.scss";
const processMediaElements = (document: Document) => {
const $images = Array.from(document.querySelectorAll("img"));
@@ -53,8 +54,6 @@ const getImageWithCustomPriority = (
};
export function GameDetailsContent() {
const heroRef = useRef<HTMLDivElement | null>(null);
const { t } = useTranslation("game_details");
const {
@@ -152,18 +151,12 @@ export function GameDetailsContent() {
className={`game-details__wrapper ${hasNSFWContentBlocked ? "game-details__wrapper--blurred" : ""}`}
>
<section className="game-details__container">
<div ref={heroRef} className="game-details__hero">
<div className="game-details__hero">
<img
src={heroImage}
className="game-details__hero-image"
alt={game?.title}
/>
<div
className="game-details__hero-backdrop"
style={{
flex: 1,
}}
/>
<div
className="game-details__hero-logo-backdrop"
@@ -202,11 +195,13 @@ export function GameDetailsContent() {
)}
</div>
</div>
<div className="game-details__hero-panel">
<HeroPanel />
</div>
</div>
</div>
<HeroPanel />
<div className="game-details__description-container">
<div className="game-details__description-content">
<DescriptionHeader />
@@ -233,7 +228,7 @@ export function GameDetailsContent() {
</button>
)}
{game?.shop !== "custom" && shop && objectId && (
{shop !== "custom" && shop && objectId && (
<GameReviews
shop={shop}
objectId={objectId}
@@ -246,7 +241,7 @@ export function GameDetailsContent() {
)}
</div>
{game?.shop !== "custom" && <Sidebar />}
{shop !== "custom" && <Sidebar />}
</div>
</section>

View File

@@ -1,63 +1,170 @@
import Skeleton from "react-loading-skeleton";
import { Button } from "@renderer/components";
import { useTranslation } from "react-i18next";
import "./game-details.scss";
import "react-loading-skeleton/dist/skeleton.css";
export function GameDetailsSkeleton() {
const { t } = useTranslation("game_details");
return (
<div className="game-details__container">
<div className="game-details__hero">
<Skeleton className="game-details__hero-image-skeleton" />
</div>
<div className="game-details__hero-panel-skeleton">
<section className="description-header__info">
<Skeleton width={155} />
<Skeleton width={135} />
</section>
</div>
<div className="game-details__description-container">
<div className="game-details__description-content">
<div className="description-header">
<section className="description-header__info">
<Skeleton width={145} />
<Skeleton width={150} />
</section>
<div className="game-details__wrapper game-details__skeleton">
<section className="game-details__container">
<div className="game-details__hero">
<Skeleton
height={350}
style={{
borderRadius: "0px 0px 8px 8px",
position: "absolute",
width: "100%",
zIndex: 0,
}}
/>
<div className="game-details__hero-logo-backdrop">
<div className="game-details__hero-content">
<div className="game-details__game-logo" />
<div className="game-details__hero-buttons game-details__hero-buttons--right" />
</div>
<div className="game-details__hero-panel">
<div className="hero-panel__container">
<div className="hero-panel">
<div className="hero-panel__content">
<Skeleton height={16} width={150} />
<Skeleton height={16} width={120} />
</div>
<div className="hero-panel__actions" style={{ gap: "16px" }}>
<Skeleton
height={36}
width={36}
style={{ borderRadius: "6px" }}
/>
<Skeleton
height={36}
width={36}
style={{ borderRadius: "6px" }}
/>
<Skeleton
height={36}
width={100}
style={{ borderRadius: "6px" }}
/>
</div>
</div>
</div>
</div>
</div>
<div className="game-details__description-skeleton">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={index} />
))}
<Skeleton className="game-details__hero-image-skeleton" />
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} />
))}
<Skeleton className="game-details__hero-image-skeleton" />
<Skeleton />
</div>
<div className="game-details__description-container">
<div className="game-details__description-content">
<div className="description-header">
<section className="description-header__info">
<Skeleton height={16} width={200} />
<Skeleton height={16} width={150} />
</section>
</div>
<div style={{ marginBottom: "24px" }}>
<Skeleton
height={200}
width="100%"
style={{ borderRadius: "8px" }}
/>
</div>
<div className="game-details__description">
<Skeleton count={8} height={22} style={{ marginBottom: "8px" }} />
<Skeleton height={22} width="60%" />
</div>
<Skeleton
width={120}
height={36}
className="game-details__description-toggle"
width={100}
style={{
borderRadius: "4px",
marginTop: "24px",
alignSelf: "center",
}}
/>
<div style={{ marginTop: "48px" }} />
</div>
<aside className="content-sidebar">
<div className="sidebar-section">
<div
className="sidebar-section__button"
style={{ pointerEvents: "none" }}
>
<Skeleton height={16} width={16} />
<Skeleton height={16} width={60} />
</div>
<div className="sidebar-section__content">
<div className="stats__section">
<div className="stats__category">
<div className="stats__category-title">
<Skeleton height={14} width={14} />
<Skeleton height={14} width={80} />
</div>
<Skeleton height={14} width={40} />
</div>
<div className="stats__category">
<div className="stats__category-title">
<Skeleton height={14} width={14} />
<Skeleton height={14} width={70} />
</div>
<Skeleton height={14} width={35} />
</div>
<div className="stats__category">
<div className="stats__category-title">
<Skeleton height={14} width={14} />
<Skeleton height={14} width={60} />
</div>
<Skeleton height={14} width={30} />
</div>
</div>
</div>
</div>
<div className="sidebar-section">
<div
className="sidebar-section__button"
style={{ pointerEvents: "none" }}
>
<Skeleton height={16} width={16} />
<Skeleton height={16} width={120} />
</div>
<div className="sidebar-section__content">
<ul className="list">
{Array.from({ length: 4 }).map((_, index) => (
<li key={index}>
<div
className="list__item"
style={{ pointerEvents: "none" }}
>
<Skeleton
height={54}
width={54}
style={{ borderRadius: "4px" }}
/>
<div>
<Skeleton
height={14}
width={120}
style={{ marginBottom: "4px" }}
/>
<Skeleton height={12} width={80} />
</div>
</div>
</li>
))}
</ul>
</div>
</div>
</aside>
</div>
<div className="content-sidebar">
<div className="requirement__button-container">
<Button className="requirement__button" theme="primary" disabled>
{t("minimum")}
</Button>
<Button className="requirement__button" theme="outline" disabled>
{t("recommended")}
</Button>
</div>
<div className="requirement__details-skeleton">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} height={20} />
))}
</div>
</div>
</div>
</section>
</div>
);
}

View File

@@ -1,19 +1,5 @@
@use "../../scss/globals.scss";
$hero-height: 300px;
@keyframes slide-in {
0% {
transform: translateY(calc(40px + globals.$spacing-unit * 2));
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
.game-details {
&__wrapper {
display: flex;
@@ -27,617 +13,6 @@ $hero-height: 300px;
}
}
&__review-form {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
&__review-form-controls {
display: flex;
gap: calc(globals.$spacing-unit * 2);
align-items: flex-end;
flex-wrap: wrap;
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
gap: calc(globals.$spacing-unit * 1.5);
}
}
&__review-form-bottom {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
&__review-message {
padding: calc(globals.$spacing-unit * 1);
border-radius: 4px;
font-size: globals.$small-font-size;
font-weight: 500;
margin-top: calc(globals.$spacing-unit * 1);
border: 1px solid;
&--success {
background: rgba(34, 197, 94, 0.1);
color: #86efac;
border-color: rgba(34, 197, 94, 0.3);
}
&--error {
background: rgba(239, 68, 68, 0.1);
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.3);
}
}
&__review-score-container {
display: flex;
align-items: center;
gap: 4px;
}
&__review-score-label {
font-size: 14px;
color: #ffffff;
font-weight: 500;
}
&__review-score-select {
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #ffffff;
padding: 6px 12px;
font-size: 14px;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&:focus {
outline: none;
}
&--red {
border-color: #e74c3c;
background-color: rgba(231, 76, 60, 0.1);
}
&--yellow {
border-color: #f39c12;
background-color: rgba(243, 156, 18, 0.1);
}
&--green {
border-color: #27ae60;
background-color: rgba(39, 174, 96, 0.1);
}
option {
background-color: #2a2a2a;
color: #ffffff;
}
}
&__star-rating {
display: flex;
align-items: center;
gap: 2px;
}
&__star {
background: none;
border: none;
color: #666666;
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.1);
transform: scale(1.1);
}
&--filled {
color: #ffffff;
&.game-details__review-score-select--red {
color: #e74c3c;
}
&.game-details__review-score-select--yellow {
color: #f39c12;
}
&.game-details__review-score-select--green {
color: #27ae60;
}
}
&--empty {
color: #666666;
&:hover {
color: #ffffff;
}
}
svg {
fill: currentColor;
}
}
&__reviews-sort {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.75);
min-width: 150px;
}
&__reviews-sort-label {
display: block;
font-size: globals.$body-font-size;
color: globals.$body-color;
}
&__reviews-sort-select {
background-color: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
border-radius: 4px;
padding: calc(globals.$spacing-unit * 0.75) calc(globals.$spacing-unit * 1);
color: globals.$body-color;
font-size: globals.$body-font-size;
font-family: inherit;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&:focus {
outline: none;
background-color: rgba(255, 255, 255, 0.08);
border-color: globals.$brand-teal;
}
&:hover {
border-color: rgba(255, 255, 255, 0.15);
}
option {
background-color: globals.$dark-background-color;
color: globals.$body-color;
}
}
&__reviews-list {
margin-top: calc(globals.$spacing-unit * 3);
}
&__reviews-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 4);
}
&__reviews-separator {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: calc(globals.$spacing-unit * 3) 0;
width: 100%;
}
&__reviews-list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: calc(globals.$spacing-unit * 1);
}
&__reviews-empty {
text-align: center;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__reviews-empty-icon {
font-size: 48px;
margin-bottom: calc(globals.$spacing-unit * 2);
color: rgba(255, 255, 255, 0.6);
}
&__reviews-empty-title {
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
margin: 0 0 calc(globals.$spacing-unit * 1) 0;
}
&__reviews-empty-message {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
margin: 0;
line-height: 1.4;
}
&__review-item {
overflow: hidden;
word-wrap: break-word;
}
&__review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
&__review-user {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
}
&__review-user-info {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.25);
}
&__review-display-name {
color: rgba(255, 255, 255, 0.9);
font-size: globals.$small-font-size;
font-weight: 600;
display: inline-flex;
&--clickable {
cursor: pointer;
transition: color 0.2s ease;
&:hover {
text-decoration: underline;
}
}
}
&__review-actions {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
&__review-votes {
display: flex;
gap: 12px;
}
&__vote-button {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 6px 12px;
color: #ccc;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
&--active {
&.game-details__vote-button--upvote {
svg {
fill: white;
}
}
&.game-details__vote-button--downvote {
svg {
fill: white;
}
}
}
span {
font-weight: 500;
display: inline-block;
min-width: 1ch;
overflow: hidden;
}
}
&__delete-review-button {
display: flex;
align-items: center;
justify-content: center;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 6px;
padding: 6px;
color: #f44336;
cursor: pointer;
transition: all 0.2s ease;
gap: 6px;
&:hover {
background: rgba(244, 67, 54, 0.2);
border-color: #f44336;
color: #ff5722;
}
}
&__blocked-review-simple {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
}
&__blocked-review-show-link {
background: none;
border: none;
color: #ffc107;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: #ffeb3b;
}
}
&__blocked-review-hide-link {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
&__review-score-stars {
display: flex;
align-items: center;
gap: 2px;
}
&__review-star {
color: #666666;
transition: color 0.2s ease;
cursor: default;
&--filled {
color: #ffffff;
&.game-details__review-score--red {
color: #fca5a5;
}
&.game-details__review-score--yellow {
color: #fcd34d;
}
&.game-details__review-score--green {
color: #86efac;
}
}
&--empty {
color: #666666;
}
svg {
fill: currentColor;
}
}
&__review-date {
display: flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
}
&__review-content {
color: globals.$body-color;
line-height: 1.5;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
max-width: 100%;
}
&__reviews-loading {
text-align: center;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit * 2);
}
&__load-more-reviews {
background: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 2);
border-radius: 4px;
cursor: pointer;
font-size: globals.$body-font-size;
font-family: inherit;
transition: all 0.2s ease;
width: 100%;
margin-top: calc(globals.$spacing-unit * 2);
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: globals.$brand-teal;
}
}
&__hero {
width: 100%;
height: $hero-height;
min-height: $hero-height;
display: flex;
flex-direction: column;
position: relative;
transition: all ease 0.2s;
@media (min-width: 1250px) {
height: 350px;
min-height: 350px;
}
}
&__hero-content {
padding: calc(globals.$spacing-unit * 1.5);
height: 100%;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
@media (min-width: 768px) {
padding: calc(globals.$spacing-unit * 2);
}
}
&__hero-buttons {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
&--right {
margin-left: auto;
}
}
&__edit-custom-game-button {
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
color: globals.$muted-color;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
color: globals.$body-color;
}
}
&__hero-logo-backdrop {
width: 100%;
height: 100%;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.3) 60%, transparent 100%);
position: absolute;
display: flex;
flex-direction: column;
justify-content: space-between;
}
&__hero-image {
width: 100%;
height: calc($hero-height + 72px);
min-height: calc($hero-height + 72px);
object-fit: cover;
object-position: top;
transition: all ease 0.2s;
position: absolute;
z-index: 0;
@media (min-width: 1250px) {
object-position: center;
height: calc(350px + 72px);
min-height: calc(350px + 72px);
}
}
&__game-logo {
width: 200px;
align-self: flex-end;
@media (min-width: 768px) {
width: 250px;
}
@media (min-width: 1024px) {
width: 300px;
}
}
&__game-logo-text {
width: 200px;
align-self: flex-end;
font-size: 1.8rem;
font-weight: bold;
color: #ffffff;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
text-align: left;
line-height: 1.2;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
@media (min-width: 768px) {
width: 250px;
font-size: 2.2rem;
}
@media (min-width: 1024px) {
width: 300px;
font-size: 2.5rem;
}
}
&__hero-image-skeleton {
height: 300px;
@media (min-width: 1250px) {
height: 350px;
}
}
&__container {
width: 100%;
height: 100%;
@@ -646,6 +21,7 @@ $hero-height: 300px;
z-index: 1;
}
// Description Section Styles
&__description-container {
display: flex;
width: 100%;
@@ -754,322 +130,51 @@ $hero-height: 300px;
}
}
&__description-skeleton {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 1.5);
width: 100%;
margin-left: auto;
margin-right: auto;
@media (min-width: 768px) {
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
// Skeleton-specific styles
&__skeleton {
.react-loading-skeleton {
background: linear-gradient(90deg, #1c1c1c 25%, #2a2a2a 50%, #1c1c1c 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
}
@media (min-width: 1024px) {
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 2);
width: 80%;
// Ensure skeleton elements maintain proper spacing
.description-header {
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
@media (min-width: 1280px) {
width: 60%;
line-height: 22px;
.content-sidebar {
min-width: 300px;
max-width: 300px;
}
@media (min-width: 1536px) {
width: 50%;
}
}
&__randomizer-button {
animation: slide-in 0.2s;
position: fixed;
bottom: calc(globals.$spacing-unit * 3);
right: calc(9px + globals.$spacing-unit * 2);
box-shadow: rgba(255, 255, 255, 0.1) 0px 0px 10px 1px;
border: solid 2px globals.$border-color;
z-index: 1;
background-color: globals.$background-color;
&:hover {
background-color: globals.$background-color;
box-shadow: rgba(255, 255, 255, 0.1) 0px 0px 15px 5px;
opacity: 1;
}
&:active {
transform: scale(0.98);
}
&:disabled {
box-shadow: none;
transform: none;
opacity: 0.8;
background-color: globals.$background-color;
}
}
&__hero-panel-skeleton {
width: 100%;
padding: calc(globals.$spacing-unit * 2);
display: flex;
align-items: center;
background-color: globals.$background-color;
height: 72px;
border-bottom: solid 1px globals.$border-color;
}
&__cloud-sync-button {
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: globals.$spacing-unit;
color: globals.$muted-color;
font-size: globals.$small-font-size;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:disabled {
opacity: globals.$disabled-opacity;
cursor: not-allowed;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
}
}
&__stars-icon-container {
width: 16px;
height: 16px;
position: relative;
}
&__stars-icon {
width: 70px;
position: absolute;
top: -28px;
left: -27px;
}
&__cloud-icon-container {
width: 20px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
&__cloud-icon {
width: 26px;
position: absolute;
top: -3px;
}
&__hero-backdrop {
flex: 1;
transition: opacity 0.2s ease;
}
&__reviews-section {
margin-top: calc(globals.$spacing-unit * 3);
padding-top: calc(globals.$spacing-unit * 3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
width: 100%;
margin-left: auto;
margin-right: auto;
@media (min-width: 1280px) {
width: 60%;
}
@media (min-width: 1536px) {
width: 50%;
}
}
&__reviews-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 2);
@media (max-width: 768px) {
// Hero panel skeleton spacing
.hero-panel__content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit * 1.5);
}
}
&__reviews-title {
font-size: 1.25rem;
font-weight: 600;
color: globals.$muted-color;
margin: 0;
}
&__reviews-title-group {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex: 1;
}
&__reviews-badge {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
flex-shrink: 0;
}
&__leave-review-cta {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
padding: calc(globals.$spacing-unit * 0.75)
calc(globals.$spacing-unit * 1.5);
background: linear-gradient(
135deg,
globals.$brand-teal,
globals.$brand-blue
);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-bottom: calc(globals.$spacing-unit);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(globals.$brand-teal, 0.3);
gap: calc(globals.$spacing-unit * 0.5);
}
&:active {
transform: translateY(0);
// Review items skeleton spacing
.review-item-skeleton {
border: 1px solid globals.$border-color;
border-radius: 8px;
padding: calc(globals.$spacing-unit * 1);
margin-bottom: calc(globals.$spacing-unit * 1);
}
svg {
flex-shrink: 0;
}
}
&__review-input-container {
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background-color: globals.$dark-background-color;
overflow: hidden;
}
&__review-input-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: globals.$background-color;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&__review-editor-toolbar {
display: flex;
gap: 4px;
}
&__editor-button {
background: none;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: #ffffff;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
}
&.is-active {
background-color: globals.$brand-blue;
border-color: globals.$brand-blue;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__review-char-counter {
font-size: 12px;
color: #888888;
.over-limit {
color: #ff6b6b;
}
}
&__review-input {
min-height: 120px;
padding: 12px;
cursor: text;
.ProseMirror {
outline: none;
color: #ffffff;
font-size: 14px;
line-height: 1.5;
min-height: 96px; // 120px - 24px padding
width: 100%;
cursor: text;
&:focus {
outline: none;
}
p {
margin: 0 0 8px 0;
&:last-child {
margin-bottom: 0;
}
}
strong {
font-weight: bold;
}
em {
font-style: italic;
}
u {
text-decoration: underline;
}
// Sidebar section spacing
.sidebar-section-skeleton {
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
}
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

View File

@@ -25,6 +25,7 @@ import { Downloader, getDownloadersForUri } from "@shared";
import { CloudSyncModal } from "./cloud-sync-modal/cloud-sync-modal";
import { CloudSyncFilesModal } from "./cloud-sync-files-modal/cloud-sync-files-modal";
import "./game-details.scss";
import "./hero.scss";
export default function GameDetails() {
const [randomGame, setRandomGame] = useState<Steam250Game | null>(null);
@@ -102,7 +103,6 @@ export default function GameDetails() {
automaticallyExtract: boolean
) => {
const response = await startDownload({
repackId: repack.id,
objectId: objectId!,
title: gameTitle,
downloader,

View File

@@ -0,0 +1,116 @@
@use "../../scss/globals.scss";
.game-details {
&__reviews-section {
margin-top: calc(globals.$spacing-unit * 3);
padding-top: calc(globals.$spacing-unit * 3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
width: 100%;
margin-left: auto;
margin-right: auto;
@media (min-width: 1280px) {
width: 60%;
}
@media (min-width: 1536px) {
width: 50%;
}
}
&__reviews-title {
font-size: 1.25rem;
font-weight: 600;
color: globals.$muted-color;
margin: 0;
}
&__reviews-title-group {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit);
flex: 1;
}
&__reviews-badge {
background-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.7);
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
flex-shrink: 0;
}
&__reviews-container {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 4);
}
&__reviews-separator {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: calc(globals.$spacing-unit * 3) 0;
width: 100%;
}
&__reviews-list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: calc(globals.$spacing-unit * 1);
}
&__reviews-empty {
text-align: center;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 2);
margin-bottom: calc(globals.$spacing-unit * 2);
}
&__reviews-empty-icon {
font-size: 48px;
margin-bottom: calc(globals.$spacing-unit * 2);
color: rgba(255, 255, 255, 0.6);
}
&__reviews-empty-title {
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
margin: 0 0 calc(globals.$spacing-unit * 1) 0;
}
&__reviews-empty-message {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
margin: 0;
line-height: 1.4;
}
&__reviews-loading {
text-align: center;
color: rgba(255, 255, 255, 0.6);
padding: calc(globals.$spacing-unit * 2);
}
&__load-more-reviews {
background: rgba(255, 255, 255, 0.05);
border: 1px solid globals.$border-color;
color: globals.$body-color;
padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 2);
border-radius: 4px;
cursor: pointer;
font-size: globals.$body-font-size;
font-family: inherit;
transition: all 0.2s ease;
width: 100%;
margin-top: calc(globals.$spacing-unit * 2);
&:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: globals.$brand-teal;
}
}
}

View File

@@ -9,6 +9,7 @@ import { ReviewForm } from "./review-form";
import { ReviewItem } from "./review-item";
import { ReviewSortOptions } from "./review-sort-options";
import { ReviewPromptBanner } from "./review-prompt-banner";
import "./game-reviews.scss";
import { useToast } from "@renderer/hooks";
type ReviewSortOption =
@@ -116,7 +117,7 @@ export function GameReviews({
});
const checkUserReview = useCallback(async () => {
if (!objectId || !userDetailsId) return;
if (!objectId || !userDetailsId || shop === "custom") return;
try {
const response = await window.electron.hydraApi.get<{
@@ -144,11 +145,9 @@ export function GameReviews({
}
}, [objectId, userDetailsId, shop, game, onUserReviewedChange]);
console.log("reviews", reviews);
const loadReviews = useCallback(
async (reset = false) => {
if (!objectId) return;
if (!objectId || shop === "custom") return;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
@@ -164,7 +163,6 @@ export function GameReviews({
take: "20",
skip: skip.toString(),
sortBy: reviewsSortBy,
language: i18n.language,
});
const response = await window.electron.hydraApi.get(
@@ -440,8 +438,6 @@ export function GameReviews({
});
}, [reviews]);
console.log("reviews", reviews);
return (
<div className="game-details__reviews-section">
{showReviewPrompt &&
@@ -469,84 +465,82 @@ export function GameReviews({
</>
)}
<div className="game-details__reviews-list">
<div className="game-details__reviews-list-header">
<div className="game-details__reviews-title-group">
<h3 className="game-details__reviews-title">{t("reviews")}</h3>
<span className="game-details__reviews-badge">
{totalReviewCount}
</span>
</div>
<div className="game-details__reviews-list-header">
<div className="game-details__reviews-title-group">
<h3 className="game-details__reviews-title">{t("reviews")}</h3>
<span className="game-details__reviews-badge">
{totalReviewCount}
</span>
</div>
<ReviewSortOptions
sortBy={reviewsSortBy}
onSortChange={handleSortChange}
/>
{reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-loading">
{t("loading_reviews")}
</div>
)}
{!reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-empty">
<div className="game-details__reviews-empty-icon">
<NoteIcon size={48} />
</div>
<h4 className="game-details__reviews-empty-title">
{t("no_reviews_yet")}
</h4>
<p className="game-details__reviews-empty-message">
{t("be_first_to_review")}
</p>
</div>
)}
<div
className="game-details__reviews-container"
style={{
opacity: reviewsLoading && reviews.length > 0 ? 0.5 : 1,
transition: "opacity 0.2s ease",
}}
>
{reviews.map((review) => (
<ReviewItem
key={review.id}
review={review}
userDetailsId={userDetailsId}
isBlocked={review.isBlocked}
isVisible={visibleBlockedReviews.has(review.id)}
isVoting={votingReviews.has(review.id)}
previousVotes={
previousVotesRef.current.get(review.id) || {
upvotes: 0,
downvotes: 0,
}
}
onVote={handleVoteReview}
onDelete={handleDeleteReview}
onToggleVisibility={toggleBlockedReview}
onAnimationComplete={handleVoteAnimationComplete}
/>
))}
</div>
{hasMoreReviews && !reviewsLoading && (
<button
className="game-details__load-more-reviews"
onClick={loadMoreReviews}
>
{t("load_more_reviews")}
</button>
)}
{reviewsLoading && reviews.length > 0 && (
<div className="game-details__reviews-loading">
{t("loading_more_reviews")}
</div>
)}
</div>
<ReviewSortOptions
sortBy={reviewsSortBy}
onSortChange={handleSortChange}
/>
{reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-loading">
{t("loading_reviews")}
</div>
)}
{!reviewsLoading && reviews.length === 0 && (
<div className="game-details__reviews-empty">
<div className="game-details__reviews-empty-icon">
<NoteIcon size={48} />
</div>
<h4 className="game-details__reviews-empty-title">
{t("no_reviews_yet")}
</h4>
<p className="game-details__reviews-empty-message">
{t("be_first_to_review")}
</p>
</div>
)}
<div
className="game-details__reviews-container"
style={{
opacity: reviewsLoading && reviews.length > 0 ? 0.5 : 1,
transition: "opacity 0.2s ease",
}}
>
{reviews.map((review) => (
<ReviewItem
key={review.id}
review={review}
userDetailsId={userDetailsId}
isBlocked={review.isBlocked}
isVisible={visibleBlockedReviews.has(review.id)}
isVoting={votingReviews.has(review.id)}
previousVotes={
previousVotesRef.current.get(review.id) || {
upvotes: 0,
downvotes: 0,
}
}
onVote={handleVoteReview}
onDelete={handleDeleteReview}
onToggleVisibility={toggleBlockedReview}
onAnimationComplete={handleVoteAnimationComplete}
/>
))}
</div>
{hasMoreReviews && !reviewsLoading && (
<button
className="game-details__load-more-reviews"
onClick={loadMoreReviews}
>
{t("load_more_reviews")}
</button>
)}
{reviewsLoading && reviews.length > 0 && (
<div className="game-details__reviews-loading">
{t("loading_more_reviews")}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,274 @@
@use "../../scss/globals.scss";
$hero-height: 350px;
@keyframes slide-in {
0% {
transform: translateY(calc(40px + globals.$spacing-unit * 2));
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
.game-details {
&__hero-panel {
padding: globals.$spacing-unit;
}
&__hero {
width: 100%;
height: $hero-height;
min-height: $hero-height;
display: flex;
flex-direction: column;
position: relative;
transition: all ease 0.2s;
@media (min-width: 1250px) {
height: 350px;
min-height: 350px;
}
}
&__hero-content {
padding: calc(globals.$spacing-unit * 1.5);
height: 100%;
width: 100%;
display: flex;
justify-content: space-between;
align-items: flex-end;
@media (min-width: 768px) {
padding: calc(globals.$spacing-unit * 2);
}
}
&__hero-buttons {
display: flex;
gap: globals.$spacing-unit;
align-items: center;
&--right {
margin-left: auto;
}
}
&__edit-custom-game-button {
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
color: globals.$muted-color;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
color: globals.$body-color;
}
}
&__hero-logo-backdrop {
width: 100%;
height: 100%;
position: absolute;
display: flex;
flex-direction: column;
justify-content: space-between;
}
&__hero-image-wrapper {
position: absolute;
width: 100%;
height: 384px;
max-height: 384px;
overflow: hidden;
border-radius: 0px 0px 8px 8px;
z-index: 0;
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.3) 60%,
transparent 100%
);
z-index: 1;
pointer-events: none;
border-radius: inherit;
}
@media (min-width: 1250px) {
height: calc(350px + 82px);
min-height: calc(350px + 84px);
}
}
&__hero-image {
width: 100%;
height: $hero-height;
min-height: $hero-height;
object-fit: cover;
object-position: top;
transition: all ease 0.2s;
position: absolute;
z-index: 0;
border-radius: 0px 0px 8px 8px;
@media (min-width: 1250px) {
object-position: center;
height: $hero-height;
min-height: $hero-height;
}
}
&__game-logo {
width: 200px;
align-self: flex-end;
object-fit: contain;
object-position: left bottom;
@media (min-width: 768px) {
width: 250px;
}
@media (min-width: 1024px) {
width: 300px;
max-height: 150px;
}
}
&__game-logo-text {
width: 200px;
align-self: flex-end;
font-size: 1.8rem;
font-weight: bold;
color: #ffffff;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
text-align: left;
line-height: 1.2;
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
@media (min-width: 768px) {
width: 250px;
font-size: 2.2rem;
}
@media (min-width: 1024px) {
width: 300px;
font-size: 2.5rem;
}
}
&__cloud-sync-button {
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: globals.$spacing-unit;
color: globals.$muted-color;
font-size: globals.$small-font-size;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:disabled {
opacity: globals.$disabled-opacity;
cursor: not-allowed;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
}
}
&__cloud-icon-container {
width: 20px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
&__cloud-icon {
width: 26px;
position: absolute;
top: -3px;
}
&__randomizer-button {
padding: calc(globals.$spacing-unit * 1.5);
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(20px);
border-radius: 8px;
transition: all ease 0.2s;
cursor: pointer;
min-height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
color: globals.$muted-color;
border: solid 1px globals.$border-color;
box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.8);
animation: slide-in 0.3s cubic-bezier(0.33, 1, 0.68, 1);
&:active {
opacity: 0.9;
}
&:hover {
background-color: rgba(0, 0, 0, 0.5);
color: globals.$body-color;
}
}
&__stars-icon-container {
width: 20px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
&__stars-icon {
width: 26px;
position: absolute;
top: -3px;
}
}

View File

@@ -18,6 +18,7 @@
top: 0;
z-index: 2;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border-radius: 8px;
&--stuck {
background: rgba(0, 0, 0, 0.7);
@@ -29,7 +30,18 @@
&__content {
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
gap: calc(globals.$spacing-unit * 0.5);
p {
font-size: globals.$small-font-size;
color: globals.$muted-color;
font-weight: 400;
margin: 0;
&:first-child {
font-weight: 600;
}
}
}
&__actions {

View File

@@ -50,25 +50,29 @@ export function HeroPanel() {
game?.download?.status === "paused";
return (
<div className="hero-panel">
<div className="hero-panel__content">{getInfo()}</div>
<div className="hero-panel__actions">
<HeroPanelActions />
</div>
<div className="hero-panel__container">
<div className="hero-panel">
<div className="hero-panel__content">{getInfo()}</div>
<div className="hero-panel__actions">
<HeroPanelActions />
</div>
{showProgressBar && (
<progress
max={1}
value={
isGameDownloading ? lastPacket?.progress : game?.download?.progress
}
className={`hero-panel__progress-bar ${
game?.download?.status === "paused"
? "hero-panel__progress-bar--disabled"
: ""
}`}
/>
)}
{showProgressBar && (
<progress
max={1}
value={
isGameDownloading
? lastPacket?.progress
: game?.download?.progress
}
className={`hero-panel__progress-bar ${
game?.download?.status === "paused"
? "hero-panel__progress-bar--disabled"
: ""
}`}
/>
)}
</div>
</div>
);
}

View File

@@ -54,7 +54,7 @@ export function RepacksModal({
{}
);
const { repacks, game } = useContext(gameDetailsContext);
const { game, repacks } = useContext(gameDetailsContext);
const { t } = useTranslation("game_details");
@@ -88,6 +88,15 @@ export function RepacksModal({
});
}, [repacks, isFeatureEnabled, Feature]);
useEffect(() => {
const fetchDownloadSources = async () => {
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
};
fetchDownloadSources();
}, []);
const sortedRepacks = useMemo(() => {
return orderBy(
repacks,
@@ -103,23 +112,13 @@ export function RepacksModal({
);
}, [repacks, hashesInDebrid]);
useEffect(() => {
window.electron.getDownloadSourcesList().then((sources) => {
const uniqueRepackers = new Set(sortedRepacks.map((r) => r.repacker));
const filteredSources = sources.filter(
(s) => s.name && uniqueRepackers.has(s.name) && !!s.fingerprint
);
setDownloadSources(filteredSources);
});
}, [sortedRepacks]);
useEffect(() => {
const term = filterTerm.trim().toLowerCase();
const byTerm = sortedRepacks.filter((repack) => {
if (!term) return true;
const lowerTitle = repack.title.toLowerCase();
const lowerRepacker = repack.repacker.toLowerCase();
const lowerRepacker = repack.downloadSourceName.toLowerCase();
return lowerTitle.includes(term) || lowerRepacker.includes(term);
});
@@ -130,7 +129,7 @@ export function RepacksModal({
(src) =>
src.fingerprint &&
selectedFingerprints.includes(src.fingerprint) &&
src.name === repack.repacker
src.name === repack.downloadSourceName
);
});
@@ -281,7 +280,7 @@ export function RepacksModal({
)}
<p className="repacks-modal__repack-info">
{repack.fileSize} - {repack.repacker} -{" "}
{repack.fileSize} - {repack.downloadSourceName} -{" "}
{repack.uploadDate ? formatDate(repack.uploadDate) : ""}
</p>

View File

@@ -0,0 +1,232 @@
@use "../../scss/globals.scss";
.game-details {
&__reviews-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 2);
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
gap: calc(globals.$spacing-unit * 1.5);
}
}
&__reviews-title {
font-size: 1.25rem;
font-weight: 600;
color: globals.$muted-color;
margin: 0;
}
&__review-form {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
&__review-form-bottom {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
&__review-score-container {
display: flex;
align-items: center;
gap: 4px;
}
&__review-score-select {
background-color: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #ffffff;
padding: 6px 12px;
font-size: 14px;
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
&:focus {
outline: none;
}
&--red {
border-color: #e74c3c;
background-color: rgba(231, 76, 60, 0.1);
}
&--yellow {
border-color: #f39c12;
background-color: rgba(243, 156, 18, 0.1);
}
&--green {
border-color: #27ae60;
background-color: rgba(39, 174, 96, 0.1);
}
option {
background-color: #2a2a2a;
color: #ffffff;
}
}
&__star-rating {
display: flex;
align-items: center;
gap: 2px;
}
&__star {
background: none;
border: none;
color: #666666;
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
color: #ffffff;
background-color: rgba(255, 255, 255, 0.1);
transform: scale(1.1);
}
&--filled {
color: #ffffff;
&.game-details__review-score-select--red {
color: #e74c3c;
}
&.game-details__review-score-select--yellow {
color: #f39c12;
}
&.game-details__review-score-select--green {
color: #27ae60;
}
}
&--empty {
color: #666666;
&:hover {
color: #ffffff;
}
}
svg {
fill: currentColor;
}
}
&__review-input-container {
display: flex;
flex-direction: column;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background-color: globals.$dark-background-color;
overflow: hidden;
}
&__review-input-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background-color: globals.$background-color;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
&__review-editor-toolbar {
display: flex;
gap: 4px;
}
&__editor-button {
background: none;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: #ffffff;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
}
&.is-active {
background-color: globals.$brand-blue;
border-color: globals.$brand-blue;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__review-char-counter {
font-size: 12px;
color: #888888;
.over-limit {
color: #ff6b6b;
}
}
&__review-input {
min-height: 120px;
padding: 12px;
cursor: text;
.ProseMirror {
outline: none;
color: #ffffff;
font-size: 14px;
line-height: 1.5;
min-height: 96px; // 120px - 24px padding
width: 100%;
cursor: text;
&:focus {
outline: none;
}
p {
margin: 0 0 8px 0;
&:last-child {
margin-bottom: 0;
}
}
strong {
font-weight: bold;
}
em {
font-style: italic;
}
u {
text-decoration: underline;
}
}
}
}

View File

@@ -2,6 +2,7 @@ import { Star } from "lucide-react";
import { useTranslation } from "react-i18next";
import { EditorContent, Editor } from "@tiptap/react";
import { Button } from "@renderer/components";
import "./review-form.scss";
interface ReviewFormProps {
editor: Editor | null;

View File

@@ -1,6 +1,237 @@
@use "../../scss/globals.scss";
.game-details {
&__review-item {
overflow: hidden;
word-wrap: break-word;
}
&__review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: calc(globals.$spacing-unit * 1.5);
}
&__review-user {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 1);
}
&__review-user-info {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 0.45);
}
&__review-meta-row {
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.75);
}
&__review-display-name {
color: rgba(255, 255, 255, 0.9);
font-size: globals.$small-font-size;
font-weight: 600;
display: inline-flex;
&--clickable {
cursor: pointer;
transition: color 0.2s ease;
&:hover {
text-decoration: underline;
}
}
}
&__review-actions {
margin-top: 12px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
}
&__review-votes {
display: flex;
gap: 12px;
}
&__vote-button {
display: flex;
align-items: center;
gap: 6px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 6px 12px;
color: #ccc;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
&--active {
&.game-details__vote-button--upvote {
svg {
fill: white;
}
}
&.game-details__vote-button--downvote {
svg {
fill: white;
}
}
}
span {
font-weight: 500;
display: inline-block;
min-width: 1ch;
overflow: hidden;
}
}
&__delete-review-button {
display: flex;
align-items: center;
justify-content: center;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 6px;
padding: 6px;
color: #f44336;
cursor: pointer;
transition: all 0.2s ease;
gap: 6px;
&:hover {
background: rgba(244, 67, 54, 0.2);
border-color: #f44336;
color: #ff5722;
}
}
&__blocked-review-simple {
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
display: flex;
align-items: center;
gap: calc(globals.$spacing-unit * 0.5);
}
&__blocked-review-show-link {
background: none;
border: none;
color: #ffc107;
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: #ffeb3b;
}
}
&__blocked-review-hide-link {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: globals.$small-font-size;
cursor: pointer;
text-decoration: underline;
padding: 0;
transition: color 0.2s ease;
&:hover {
color: rgba(255, 255, 255, 0.8);
}
}
&__review-score-stars {
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 11px;
font-weight: 500;
}
&__review-right {
display: flex;
flex-direction: column;
align-items: flex-end;
}
&__review-star {
color: rgba(255, 255, 255, 0.7);
transition: color 0.2s ease;
cursor: default;
&--filled {
color: rgba(255, 255, 255, 0.7);
}
&--empty {
color: #666666;
}
svg {
fill: currentColor;
}
}
&__review-date {
display: flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.6);
font-size: globals.$small-font-size;
}
&__review-playtime {
display: flex;
align-items: center;
gap: 4px;
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
font-weight: 500;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 2px 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
margin-top: 0;
svg {
color: rgba(255, 255, 255, 0.6);
}
}
&__review-content {
color: globals.$body-color;
line-height: 1.5;
word-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
max-width: 100%;
}
&__review-translation-toggle {
display: inline-flex;
align-items: center;

View File

@@ -7,9 +7,10 @@ import { useState } from "react";
import type { GameReview } from "@types";
import { sanitizeHtml } from "@shared";
import { useDate } from "@renderer/hooks";
import { useDate, useFormat } from "@renderer/hooks";
import { formatNumber } from "@renderer/helpers";
import { Avatar } from "@renderer/components";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import "./review-item.scss";
@@ -29,13 +30,6 @@ interface ReviewItemProps {
) => void;
}
const getScoreColorClass = (score: number): string => {
if (score >= 1 && score <= 2) return "game-details__review-score--red";
if (score >= 3 && score <= 3) return "game-details__review-score--yellow";
if (score >= 4 && score <= 5) return "game-details__review-score--green";
return "";
};
const getRatingText = (score: number, t: (key: string) => string): string => {
switch (score) {
case 1:
@@ -68,28 +62,22 @@ export function ReviewItem({
const navigate = useNavigate();
const { t, i18n } = useTranslation("game_details");
const { formatDistance } = useDate();
const { numberFormatter } = useFormat();
const [showOriginal, setShowOriginal] = useState(false);
// Check if this is the user's own review
const isOwnReview = userDetailsId === review.user.id;
// Helper to get base language code (e.g., "pt" from "pt-BR")
const getBaseLanguage = (lang: string) => lang.split("-")[0];
const getBaseLanguage = (lang: string | null) => lang?.split("-")[0] || "";
// Check if the review is in a different language (comparing base language codes)
const isDifferentLanguage =
getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language);
// Check if translation is available and needed (but not for own reviews)
const needsTranslation =
!isOwnReview &&
isDifferentLanguage &&
review.translations &&
review.translations[i18n.language];
!isOwnReview && isDifferentLanguage && review.translations[i18n.language];
// Get the full language name using Intl.DisplayNames
const getLanguageName = (languageCode: string) => {
const getLanguageName = (languageCode: string | null) => {
if (!languageCode) return "";
try {
const displayNames = new Intl.DisplayNames([i18n.language], {
type: "language",
@@ -100,6 +88,20 @@ export function ReviewItem({
}
};
// Format playtime similar to hero panel
const formatPlayTime = (playTimeInSeconds: number) => {
const minutes = playTimeInSeconds / 60;
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
return t("amount_minutes", {
amount: minutes.toFixed(0),
});
}
const hours = minutes / 60;
return t("amount_hours", { amount: numberFormatter.format(hours) });
};
// Determine which content to show - always show original for own reviews
const displayContent = needsTranslation
? review.translations[i18n.language]
@@ -109,12 +111,12 @@ export function ReviewItem({
return (
<div className="game-details__review-item">
<div className="game-details__blocked-review-simple">
Review from blocked user {" "}
{t("review_from_blocked_user")}
<button
className="game-details__blocked-review-show-link"
onClick={() => onToggleVisibility(review.id)}
>
Show
{t("show")}
</button>
</div>
</div>
@@ -144,34 +146,40 @@ export function ReviewItem({
>
{review.user.displayName || "Anonymous"}
</button>
<div className="game-details__review-date">
<ClockIcon size={12} />
{formatDistance(new Date(review.createdAt), new Date(), {
addSuffix: true,
})}
<div className="game-details__review-meta-row">
<div
className="game-details__review-score-stars"
title={getRatingText(review.score, t)}
>
<Star
size={12}
className="game-details__review-star game-details__review-star--filled"
/>
<span className="game-details__review-score-text">
{review.score}/5
</span>
</div>
{Boolean(
review.playTimeInSeconds && review.playTimeInSeconds > 0
) && (
<div className="game-details__review-playtime">
<ClockIcon size={12} />
<span>
{t("review_played_for")}{" "}
{formatPlayTime(review.playTimeInSeconds || 0)}
</span>
</div>
)}
</div>
</div>
</div>
<div
className="game-details__review-score-stars"
title={getRatingText(review.score, t)}
>
{[1, 2, 3, 4, 5].map((starValue) => (
<Star
key={starValue}
size={20}
fill={starValue <= review.score ? "currentColor" : "none"}
className={`game-details__review-star ${
starValue <= review.score
? "game-details__review-star--filled"
: "game-details__review-star--empty"
} ${
starValue <= review.score
? getScoreColorClass(review.score)
: ""
}`}
/>
))}
<div className="game-details__review-right">
<div className="game-details__review-date">
<ClockIcon size={12} />
{formatDistance(new Date(review.createdAt), new Date(), {
addSuffix: true,
})}
</div>
</div>
</div>
<div>
@@ -323,7 +331,7 @@ export function ReviewItem({
className="game-details__blocked-review-hide-link"
onClick={() => onToggleVisibility(review.id)}
>
Hide
{t("hide")}
</button>
)}
</div>

View File

@@ -40,14 +40,20 @@ export default function Home() {
setCurrentCatalogueCategory(category);
setIsLoading(true);
const params = new URLSearchParams({
take: "12",
skip: "0",
});
const downloadSources = await window.electron.getDownloadSources();
const params = {
take: 12,
skip: 0,
downloadSourceIds: downloadSources.map((source) => source.id),
};
const catalogue = await window.electron.hydraApi.get<ShopAssets[]>(
`/catalogue/${category}?${params.toString()}`,
{ needsAuth: false }
`/catalogue/${category}`,
{
params,
needsAuth: false,
}
);
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));

View File

@@ -38,4 +38,11 @@
animation: spin 1s linear infinite;
margin-right: calc(globals.$spacing-unit / 2);
}
&__actions {
display: flex;
justify-content: flex-end;
gap: globals.$spacing-unit;
margin-top: calc(globals.$spacing-unit * 2);
}
}

View File

@@ -1,15 +1,13 @@
import { useCallback, useContext, useEffect, useState } from "react";
import { useContext, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Modal, TextField } from "@renderer/components";
import { settingsContext } from "@renderer/context";
import { useForm } from "react-hook-form";
import { useAppDispatch } from "@renderer/hooks";
import { logger } from "@renderer/logger";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import type { DownloadSourceValidationResult } from "@types";
import { setIsImportingSources } from "@renderer/features";
import { SyncIcon } from "@primer/octicons-react";
import "./add-download-source-modal.scss";
@@ -28,7 +26,6 @@ export function AddDownloadSourceModal({
onClose,
onAddDownloadSource,
}: Readonly<AddDownloadSourceModalProps>) {
const [url, setUrl] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation("settings");
@@ -48,77 +45,43 @@ export function AddDownloadSourceModal({
resolver: yupResolver(schema),
});
const [validationResult, setValidationResult] =
useState<DownloadSourceValidationResult | null>(null);
const { sourceUrl } = useContext(settingsContext);
const dispatch = useAppDispatch();
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
const onSubmit = useCallback(
async (values: FormValues) => {
const exists = await window.electron.checkDownloadSourceExists(
values.url
);
try {
await window.electron.addDownloadSource(values.url);
if (exists) {
setError("url", {
type: "server",
message: t("source_already_exists"),
});
onClose();
onAddDownloadSource();
} catch (error) {
logger.error("Failed to add download source:", error);
const errorMessage =
error instanceof Error && error.message.includes("already exists")
? t("download_source_already_exists")
: t("failed_add_download_source");
return;
}
const validationResult = await window.electron.validateDownloadSource(
values.url
);
setValidationResult(validationResult);
setUrl(values.url);
},
[setError, t]
);
setError("url", {
type: "server",
message: errorMessage,
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
setValue("url", "");
clearErrors();
setIsLoading(false);
setValidationResult(null);
if (sourceUrl) {
setValue("url", sourceUrl);
handleSubmit(onSubmit)();
}
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
const handleAddDownloadSource = async () => {
if (validationResult) {
setIsLoading(true);
dispatch(setIsImportingSources(true));
try {
// Single call that handles: import → API sync → fingerprint
await window.electron.addDownloadSource(url);
// Close modal and update UI
onClose();
onAddDownloadSource();
} catch (error) {
console.error("Failed to add download source:", error);
setError("url", {
type: "server",
message: "Failed to import source. Please try again.",
});
} finally {
setIsLoading(false);
dispatch(setIsImportingSources(false));
}
}
};
}, [visible, clearErrors, setValue, sourceUrl]);
const handleClose = () => {
// Prevent closing while importing
if (isLoading) return;
onClose();
};
@@ -132,49 +95,32 @@ export function AddDownloadSourceModal({
clickOutsideToClose={!isLoading}
>
<div className="add-download-source-modal__container">
<TextField
{...register("url")}
label={t("download_source_url")}
placeholder={t("insert_valid_json_url")}
error={errors.url?.message}
rightContent={
<form onSubmit={handleSubmit(onSubmit)}>
<TextField
{...register("url")}
label={t("download_source_url")}
placeholder={t("insert_valid_json_url")}
error={errors.url?.message}
/>
<div className="add-download-source-modal__actions">
<Button
type="button"
theme="outline"
className="add-download-source-modal__validate-button"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting || isLoading}
>
{t("validate_download_source")}
</Button>
}
/>
{validationResult && (
<div className="add-download-source-modal__validation-result">
<div className="add-download-source-modal__validation-info">
<h4>{validationResult?.name}</h4>
<small>
{t("found_download_option", {
count: validationResult?.downloadCount,
countFormatted:
validationResult?.downloadCount.toLocaleString(),
})}
</small>
</div>
<Button
type="button"
onClick={handleAddDownloadSource}
onClick={handleClose}
disabled={isLoading}
>
{t("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting || isLoading}>
{isLoading && (
<SyncIcon className="add-download-source-modal__spinner" />
)}
{isLoading ? t("importing") : t("import")}
{isLoading ? t("adding") : t("add_download_source")}
</Button>
</div>
)}
</form>
</div>
</Modal>
);

View File

@@ -201,7 +201,7 @@ export function SettingsAccount() {
</section>
<section className="settings-account__section">
<h3>Hydra Cloud</h3>
<h3>{t("hydra_cloud")}</h3>
<div className="settings-account__subscription-info">
{getHydraCloudSectionContent().description}
</div>

View File

@@ -27,6 +27,8 @@ export function SettingsBehavior() {
showDownloadSpeedInMegabytes: false,
extractFilesByDefault: true,
enableSteamAchievements: false,
autoplayGameTrailers: true,
hideToTrayOnGameStart: false,
});
const { t } = useTranslation("settings");
@@ -49,6 +51,8 @@ export function SettingsBehavior() {
extractFilesByDefault: userPreferences.extractFilesByDefault ?? true,
enableSteamAchievements:
userPreferences.enableSteamAchievements ?? false,
autoplayGameTrailers: userPreferences.autoplayGameTrailers ?? true,
hideToTrayOnGameStart: userPreferences.hideToTrayOnGameStart ?? false,
});
}
}, [userPreferences]);
@@ -76,6 +80,16 @@ export function SettingsBehavior() {
}
/>
<CheckboxField
label={t("hide_to_tray_on_game_start")}
checked={form.hideToTrayOnGameStart}
onChange={() =>
handleChange({
hideToTrayOnGameStart: !form.hideToTrayOnGameStart,
})
}
/>
{showRunAtStartup && (
<CheckboxField
label={t("launch_with_system")}
@@ -120,6 +134,14 @@ export function SettingsBehavior() {
/>
)}
<CheckboxField
label={t("autoplay_trailers_on_game_page")}
checked={form.autoplayGameTrailers}
onChange={() =>
handleChange({ autoplayGameTrailers: !form.autoplayGameTrailers })
}
/>
<CheckboxField
label={t("disable_nsfw_alert")}
checked={form.disableNsfwAlert}

View File

@@ -1,5 +1,14 @@
@use "../../scss/globals.scss";
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.settings-download-sources {
&__list {
padding: 0;
@@ -22,6 +31,17 @@
&--syncing {
opacity: globals.$disabled-opacity;
}
&--pending {
opacity: 0.6;
}
}
&__spinner {
animation: spin 1s linear infinite;
margin-right: calc(globals.$spacing-unit / 2);
width: 12px;
height: 12px;
}
&__item-header {

View File

@@ -16,12 +16,13 @@ import {
TrashIcon,
} from "@primer/octicons-react";
import { AddDownloadSourceModal } from "./add-download-source-modal";
import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
import { useAppDispatch, useToast } from "@renderer/hooks";
import { DownloadSourceStatus } from "@shared";
import { settingsContext } from "@renderer/context";
import { useNavigate } from "react-router-dom";
import { setFilters, clearFilters } from "@renderer/features";
import "./settings-download-sources.scss";
import { logger } from "@renderer/logger";
export function SettingsDownloadSources() {
const [
@@ -35,7 +36,6 @@ export function SettingsDownloadSources() {
useState(false);
const [isRemovingDownloadSource, setIsRemovingDownloadSource] =
useState(false);
const [isFetchingSources, setIsFetchingSources] = useState(true);
const { sourceUrl, clearSourceUrl } = useContext(settingsContext);
@@ -46,37 +46,53 @@ export function SettingsDownloadSources() {
const navigate = useNavigate();
const { updateRepacks } = useRepacks();
const getDownloadSources = async () => {
await window.electron
.getDownloadSourcesList()
.then((sources) => {
setDownloadSources(sources);
})
.finally(() => {
setIsFetchingSources(false);
});
};
useEffect(() => {
getDownloadSources();
}, []);
useEffect(() => {
if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]);
useEffect(() => {
const fetchDownloadSources = async () => {
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
};
fetchDownloadSources();
}, []);
useEffect(() => {
const hasPendingOrMatchingSource = downloadSources.some(
(source) =>
source.status === DownloadSourceStatus.PendingMatching ||
source.status === DownloadSourceStatus.Matching
);
if (!hasPendingOrMatchingSource || !downloadSources.length) {
return;
}
const intervalId = setInterval(async () => {
try {
await window.electron.syncDownloadSources();
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
} catch (error) {
logger.error("Failed to fetch download sources:", error);
}
}, 5000);
return () => clearInterval(intervalId);
}, [downloadSources]);
const handleRemoveSource = async (downloadSource: DownloadSource) => {
setIsRemovingDownloadSource(true);
try {
await window.electron.deleteDownloadSource(downloadSource.id);
await window.electron.removeDownloadSource(downloadSource.url);
await window.electron.removeDownloadSource(false, downloadSource.id);
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
showSuccessToast(t("removed_download_source"));
await getDownloadSources();
updateRepacks();
} catch (error) {
logger.error("Failed to remove download source:", error);
} finally {
setIsRemovingDownloadSource(false);
}
@@ -86,53 +102,47 @@ export function SettingsDownloadSources() {
setIsRemovingDownloadSource(true);
try {
await window.electron.deleteAllDownloadSources();
await window.electron.removeDownloadSource("", true);
showSuccessToast(t("removed_download_sources"));
await getDownloadSources();
setShowConfirmationDeleteAllSourcesModal(false);
updateRepacks();
await window.electron.removeDownloadSource(true);
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
showSuccessToast(t("removed_all_download_sources"));
} catch (error) {
logger.error("Failed to remove all download sources:", error);
} finally {
setIsRemovingDownloadSource(false);
setShowConfirmationDeleteAllSourcesModal(false);
}
};
const handleAddDownloadSource = async () => {
// Refresh sources list and repacks after import completes
await getDownloadSources();
// Force repacks update to ensure UI reflects new data
await updateRepacks();
showSuccessToast(t("added_download_source"));
try {
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
} catch (error) {
logger.error("Failed to refresh download sources:", error);
}
};
const syncDownloadSources = async () => {
setIsSyncingDownloadSources(true);
try {
// Sync local sources (check for updates)
await window.electron.syncDownloadSources();
const sources = await window.electron.getDownloadSources();
setDownloadSources(sources);
// Refresh sources and repacks AFTER sync completes
await getDownloadSources();
await updateRepacks();
showSuccessToast(t("download_sources_synced"));
} catch (error) {
console.error("Error syncing download sources:", error);
// Still refresh the UI even if sync fails
await getDownloadSources();
await updateRepacks();
showSuccessToast(t("download_sources_synced_successfully"));
} finally {
setIsSyncingDownloadSources(false);
}
};
const statusTitle = {
[DownloadSourceStatus.UpToDate]: t("download_source_up_to_date"),
[DownloadSourceStatus.Errored]: t("download_source_errored"),
[DownloadSourceStatus.PendingMatching]: t(
"download_source_pending_matching"
),
[DownloadSourceStatus.Matched]: t("download_source_matched"),
[DownloadSourceStatus.Matching]: t("download_source_matching"),
[DownloadSourceStatus.Failed]: t("download_source_failed"),
};
const handleModalClose = () => {
@@ -142,7 +152,7 @@ export function SettingsDownloadSources() {
const navigateToCatalogue = (fingerprint?: string) => {
if (!fingerprint) {
console.error("Cannot navigate: fingerprint is undefined");
logger.error("Cannot navigate: fingerprint is undefined");
return;
}
@@ -180,8 +190,7 @@ export function SettingsDownloadSources() {
disabled={
!downloadSources.length ||
isSyncingDownloadSources ||
isRemovingDownloadSource ||
isFetchingSources
isRemovingDownloadSource
}
onClick={syncDownloadSources}
>
@@ -197,8 +206,7 @@ export function SettingsDownloadSources() {
disabled={
isRemovingDownloadSource ||
isSyncingDownloadSources ||
!downloadSources.length ||
isFetchingSources
!downloadSources.length
}
>
<TrashIcon />
@@ -209,11 +217,7 @@ export function SettingsDownloadSources() {
type="button"
theme="outline"
onClick={() => setShowAddDownloadSourceModal(true)}
disabled={
isSyncingDownloadSources ||
isFetchingSources ||
isRemovingDownloadSource
}
disabled={isSyncingDownloadSources || isRemovingDownloadSource}
>
<PlusCircleIcon />
{t("add_download_source")}
@@ -223,16 +227,25 @@ export function SettingsDownloadSources() {
<ul className="settings-download-sources__list">
{downloadSources.map((downloadSource) => {
const isPendingOrMatching =
downloadSource.status === DownloadSourceStatus.PendingMatching ||
downloadSource.status === DownloadSourceStatus.Matching;
return (
<li
key={downloadSource.id}
className={`settings-download-sources__item ${isSyncingDownloadSources ? "settings-download-sources__item--syncing" : ""}`}
className={`settings-download-sources__item ${isSyncingDownloadSources ? "settings-download-sources__item--syncing" : ""} ${isPendingOrMatching ? "settings-download-sources__item--pending" : ""}`}
>
<div className="settings-download-sources__item-header">
<h2>{downloadSource.name}</h2>
<div style={{ display: "flex" }}>
<Badge>{statusTitle[downloadSource.status]}</Badge>
<Badge>
{isPendingOrMatching && (
<SyncIcon className="settings-download-sources__spinner" />
)}
{statusTitle[downloadSource.status]}
</Badge>
</div>
<button
@@ -244,11 +257,13 @@ export function SettingsDownloadSources() {
}
>
<small>
{t("download_count", {
count: downloadSource.downloadCount,
countFormatted:
downloadSource.downloadCount.toLocaleString(),
})}
{isPendingOrMatching
? t("download_source_no_information")
: t("download_count", {
count: downloadSource.downloadCount,
countFormatted:
downloadSource.downloadCount.toLocaleString(),
})}
</small>
</button>
</div>

View File

@@ -133,7 +133,7 @@ export function SettingsRealDebrid() {
{t("save_changes")}
</Button>
}
placeholder="API Token"
placeholder={t("api_token")}
hint={
<Trans i18nKey="debrid_api_token_hint" ns="settings">
<Link to={REAL_DEBRID_API_TOKEN_URL} />

View File

@@ -116,7 +116,7 @@ export function SettingsTorBox() {
onChange={(event) =>
setForm({ ...form, torBoxApiToken: event.target.value })
}
placeholder="API Token"
placeholder={t("api_token")}
rightContent={
<Button type="submit" disabled={isButtonDisabled}>
{t("save_changes")}

View File

@@ -8,8 +8,6 @@ import {
userDetailsSlice,
gameRunningSlice,
subscriptionSlice,
repacksSlice,
downloadSourcesSlice,
catalogueSearchSlice,
} from "@renderer/features";
@@ -23,8 +21,6 @@ export const store = configureStore({
userDetails: userDetailsSlice.reducer,
gameRunning: gameRunningSlice.reducer,
subscription: subscriptionSlice.reducer,
repacks: repacksSlice.reducer,
downloadSources: downloadSourcesSlice.reducer,
catalogueSearch: catalogueSearchSlice.reducer,
},
});