mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-19 01:03:57 +00:00
Merge branch 'main' into refactor/remove-unnecessary-usememo
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -98,6 +98,11 @@ export function CloudSyncContextProvider({
|
||||
);
|
||||
|
||||
const getGameArtifacts = useCallback(async () => {
|
||||
if (shop === "custom") {
|
||||
setArtifacts([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
objectId,
|
||||
shop,
|
||||
|
||||
@@ -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,34 @@ export function GameDetailsContextProvider({
|
||||
};
|
||||
}, [objectId, shop, userDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
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 +364,7 @@ export function GameDetailsContextProvider({
|
||||
stats,
|
||||
achievements,
|
||||
hasNSFWContentBlocked,
|
||||
lastDownloadedOption,
|
||||
lastDownloadedOption: null,
|
||||
setHasNSFWContentBlocked,
|
||||
selectGameExecutable,
|
||||
updateGame,
|
||||
|
||||
22
src/renderer/src/declaration.d.ts
vendored
22
src/renderer/src/declaration.d.ts
vendored
@@ -31,8 +31,6 @@ import type {
|
||||
Game,
|
||||
DiskUsage,
|
||||
DownloadSource,
|
||||
DownloadSourceValidationResult,
|
||||
GameRepack,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
|
||||
@@ -210,20 +208,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>;
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(
|
||||
(state) => state.catalogueSearch
|
||||
);
|
||||
|
||||
const [downloadSources, setDownloadSources] = useState<DownloadSource[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [results, setResults] = useState<CatalogueSearchResult[]>([]);
|
||||
@@ -51,24 +54,41 @@ export default function Catalogue() {
|
||||
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 +99,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 +187,7 @@ export default function Catalogue() {
|
||||
value: publisher,
|
||||
})),
|
||||
];
|
||||
}, [filters, steamUserTags, steamGenresMapping, language, downloadSources]);
|
||||
}, [filters, steamUserTags, downloadSources, steamGenresMapping, language]);
|
||||
|
||||
const filterSections = useMemo(() => {
|
||||
return [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,51 @@
|
||||
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="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
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;
|
||||
@@ -16,20 +59,82 @@ export function Pagination({
|
||||
}: 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 val = e.target.value;
|
||||
if (val === "") {
|
||||
setJumpValue("");
|
||||
return;
|
||||
}
|
||||
const num = Number(val);
|
||||
if (Number.isNaN(num)) {
|
||||
return;
|
||||
}
|
||||
if (num < 1) {
|
||||
setJumpValue("1");
|
||||
return;
|
||||
}
|
||||
if (num > totalPages) {
|
||||
setJumpValue(String(totalPages));
|
||||
return;
|
||||
}
|
||||
setJumpValue(val);
|
||||
};
|
||||
|
||||
const onJumpKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
if (jumpValue.trim() === "") return;
|
||||
const parsed = Number(jumpValue);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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 +144,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 +180,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 +212,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
116
src/renderer/src/pages/game-details/game-reviews.scss
Normal file
116
src/renderer/src/pages/game-details/game-reviews.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -440,8 +439,6 @@ export function GameReviews({
|
||||
});
|
||||
}, [reviews]);
|
||||
|
||||
console.log("reviews", reviews);
|
||||
|
||||
return (
|
||||
<div className="game-details__reviews-section">
|
||||
{showReviewPrompt &&
|
||||
@@ -469,84 +466,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>
|
||||
);
|
||||
}
|
||||
|
||||
274
src/renderer/src/pages/game-details/hero.scss
Normal file
274
src/renderer/src/pages/game-details/hero.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
232
src/renderer/src/pages/game-details/review-form.scss
Normal file
232
src/renderer/src/pages/game-details/review-form.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,213 @@
|
||||
@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.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%;
|
||||
}
|
||||
|
||||
&__review-translation-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 as DownloadSource[]);
|
||||
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 as DownloadSource[]);
|
||||
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 as DownloadSource[]);
|
||||
} 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 as DownloadSource[]);
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user