mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-19 17:23:57 +00:00
Merge branch 'main' into refactor/remove-unnecessary-usememo
This commit is contained in:
@@ -20,14 +20,12 @@ import {
|
||||
setUserDetails,
|
||||
setProfileBackground,
|
||||
setGameRunning,
|
||||
setIsImportingSources,
|
||||
} from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
|
||||
import { downloadSourcesWorker } from "./workers";
|
||||
import { downloadSourcesTable } from "./dexie";
|
||||
import { useSubscription } from "./hooks/use-subscription";
|
||||
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
|
||||
import { generateUUID } from "./helpers";
|
||||
|
||||
import { injectCustomCss, removeCustomCss } from "./helpers";
|
||||
import "./app.scss";
|
||||
@@ -137,15 +135,6 @@ export function App() {
|
||||
}, [fetchUserDetails, updateUserDetails, dispatch]);
|
||||
|
||||
const onSignIn = useCallback(() => {
|
||||
window.electron.getDownloadSources().then((sources) => {
|
||||
sources.forEach((source) => {
|
||||
downloadSourcesWorker.postMessage([
|
||||
"IMPORT_DOWNLOAD_SOURCE",
|
||||
source.url,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
fetchUserDetails().then((response) => {
|
||||
if (response) {
|
||||
updateUserDetails(response);
|
||||
@@ -211,41 +200,34 @@ export function App() {
|
||||
}, [dispatch, draggingDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
updateRepacks();
|
||||
(async () => {
|
||||
dispatch(setIsImportingSources(true));
|
||||
|
||||
const id = generateUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
try {
|
||||
// Initial repacks load
|
||||
await updateRepacks();
|
||||
|
||||
channel.onmessage = async (event: MessageEvent<number>) => {
|
||||
const newRepacksCount = event.data;
|
||||
window.electron.publishNewRepacksNotification(newRepacksCount);
|
||||
updateRepacks();
|
||||
// Sync all local sources (check for updates)
|
||||
const newRepacksCount = await window.electron.syncDownloadSources();
|
||||
|
||||
const downloadSources = await downloadSourcesTable.toArray();
|
||||
if (newRepacksCount > 0) {
|
||||
window.electron.publishNewRepacksNotification(newRepacksCount);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
downloadSources
|
||||
.filter((source) => !source.fingerprint)
|
||||
.map(async (downloadSource) => {
|
||||
const { fingerprint } = await window.electron.putDownloadSource(
|
||||
downloadSource.objectIds
|
||||
);
|
||||
// Update fingerprints for sources that don't have them
|
||||
await window.electron.updateMissingFingerprints();
|
||||
|
||||
return downloadSourcesTable.update(downloadSource.id, {
|
||||
fingerprint,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
channel.close();
|
||||
};
|
||||
|
||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||
|
||||
return () => {
|
||||
channel.close();
|
||||
};
|
||||
}, [updateRepacks]);
|
||||
// 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();
|
||||
|
||||
@@ -302,7 +302,8 @@ $margin-bottom: 28px;
|
||||
}
|
||||
|
||||
&--rare &__trophy-overlay {
|
||||
background: linear-gradient(
|
||||
background:
|
||||
linear-gradient(
|
||||
118deg,
|
||||
#e8ad15 18.96%,
|
||||
#d5900f 26.41%,
|
||||
|
||||
@@ -109,12 +109,7 @@ export function GameCard({ game, ...props }: GameCardProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="game-card__specifics-item">
|
||||
<StarRating
|
||||
rating={stats?.averageScore || null}
|
||||
size={14}
|
||||
showCalculating={!!(stats && stats.averageScore === null)}
|
||||
calculatingText={t("calculating")}
|
||||
/>
|
||||
<StarRating rating={stats?.averageScore || null} size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(globals.$spacing-unit * 3);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
min-width: 500px;
|
||||
}
|
||||
|
||||
&__form {
|
||||
|
||||
@@ -1,76 +1,31 @@
|
||||
import { StarIcon, StarFillIcon } from "@primer/octicons-react";
|
||||
import { StarFillIcon } from "@primer/octicons-react";
|
||||
import "./star-rating.scss";
|
||||
|
||||
export interface StarRatingProps {
|
||||
rating: number | null;
|
||||
maxStars?: number;
|
||||
size?: number;
|
||||
showCalculating?: boolean;
|
||||
calculatingText?: string;
|
||||
hideIcon?: boolean;
|
||||
}
|
||||
|
||||
export function StarRating({
|
||||
rating,
|
||||
maxStars = 5,
|
||||
size = 12,
|
||||
showCalculating = false,
|
||||
calculatingText = "Calculating",
|
||||
hideIcon = false,
|
||||
}: Readonly<StarRatingProps>) {
|
||||
if (rating === null && showCalculating) {
|
||||
return (
|
||||
<div className="star-rating star-rating--calculating">
|
||||
{!hideIcon && <StarIcon size={size} />}
|
||||
<span className="star-rating__calculating-text">{calculatingText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StarRating({ rating, size = 12 }: Readonly<StarRatingProps>) {
|
||||
if (rating === null || rating === undefined) {
|
||||
return (
|
||||
<div className="star-rating star-rating--no-rating">
|
||||
{!hideIcon && <StarIcon size={size} />}
|
||||
<span className="star-rating__no-rating-text">…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filledStars = Math.floor(rating);
|
||||
const hasHalfStar = rating % 1 >= 0.5;
|
||||
const emptyStars = maxStars - filledStars - (hasHalfStar ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="star-rating">
|
||||
{Array.from({ length: filledStars }, (_, index) => (
|
||||
<div className="star-rating star-rating--single">
|
||||
<StarFillIcon
|
||||
key={`filled-${index}`}
|
||||
size={size}
|
||||
className="star-rating__star star-rating__star--filled"
|
||||
/>
|
||||
))}
|
||||
|
||||
{hasHalfStar && (
|
||||
<div className="star-rating__half-star" key="half-star">
|
||||
<StarIcon
|
||||
size={size}
|
||||
className="star-rating__star star-rating__star--empty"
|
||||
/>
|
||||
<StarFillIcon
|
||||
size={size}
|
||||
className="star-rating__star star-rating__star--half"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array.from({ length: emptyStars }, (_, index) => (
|
||||
<StarIcon
|
||||
key={`empty-${index}`}
|
||||
size={size}
|
||||
className="star-rating__star star-rating__star--empty"
|
||||
/>
|
||||
))}
|
||||
<span className="star-rating__value">…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Always use single star mode with numeric score
|
||||
return (
|
||||
<div className="star-rating star-rating--single">
|
||||
<StarFillIcon
|
||||
size={size}
|
||||
className="star-rating__star star-rating__star--filled"
|
||||
/>
|
||||
<span className="star-rating__value">{rating.toFixed(1)}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ export const DOWNLOADER_NAME = {
|
||||
[Downloader.Datanodes]: "Datanodes",
|
||||
[Downloader.Mediafire]: "Mediafire",
|
||||
[Downloader.TorBox]: "TorBox",
|
||||
[Downloader.AllDebrid]: "All-Debrid",
|
||||
[Downloader.Hydra]: "Nimbus",
|
||||
};
|
||||
|
||||
|
||||
@@ -66,10 +66,7 @@ export function UserProfileContextProvider({
|
||||
const isMe = userDetails?.id === userProfile?.id;
|
||||
|
||||
const getHeroBackgroundFromImageUrl = async (imageUrl: string) => {
|
||||
const output = await average(imageUrl, {
|
||||
amount: 1,
|
||||
format: "hex",
|
||||
});
|
||||
const output = await average(imageUrl, { amount: 1, format: "hex" });
|
||||
|
||||
return `linear-gradient(135deg, ${darkenColor(output as string, 0.5)}, ${darkenColor(output as string, 0.6, 0.5)})`;
|
||||
};
|
||||
@@ -135,28 +132,25 @@ export function UserProfileContextProvider({
|
||||
getUserLibraryGames();
|
||||
|
||||
return window.electron.hydraApi
|
||||
.get<UserProfile | null>(`/users/${userId}`)
|
||||
.get<UserProfile>(`/users/${userId}`)
|
||||
.then((userProfile) => {
|
||||
if (userProfile) {
|
||||
setUserProfile(userProfile);
|
||||
setUserProfile(userProfile);
|
||||
|
||||
if (userProfile.profileImageUrl) {
|
||||
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
|
||||
(color) => setHeroBackground(color)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
showErrorToast(t("user_not_found"));
|
||||
navigate(-1);
|
||||
if (userProfile.profileImageUrl) {
|
||||
getHeroBackgroundFromImageUrl(userProfile.profileImageUrl).then(
|
||||
(color) => setHeroBackground(color)
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
showErrorToast(t("user_not_found"));
|
||||
navigate(-1);
|
||||
});
|
||||
}, [navigate, getUserStats, getUserLibraryGames, showErrorToast, userId, t]);
|
||||
|
||||
const getBadges = useCallback(async () => {
|
||||
const language = i18n.language.split("-")[0];
|
||||
const params = new URLSearchParams({
|
||||
locale: language,
|
||||
});
|
||||
const params = new URLSearchParams({ locale: language });
|
||||
|
||||
const badges = await window.electron.hydraApi.get<Badge[]>(
|
||||
`/badges?${params.toString()}`,
|
||||
|
||||
22
src/renderer/src/declaration.d.ts
vendored
22
src/renderer/src/declaration.d.ts
vendored
@@ -8,7 +8,6 @@ import type {
|
||||
UserPreferences,
|
||||
StartGameDownloadPayload,
|
||||
RealDebridUser,
|
||||
AllDebridUser,
|
||||
UserProfile,
|
||||
FriendRequestAction,
|
||||
UpdateProfileRequest,
|
||||
@@ -31,6 +30,9 @@ import type {
|
||||
AchievementNotificationInfo,
|
||||
Game,
|
||||
DiskUsage,
|
||||
DownloadSource,
|
||||
DownloadSourceValidationResult,
|
||||
GameRepack,
|
||||
} from "@types";
|
||||
import type { AxiosProgressEvent } from "axios";
|
||||
|
||||
@@ -190,9 +192,6 @@ declare global {
|
||||
) => Promise<void>;
|
||||
/* User preferences */
|
||||
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
|
||||
authenticateAllDebrid: (
|
||||
apiKey: string
|
||||
) => Promise<AllDebridUser | { error_code: string }>;
|
||||
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
|
||||
getUserPreferences: () => Promise<UserPreferences | null>;
|
||||
updateUserPreferences: (
|
||||
@@ -210,14 +209,21 @@ declare global {
|
||||
createSteamShortcut: (shop: GameShop, objectId: string) => Promise<void>;
|
||||
|
||||
/* Download sources */
|
||||
putDownloadSource: (
|
||||
objectIds: string[]
|
||||
) => Promise<{ fingerprint: string }>;
|
||||
createDownloadSources: (urls: string[]) => Promise<void>;
|
||||
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[]>;
|
||||
|
||||
/* Hardware */
|
||||
getDiskFreeSpace: (path: string) => Promise<DiskUsage>;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { GameShop, HowLongToBeatCategory } from "@types";
|
||||
import { Dexie } from "dexie";
|
||||
|
||||
export interface HowLongToBeatEntry {
|
||||
id?: number;
|
||||
objectId: string;
|
||||
categories: HowLongToBeatCategory[];
|
||||
shop: GameShop;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export const db = new Dexie("Hydra");
|
||||
|
||||
db.version(9).stores({
|
||||
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`,
|
||||
downloadSources: `++id, &url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
|
||||
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
|
||||
});
|
||||
|
||||
export const downloadSourcesTable = db.table("downloadSources");
|
||||
export const repacksTable = db.table("repacks");
|
||||
export const howLongToBeatEntriesTable = db.table<HowLongToBeatEntry>(
|
||||
"howLongToBeatEntries"
|
||||
);
|
||||
|
||||
db.open();
|
||||
21
src/renderer/src/features/download-sources-slice.ts
Normal file
21
src/renderer/src/features/download-sources-slice.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
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;
|
||||
@@ -7,4 +7,5 @@ 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";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { repacksTable } from "@renderer/dexie";
|
||||
import { setRepacks } from "@renderer/features";
|
||||
import { useCallback } from "react";
|
||||
import { RootState } from "@renderer/store";
|
||||
@@ -16,18 +15,11 @@ export function useRepacks() {
|
||||
[repacks]
|
||||
);
|
||||
|
||||
const updateRepacks = useCallback(() => {
|
||||
repacksTable.toArray().then((repacks) => {
|
||||
dispatch(
|
||||
setRepacks(
|
||||
JSON.parse(
|
||||
JSON.stringify(
|
||||
repacks.filter((repack) => Array.isArray(repack.objectIds))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
const updateRepacks = useCallback(async () => {
|
||||
const repacks = await window.electron.getAllRepacks();
|
||||
dispatch(
|
||||
setRepacks(repacks.filter((repack) => Array.isArray(repack.objectIds)))
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
return { getRepacksForObjectId, updateRepacks };
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import type { CatalogueSearchResult, DownloadSource } from "@types";
|
||||
|
||||
import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useFormat,
|
||||
useRepacks,
|
||||
} from "@renderer/hooks";
|
||||
import { useAppDispatch, useAppSelector, useFormat } from "@renderer/hooks";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import "./catalogue.scss";
|
||||
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { FilterSection } from "./filter-section";
|
||||
import { setFilters, setPage } from "@renderer/features";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -56,8 +50,6 @@ export default function Catalogue() {
|
||||
|
||||
const { t, i18n } = useTranslation("catalogue");
|
||||
|
||||
const { getRepacksForObjectId } = useRepacks();
|
||||
|
||||
const debouncedSearch = useRef(
|
||||
debounce(async (filters, pageSize, offset) => {
|
||||
const abortController = new AbortController();
|
||||
@@ -95,10 +87,10 @@ export default function Catalogue() {
|
||||
}, [filters, page, debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
downloadSourcesTable.toArray().then((sources) => {
|
||||
window.electron.getDownloadSourcesList().then((sources) => {
|
||||
setDownloadSources(sources.filter((source) => !!source.fingerprint));
|
||||
});
|
||||
}, [getRepacksForObjectId]);
|
||||
}, []);
|
||||
|
||||
const language = i18n.language.split("-")[0];
|
||||
|
||||
@@ -192,13 +184,15 @@ export default function Catalogue() {
|
||||
},
|
||||
{
|
||||
title: t("download_sources"),
|
||||
items: downloadSources.map((source) => ({
|
||||
label: source.name,
|
||||
value: source.fingerprint,
|
||||
checked: filters.downloadSourceFingerprints.includes(
|
||||
source.fingerprint
|
||||
),
|
||||
})),
|
||||
items: downloadSources
|
||||
.filter((source) => source.fingerprint)
|
||||
.map((source) => ({
|
||||
label: source.name,
|
||||
value: source.fingerprint!,
|
||||
checked: filters.downloadSourceFingerprints.includes(
|
||||
source.fingerprint!
|
||||
),
|
||||
})),
|
||||
key: "downloadSourceFingerprints",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -114,15 +114,6 @@ export function DownloadGroup({
|
||||
return <p>{t("deleting")}</p>;
|
||||
}
|
||||
|
||||
if (download.downloader === Downloader.AllDebrid) {
|
||||
return (
|
||||
<>
|
||||
<p>{progress}</p>
|
||||
<p>{t("alldebrid_size_not_supported")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGameDownloading) {
|
||||
if (lastPacket?.isDownloadingMetadata) {
|
||||
return <p>{t("downloading_metadata")}</p>;
|
||||
@@ -190,15 +181,6 @@ export function DownloadGroup({
|
||||
}
|
||||
|
||||
if (download.status === "active") {
|
||||
if ((download.downloader as unknown as string) === "alldebrid") {
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(download.progress)}</p>
|
||||
<p>{t("alldebrid_size_not_supported")}</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p>{formatDownloadProgress(download.progress)}</p>
|
||||
@@ -293,9 +275,7 @@ export function DownloadGroup({
|
||||
(download?.downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(download?.downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken) ||
|
||||
(download?.downloader === Downloader.AllDebrid &&
|
||||
!userPreferences?.allDebridApiKey);
|
||||
!userPreferences?.torBoxApiToken);
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -25,11 +25,6 @@
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 80%;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
max-height: 500px;
|
||||
@@ -72,10 +67,6 @@
|
||||
overflow-y: hidden;
|
||||
gap: calc(globals.$spacing-unit / 2);
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
@@ -224,6 +224,12 @@ $hero-height: 300px;
|
||||
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);
|
||||
@@ -264,16 +270,6 @@ $hero-height: 300px;
|
||||
}
|
||||
|
||||
&__review-item {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
globals.$dark-background-color 0%,
|
||||
globals.$dark-background-color 30%,
|
||||
globals.$background-color 100%
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: calc(globals.$spacing-unit * 2);
|
||||
margin-bottom: calc(globals.$spacing-unit * 2);
|
||||
overflow: hidden;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function GameReviews({
|
||||
hasUserReviewed,
|
||||
onUserReviewedChange,
|
||||
}: Readonly<GameReviewsProps>) {
|
||||
const { t } = useTranslation("game_details");
|
||||
const { t, i18n } = useTranslation("game_details");
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [reviews, setReviews] = useState<GameReview[]>([]);
|
||||
@@ -129,9 +129,7 @@ export function GameReviews({
|
||||
|
||||
const twoHoursInMilliseconds = 2 * 60 * 60 * 1000;
|
||||
const hasEnoughPlaytime =
|
||||
game &&
|
||||
game.playTimeInMilliseconds >= twoHoursInMilliseconds &&
|
||||
!game.hasManuallyUpdatedPlaytime;
|
||||
game && game.playTimeInMilliseconds >= twoHoursInMilliseconds;
|
||||
|
||||
if (
|
||||
!hasReviewed &&
|
||||
@@ -146,6 +144,8 @@ export function GameReviews({
|
||||
}
|
||||
}, [objectId, userDetailsId, shop, game, onUserReviewedChange]);
|
||||
|
||||
console.log("reviews", reviews);
|
||||
|
||||
const loadReviews = useCallback(
|
||||
async (reset = false) => {
|
||||
if (!objectId) return;
|
||||
@@ -164,6 +164,7 @@ export function GameReviews({
|
||||
take: "20",
|
||||
skip: skip.toString(),
|
||||
sortBy: reviewsSortBy,
|
||||
language: i18n.language,
|
||||
});
|
||||
|
||||
const response = await window.electron.hydraApi.get(
|
||||
@@ -200,7 +201,7 @@ export function GameReviews({
|
||||
}
|
||||
}
|
||||
},
|
||||
[objectId, shop, reviewsPage, reviewsSortBy]
|
||||
[objectId, shop, reviewsPage, reviewsSortBy, i18n.language]
|
||||
);
|
||||
|
||||
const handleVoteReview = async (
|
||||
@@ -439,6 +440,8 @@ export function GameReviews({
|
||||
});
|
||||
}, [reviews]);
|
||||
|
||||
console.log("reviews", reviews);
|
||||
|
||||
return (
|
||||
<div className="game-details__reviews-section">
|
||||
{showReviewPrompt &&
|
||||
@@ -501,6 +504,7 @@ export function GameReviews({
|
||||
)}
|
||||
|
||||
<div
|
||||
className="game-details__reviews-container"
|
||||
style={{
|
||||
opacity: reviewsLoading && reviews.length > 0 ? 0.5 : 1,
|
||||
transition: "opacity 0.2s ease",
|
||||
|
||||
@@ -117,8 +117,6 @@ export function DownloadSettingsModal({
|
||||
return userPreferences?.realDebridApiToken;
|
||||
if (downloader === Downloader.TorBox)
|
||||
return userPreferences?.torBoxApiToken;
|
||||
if (downloader === Downloader.AllDebrid)
|
||||
return userPreferences?.allDebridApiKey;
|
||||
if (downloader === Downloader.Hydra)
|
||||
return isFeatureEnabled(Feature.Nimbus);
|
||||
return true;
|
||||
@@ -133,7 +131,6 @@ export function DownloadSettingsModal({
|
||||
downloaders,
|
||||
userPreferences?.realDebridApiToken,
|
||||
userPreferences?.torBoxApiToken,
|
||||
userPreferences?.allDebridApiKey,
|
||||
]);
|
||||
|
||||
const handleChooseDownloadsPath = async () => {
|
||||
@@ -194,8 +191,6 @@ export function DownloadSettingsModal({
|
||||
const shouldDisableButton =
|
||||
(downloader === Downloader.RealDebrid &&
|
||||
!userPreferences?.realDebridApiToken) ||
|
||||
(downloader === Downloader.AllDebrid &&
|
||||
!userPreferences?.allDebridApiKey) ||
|
||||
(downloader === Downloader.TorBox &&
|
||||
!userPreferences?.torBoxApiToken) ||
|
||||
(downloader === Downloader.Hydra &&
|
||||
|
||||
@@ -55,11 +55,8 @@
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background-color: var(--color-background-secondary);
|
||||
background-image: linear-gradient(
|
||||
45deg,
|
||||
rgba(255, 255, 255, 0.1) 25%,
|
||||
transparent 25%
|
||||
),
|
||||
background-image:
|
||||
linear-gradient(45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(255, 255, 255, 0.1) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.1) 75%);
|
||||
|
||||
@@ -19,6 +19,68 @@ export interface EditGameModalProps {
|
||||
|
||||
type AssetType = "icon" | "logo" | "hero";
|
||||
|
||||
interface ElectronFile extends File {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface GameWithOriginalAssets extends Game {
|
||||
originalIconPath?: string;
|
||||
originalLogoPath?: string;
|
||||
originalHeroPath?: string;
|
||||
}
|
||||
|
||||
interface LibraryGameWithCustomOriginalAssets extends LibraryGame {
|
||||
customOriginalIconPath?: string;
|
||||
customOriginalLogoPath?: string;
|
||||
customOriginalHeroPath?: string;
|
||||
}
|
||||
|
||||
interface AssetPaths {
|
||||
icon: string;
|
||||
logo: string;
|
||||
hero: string;
|
||||
}
|
||||
|
||||
interface AssetUrls {
|
||||
icon: string | null;
|
||||
logo: string | null;
|
||||
hero: string | null;
|
||||
}
|
||||
|
||||
interface RemovedAssets {
|
||||
icon: boolean;
|
||||
logo: boolean;
|
||||
hero: boolean;
|
||||
}
|
||||
|
||||
const VALID_IMAGE_TYPES = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
] as const;
|
||||
|
||||
const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp"] as const;
|
||||
|
||||
const INITIAL_ASSET_PATHS: AssetPaths = {
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
};
|
||||
|
||||
const INITIAL_REMOVED_ASSETS: RemovedAssets = {
|
||||
icon: false,
|
||||
logo: false,
|
||||
hero: false,
|
||||
};
|
||||
|
||||
const INITIAL_ASSET_URLS: AssetUrls = {
|
||||
icon: null,
|
||||
logo: null,
|
||||
hero: null,
|
||||
};
|
||||
|
||||
export function EditGameModal({
|
||||
visible,
|
||||
onClose,
|
||||
@@ -30,33 +92,18 @@ export function EditGameModal({
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [gameName, setGameName] = useState("");
|
||||
const [assetPaths, setAssetPaths] = useState({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
const [assetDisplayPaths, setAssetDisplayPaths] = useState({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
const [originalAssetPaths, setOriginalAssetPaths] = useState({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
const [removedAssets, setRemovedAssets] = useState({
|
||||
icon: false,
|
||||
logo: false,
|
||||
hero: false,
|
||||
});
|
||||
const [defaultUrls, setDefaultUrls] = useState({
|
||||
icon: null as string | null,
|
||||
logo: null as string | null,
|
||||
hero: null as string | null,
|
||||
});
|
||||
const [assetPaths, setAssetPaths] = useState<AssetPaths>(INITIAL_ASSET_PATHS);
|
||||
const [assetDisplayPaths, setAssetDisplayPaths] =
|
||||
useState<AssetPaths>(INITIAL_ASSET_PATHS);
|
||||
const [originalAssetPaths, setOriginalAssetPaths] =
|
||||
useState<AssetPaths>(INITIAL_ASSET_PATHS);
|
||||
const [removedAssets, setRemovedAssets] = useState<RemovedAssets>(
|
||||
INITIAL_REMOVED_ASSETS
|
||||
);
|
||||
const [defaultUrls, setDefaultUrls] = useState<AssetUrls>(INITIAL_ASSET_URLS);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [selectedAssetType, setSelectedAssetType] = useState<AssetType>("icon");
|
||||
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
|
||||
|
||||
const isCustomGame = (game: LibraryGame | Game): boolean => {
|
||||
return game.shop === "custom";
|
||||
@@ -66,12 +113,18 @@ export function EditGameModal({
|
||||
return url?.startsWith("local:") ? url.replace("local:", "") : "";
|
||||
};
|
||||
|
||||
const capitalizeAssetType = (assetType: AssetType): string => {
|
||||
return assetType.charAt(0).toUpperCase() + assetType.slice(1);
|
||||
};
|
||||
|
||||
const setCustomGameAssets = useCallback((game: LibraryGame | Game) => {
|
||||
// Check if assets were removed (URLs are null but original paths exist)
|
||||
const iconRemoved = !game.iconUrl && (game as any).originalIconPath;
|
||||
const logoRemoved = !game.logoImageUrl && (game as any).originalLogoPath;
|
||||
const gameWithAssets = game as GameWithOriginalAssets;
|
||||
const iconRemoved =
|
||||
!game.iconUrl && Boolean(gameWithAssets.originalIconPath);
|
||||
const logoRemoved =
|
||||
!game.logoImageUrl && Boolean(gameWithAssets.originalLogoPath);
|
||||
const heroRemoved =
|
||||
!game.libraryHeroImageUrl && (game as any).originalHeroPath;
|
||||
!game.libraryHeroImageUrl && Boolean(gameWithAssets.originalHeroPath);
|
||||
|
||||
setAssetPaths({
|
||||
icon: extractLocalPath(game.iconUrl),
|
||||
@@ -84,15 +137,14 @@ export function EditGameModal({
|
||||
hero: extractLocalPath(game.libraryHeroImageUrl),
|
||||
});
|
||||
setOriginalAssetPaths({
|
||||
icon: (game as any).originalIconPath || extractLocalPath(game.iconUrl),
|
||||
icon: gameWithAssets.originalIconPath || extractLocalPath(game.iconUrl),
|
||||
logo:
|
||||
(game as any).originalLogoPath || extractLocalPath(game.logoImageUrl),
|
||||
gameWithAssets.originalLogoPath || extractLocalPath(game.logoImageUrl),
|
||||
hero:
|
||||
(game as any).originalHeroPath ||
|
||||
gameWithAssets.originalHeroPath ||
|
||||
extractLocalPath(game.libraryHeroImageUrl),
|
||||
});
|
||||
|
||||
// Set removed assets state based on whether assets were explicitly removed
|
||||
setRemovedAssets({
|
||||
icon: iconRemoved,
|
||||
logo: logoRemoved,
|
||||
@@ -102,13 +154,15 @@ export function EditGameModal({
|
||||
|
||||
const setNonCustomGameAssets = useCallback(
|
||||
(game: LibraryGame) => {
|
||||
// Check if assets were removed (custom URLs are null but original paths exist)
|
||||
const gameWithAssets = game as LibraryGameWithCustomOriginalAssets;
|
||||
const iconRemoved =
|
||||
!game.customIconUrl && (game as any).customOriginalIconPath;
|
||||
!game.customIconUrl && Boolean(gameWithAssets.customOriginalIconPath);
|
||||
const logoRemoved =
|
||||
!game.customLogoImageUrl && (game as any).customOriginalLogoPath;
|
||||
!game.customLogoImageUrl &&
|
||||
Boolean(gameWithAssets.customOriginalLogoPath);
|
||||
const heroRemoved =
|
||||
!game.customHeroImageUrl && (game as any).customOriginalHeroPath;
|
||||
!game.customHeroImageUrl &&
|
||||
Boolean(gameWithAssets.customOriginalHeroPath);
|
||||
|
||||
setAssetPaths({
|
||||
icon: extractLocalPath(game.customIconUrl),
|
||||
@@ -122,17 +176,16 @@ export function EditGameModal({
|
||||
});
|
||||
setOriginalAssetPaths({
|
||||
icon:
|
||||
(game as any).customOriginalIconPath ||
|
||||
gameWithAssets.customOriginalIconPath ||
|
||||
extractLocalPath(game.customIconUrl),
|
||||
logo:
|
||||
(game as any).customOriginalLogoPath ||
|
||||
gameWithAssets.customOriginalLogoPath ||
|
||||
extractLocalPath(game.customLogoImageUrl),
|
||||
hero:
|
||||
(game as any).customOriginalHeroPath ||
|
||||
gameWithAssets.customOriginalHeroPath ||
|
||||
extractLocalPath(game.customHeroImageUrl),
|
||||
});
|
||||
|
||||
// Set removed assets state based on whether assets were explicitly removed
|
||||
setRemovedAssets({
|
||||
icon: iconRemoved,
|
||||
logo: logoRemoved,
|
||||
@@ -171,29 +224,22 @@ export function EditGameModal({
|
||||
setSelectedAssetType(assetType);
|
||||
};
|
||||
|
||||
const getAssetPath = (assetType: AssetType): string => {
|
||||
return assetPaths[assetType];
|
||||
};
|
||||
|
||||
const getAssetDisplayPath = (assetType: AssetType): string => {
|
||||
// If asset was removed, don't show any path
|
||||
if (removedAssets[assetType]) {
|
||||
return "";
|
||||
}
|
||||
// Use display path first, then fall back to original path
|
||||
return assetDisplayPaths[assetType] || originalAssetPaths[assetType];
|
||||
};
|
||||
|
||||
const setAssetPath = (assetType: AssetType, path: string): void => {
|
||||
const updateAssetPaths = (
|
||||
assetType: AssetType,
|
||||
path: string,
|
||||
displayPath: string
|
||||
): void => {
|
||||
setAssetPaths((prev) => ({ ...prev, [assetType]: path }));
|
||||
};
|
||||
|
||||
const setAssetDisplayPath = (assetType: AssetType, path: string): void => {
|
||||
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: path }));
|
||||
};
|
||||
|
||||
const getDefaultUrl = (assetType: AssetType): string | null => {
|
||||
return defaultUrls[assetType];
|
||||
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: displayPath }));
|
||||
setOriginalAssetPaths((prev) => ({ ...prev, [assetType]: displayPath }));
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
|
||||
};
|
||||
|
||||
const getOriginalAssetUrl = (assetType: AssetType): string | null => {
|
||||
@@ -217,7 +263,7 @@ export function EditGameModal({
|
||||
filters: [
|
||||
{
|
||||
name: t("edit_game_modal_image_filter"),
|
||||
extensions: ["jpg", "jpeg", "png", "gif", "webp"],
|
||||
extensions: [...IMAGE_EXTENSIONS],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -229,41 +275,26 @@ export function EditGameModal({
|
||||
originalPath,
|
||||
assetType
|
||||
);
|
||||
setAssetPath(assetType, copiedAssetUrl.replace("local:", ""));
|
||||
setAssetDisplayPath(assetType, originalPath);
|
||||
// Store the original path for display purposes
|
||||
setOriginalAssetPaths((prev) => ({
|
||||
...prev,
|
||||
[assetType]: originalPath,
|
||||
}));
|
||||
// Clear the removed flag when a new asset is selected
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
|
||||
updateAssetPaths(
|
||||
assetType,
|
||||
copiedAssetUrl.replace("local:", ""),
|
||||
originalPath
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy ${assetType} asset:`, error);
|
||||
setAssetPath(assetType, originalPath);
|
||||
setAssetDisplayPath(assetType, originalPath);
|
||||
setOriginalAssetPaths((prev) => ({
|
||||
...prev,
|
||||
[assetType]: originalPath,
|
||||
}));
|
||||
// Clear the removed flag when a new asset is selected
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: false }));
|
||||
updateAssetPaths(assetType, originalPath, originalPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreDefault = (assetType: AssetType) => {
|
||||
// Mark asset as removed and clear paths (for both custom and non-custom games)
|
||||
setRemovedAssets((prev) => ({ ...prev, [assetType]: true }));
|
||||
setAssetPath(assetType, "");
|
||||
setAssetDisplayPath(assetType, "");
|
||||
// Don't clear originalAssetPaths - keep them for reference but don't use them for display
|
||||
setAssetPaths((prev) => ({ ...prev, [assetType]: "" }));
|
||||
setAssetDisplayPaths((prev) => ({ ...prev, [assetType]: "" }));
|
||||
};
|
||||
|
||||
const getOriginalTitle = (): string => {
|
||||
if (!game) return "";
|
||||
|
||||
// For non-custom games, the original title is from shopDetails assets
|
||||
return shopDetails?.assets?.title || game.title || "";
|
||||
};
|
||||
|
||||
@@ -274,12 +305,10 @@ export function EditGameModal({
|
||||
|
||||
const isTitleChanged = useMemo((): boolean => {
|
||||
if (!game || isCustomGame(game)) return false;
|
||||
const originalTitle = getOriginalTitle();
|
||||
const originalTitle = shopDetails?.assets?.title || game.title || "";
|
||||
return gameName.trim() !== originalTitle.trim();
|
||||
}, [game, gameName, shopDetails]);
|
||||
|
||||
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -300,14 +329,9 @@ export function EditGameModal({
|
||||
};
|
||||
|
||||
const validateImageFile = (file: File): boolean => {
|
||||
const validTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
return validTypes.includes(file.type);
|
||||
return VALID_IMAGE_TYPES.includes(
|
||||
file.type as (typeof VALID_IMAGE_TYPES)[number]
|
||||
);
|
||||
};
|
||||
|
||||
const processDroppedFile = async (file: File, assetType: AssetType) => {
|
||||
@@ -321,10 +345,6 @@ export function EditGameModal({
|
||||
try {
|
||||
let filePath: string;
|
||||
|
||||
interface ElectronFile extends File {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
if ("path" in file && typeof (file as ElectronFile).path === "string") {
|
||||
filePath = (file as ElectronFile).path!;
|
||||
} else {
|
||||
@@ -351,12 +371,13 @@ export function EditGameModal({
|
||||
assetType
|
||||
);
|
||||
|
||||
const assetPath = copiedAssetUrl.replace("local:", "");
|
||||
setAssetPath(assetType, assetPath);
|
||||
setAssetDisplayPath(assetType, filePath);
|
||||
|
||||
updateAssetPaths(
|
||||
assetType,
|
||||
copiedAssetUrl.replace("local:", ""),
|
||||
filePath
|
||||
);
|
||||
showSuccessToast(
|
||||
`${assetType.charAt(0).toUpperCase() + assetType.slice(1)} updated successfully!`
|
||||
`${capitalizeAssetType(assetType)} updated successfully!`
|
||||
);
|
||||
|
||||
if (!("path" in file) && filePath) {
|
||||
@@ -387,63 +408,45 @@ export function EditGameModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to prepare custom game assets
|
||||
const prepareCustomGameAssets = (game: LibraryGame | Game) => {
|
||||
// For custom games, check if asset was explicitly removed
|
||||
let iconUrl;
|
||||
if (removedAssets.icon) {
|
||||
iconUrl = null;
|
||||
} else if (assetPaths.icon) {
|
||||
iconUrl = `local:${assetPaths.icon}`;
|
||||
} else {
|
||||
iconUrl = game.iconUrl;
|
||||
}
|
||||
const iconUrl = removedAssets.icon
|
||||
? null
|
||||
: assetPaths.icon
|
||||
? `local:${assetPaths.icon}`
|
||||
: game.iconUrl;
|
||||
|
||||
let logoImageUrl;
|
||||
if (removedAssets.logo) {
|
||||
logoImageUrl = null;
|
||||
} else if (assetPaths.logo) {
|
||||
logoImageUrl = `local:${assetPaths.logo}`;
|
||||
} else {
|
||||
logoImageUrl = game.logoImageUrl;
|
||||
}
|
||||
const logoImageUrl = removedAssets.logo
|
||||
? null
|
||||
: assetPaths.logo
|
||||
? `local:${assetPaths.logo}`
|
||||
: game.logoImageUrl;
|
||||
|
||||
// For hero image, if removed, restore to the original gradient or keep the original
|
||||
let libraryHeroImageUrl;
|
||||
if (removedAssets.hero) {
|
||||
// If the original hero was a gradient (data URL), keep it, otherwise generate a new one
|
||||
const originalHero = game.libraryHeroImageUrl;
|
||||
libraryHeroImageUrl = originalHero?.startsWith("data:image/svg+xml")
|
||||
? originalHero
|
||||
: generateRandomGradient();
|
||||
} else {
|
||||
libraryHeroImageUrl = assetPaths.hero
|
||||
const libraryHeroImageUrl = removedAssets.hero
|
||||
? game.libraryHeroImageUrl?.startsWith("data:image/svg+xml")
|
||||
? game.libraryHeroImageUrl
|
||||
: generateRandomGradient()
|
||||
: assetPaths.hero
|
||||
? `local:${assetPaths.hero}`
|
||||
: game.libraryHeroImageUrl;
|
||||
}
|
||||
|
||||
return { iconUrl, logoImageUrl, libraryHeroImageUrl };
|
||||
};
|
||||
|
||||
// Helper function to prepare non-custom game assets
|
||||
const prepareNonCustomGameAssets = () => {
|
||||
const hasIconPath = assetPaths.icon;
|
||||
let customIconUrl: string | null = null;
|
||||
if (!removedAssets.icon && hasIconPath) {
|
||||
customIconUrl = `local:${assetPaths.icon}`;
|
||||
}
|
||||
const customIconUrl =
|
||||
!removedAssets.icon && assetPaths.icon
|
||||
? `local:${assetPaths.icon}`
|
||||
: null;
|
||||
|
||||
const hasLogoPath = assetPaths.logo;
|
||||
let customLogoImageUrl: string | null = null;
|
||||
if (!removedAssets.logo && hasLogoPath) {
|
||||
customLogoImageUrl = `local:${assetPaths.logo}`;
|
||||
}
|
||||
const customLogoImageUrl =
|
||||
!removedAssets.logo && assetPaths.logo
|
||||
? `local:${assetPaths.logo}`
|
||||
: null;
|
||||
|
||||
const hasHeroPath = assetPaths.hero;
|
||||
let customHeroImageUrl: string | null = null;
|
||||
if (!removedAssets.hero && hasHeroPath) {
|
||||
customHeroImageUrl = `local:${assetPaths.hero}`;
|
||||
}
|
||||
const customHeroImageUrl =
|
||||
!removedAssets.hero && assetPaths.hero
|
||||
? `local:${assetPaths.hero}`
|
||||
: null;
|
||||
|
||||
return {
|
||||
customIconUrl,
|
||||
@@ -452,7 +455,6 @@ export function EditGameModal({
|
||||
};
|
||||
};
|
||||
|
||||
// Helper function to update custom game
|
||||
const updateCustomGame = async (game: LibraryGame | Game) => {
|
||||
const { iconUrl, logoImageUrl, libraryHeroImageUrl } =
|
||||
prepareCustomGameAssets(game);
|
||||
@@ -470,7 +472,6 @@ export function EditGameModal({
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to update non-custom game
|
||||
const updateNonCustomGame = async (game: LibraryGame) => {
|
||||
const { customIconUrl, customLogoImageUrl, customHeroImageUrl } =
|
||||
prepareNonCustomGameAssets();
|
||||
@@ -521,43 +522,17 @@ export function EditGameModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to reset form to initial state
|
||||
const resetFormToInitialState = useCallback(
|
||||
(game: LibraryGame | Game) => {
|
||||
setGameName(game.title || "");
|
||||
|
||||
// Reset removed assets state
|
||||
setRemovedAssets({
|
||||
icon: false,
|
||||
logo: false,
|
||||
hero: false,
|
||||
});
|
||||
|
||||
// Clear all asset paths to ensure clean state
|
||||
setAssetPaths({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
setAssetDisplayPaths({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
setOriginalAssetPaths({
|
||||
icon: "",
|
||||
logo: "",
|
||||
hero: "",
|
||||
});
|
||||
setRemovedAssets(INITIAL_REMOVED_ASSETS);
|
||||
setAssetPaths(INITIAL_ASSET_PATHS);
|
||||
setAssetDisplayPaths(INITIAL_ASSET_PATHS);
|
||||
setOriginalAssetPaths(INITIAL_ASSET_PATHS);
|
||||
|
||||
if (isCustomGame(game)) {
|
||||
setCustomGameAssets(game);
|
||||
// Clear default URLs for custom games
|
||||
setDefaultUrls({
|
||||
icon: null,
|
||||
logo: null,
|
||||
hero: null,
|
||||
});
|
||||
setDefaultUrls(INITIAL_ASSET_URLS);
|
||||
} else {
|
||||
setNonCustomGameAssets(game as LibraryGame);
|
||||
}
|
||||
@@ -575,8 +550,8 @@ export function EditGameModal({
|
||||
const isFormValid = gameName.trim();
|
||||
|
||||
const getPreviewUrl = (assetType: AssetType): string | undefined => {
|
||||
const assetPath = getAssetPath(assetType);
|
||||
const defaultUrl = getDefaultUrl(assetType);
|
||||
const assetPath = assetPaths[assetType];
|
||||
const defaultUrl = defaultUrls[assetType];
|
||||
|
||||
if (game && !isCustomGame(game)) {
|
||||
return assetPath ? `local:${assetPath}` : defaultUrl || undefined;
|
||||
@@ -585,9 +560,9 @@ export function EditGameModal({
|
||||
};
|
||||
|
||||
const renderImageSection = (assetType: AssetType) => {
|
||||
const assetPath = getAssetPath(assetType);
|
||||
const assetPath = assetPaths[assetType];
|
||||
const assetDisplayPath = getAssetDisplayPath(assetType);
|
||||
const defaultUrl = getDefaultUrl(assetType);
|
||||
const defaultUrl = defaultUrls[assetType];
|
||||
const hasImage = assetPath || (game && !isCustomGame(game) && defaultUrl);
|
||||
const isDragOver = dragOverTarget === assetType;
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
TextField,
|
||||
CheckboxField,
|
||||
} from "@renderer/components";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import type { DownloadSource } from "@types";
|
||||
import type { GameRepack } from "@types";
|
||||
|
||||
@@ -105,7 +104,7 @@ export function RepacksModal({
|
||||
}, [repacks, hashesInDebrid]);
|
||||
|
||||
useEffect(() => {
|
||||
downloadSourcesTable.toArray().then((sources) => {
|
||||
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
|
||||
@@ -129,6 +128,7 @@ export function RepacksModal({
|
||||
|
||||
return downloadSources.some(
|
||||
(src) =>
|
||||
src.fingerprint &&
|
||||
selectedFingerprints.includes(src.fingerprint) &&
|
||||
src.name === repack.repacker
|
||||
);
|
||||
@@ -210,25 +210,32 @@ export function RepacksModal({
|
||||
className={`repacks-modal__download-sources ${isFilterDrawerOpen ? "repacks-modal__download-sources--open" : ""}`}
|
||||
>
|
||||
<div className="repacks-modal__source-grid">
|
||||
{downloadSources.map((source) => {
|
||||
const label = source.name || source.url;
|
||||
const truncatedLabel =
|
||||
label.length > 16 ? label.substring(0, 16) + "..." : label;
|
||||
return (
|
||||
<div
|
||||
key={source.fingerprint}
|
||||
className="repacks-modal__source-item"
|
||||
>
|
||||
<CheckboxField
|
||||
label={truncatedLabel}
|
||||
checked={selectedFingerprints.includes(
|
||||
source.fingerprint
|
||||
)}
|
||||
onChange={() => toggleFingerprint(source.fingerprint)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{downloadSources
|
||||
.filter(
|
||||
(
|
||||
source
|
||||
): source is DownloadSource & { fingerprint: string } =>
|
||||
source.fingerprint !== undefined
|
||||
)
|
||||
.map((source) => {
|
||||
const label = source.name || source.url;
|
||||
const truncatedLabel =
|
||||
label.length > 16 ? label.substring(0, 16) + "..." : label;
|
||||
return (
|
||||
<div
|
||||
key={source.fingerprint}
|
||||
className="repacks-modal__source-item"
|
||||
>
|
||||
<CheckboxField
|
||||
label={truncatedLabel}
|
||||
checked={selectedFingerprints.includes(
|
||||
source.fingerprint
|
||||
)}
|
||||
onChange={() => toggleFingerprint(source.fingerprint)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
23
src/renderer/src/pages/game-details/review-item.scss
Normal file
23
src/renderer/src/pages/game-details/review-item.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
.game-details {
|
||||
&__review-translation-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: calc(globals.$spacing-unit * 1);
|
||||
margin-top: calc(globals.$spacing-unit * 1.5);
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { TrashIcon, ClockIcon } from "@primer/octicons-react";
|
||||
import { ThumbsUp, ThumbsDown, Star } from "lucide-react";
|
||||
import { ThumbsUp, ThumbsDown, Star, Languages } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import type { GameReview } from "@types";
|
||||
|
||||
import { sanitizeHtml } from "@shared";
|
||||
@@ -10,6 +11,8 @@ import { useDate } from "@renderer/hooks";
|
||||
import { formatNumber } from "@renderer/helpers";
|
||||
import { Avatar } from "@renderer/components";
|
||||
|
||||
import "./review-item.scss";
|
||||
|
||||
interface ReviewItemProps {
|
||||
review: GameReview;
|
||||
userDetailsId?: string;
|
||||
@@ -63,9 +66,45 @@ export function ReviewItem({
|
||||
onAnimationComplete,
|
||||
}: Readonly<ReviewItemProps>) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("game_details");
|
||||
const { t, i18n } = useTranslation("game_details");
|
||||
const { formatDistance } = useDate();
|
||||
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
|
||||
// Check if this is the user's own review
|
||||
const isOwnReview = userDetailsId === review.user.id;
|
||||
|
||||
// Helper to get base language code (e.g., "pt" from "pt-BR")
|
||||
const getBaseLanguage = (lang: string) => lang.split("-")[0];
|
||||
|
||||
// Check if the review is in a different language (comparing base language codes)
|
||||
const isDifferentLanguage =
|
||||
getBaseLanguage(review.detectedLanguage) !== getBaseLanguage(i18n.language);
|
||||
|
||||
// Check if translation is available and needed (but not for own reviews)
|
||||
const needsTranslation =
|
||||
!isOwnReview &&
|
||||
isDifferentLanguage &&
|
||||
review.translations &&
|
||||
review.translations[i18n.language];
|
||||
|
||||
// Get the full language name using Intl.DisplayNames
|
||||
const getLanguageName = (languageCode: string) => {
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames([i18n.language], {
|
||||
type: "language",
|
||||
});
|
||||
return displayNames.of(languageCode) || languageCode.toUpperCase();
|
||||
} catch {
|
||||
return languageCode.toUpperCase();
|
||||
}
|
||||
};
|
||||
|
||||
// Determine which content to show - always show original for own reviews
|
||||
const displayContent = needsTranslation
|
||||
? review.translations[i18n.language]
|
||||
: review.reviewHtml;
|
||||
|
||||
if (isBlocked && !isVisible) {
|
||||
return (
|
||||
<div className="game-details__review-item">
|
||||
@@ -135,12 +174,41 @@ export function ReviewItem({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="game-details__review-content"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(review.reviewHtml),
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div
|
||||
className="game-details__review-content"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(displayContent),
|
||||
}}
|
||||
/>
|
||||
{needsTranslation && (
|
||||
<>
|
||||
<button
|
||||
className="game-details__review-translation-toggle"
|
||||
onClick={() => setShowOriginal(!showOriginal)}
|
||||
>
|
||||
<Languages size={13} />
|
||||
{showOriginal
|
||||
? t("hide_original")
|
||||
: t("show_original_translated_from", {
|
||||
language: getLanguageName(review.detectedLanguage),
|
||||
})}
|
||||
</button>
|
||||
{showOriginal && (
|
||||
<div
|
||||
className="game-details__review-content"
|
||||
style={{
|
||||
opacity: 0.6,
|
||||
marginTop: "12px",
|
||||
}}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(review.reviewHtml),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="game-details__review-actions">
|
||||
<div className="game-details__review-votes">
|
||||
<motion.button
|
||||
|
||||
@@ -25,7 +25,7 @@ export function HowLongToBeatSection({
|
||||
return `${value} ${t(durationTranslation[unit])}`;
|
||||
};
|
||||
|
||||
if (!howLongToBeatData || !isLoading) return null;
|
||||
if (!howLongToBeatData && !isLoading) return null;
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
StarIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import { HowLongToBeatSection } from "./how-long-to-beat-section";
|
||||
import { howLongToBeatEntriesTable } from "@renderer/dexie";
|
||||
import { SidebarSection } from "../sidebar-section/sidebar-section";
|
||||
import { buildGameAchievementPath } from "@renderer/helpers";
|
||||
import { useSubscription } from "@renderer/hooks/use-subscription";
|
||||
@@ -80,41 +79,22 @@ export function Sidebar() {
|
||||
if (objectId) {
|
||||
setHowLongToBeat({ isLoading: true, data: null });
|
||||
|
||||
howLongToBeatEntriesTable
|
||||
.where({ shop, objectId })
|
||||
.first()
|
||||
.then(async (cachedHowLongToBeat) => {
|
||||
if (cachedHowLongToBeat) {
|
||||
setHowLongToBeat({
|
||||
isLoading: false,
|
||||
data: cachedHowLongToBeat.categories,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const howLongToBeat = await window.electron.hydraApi.get<
|
||||
HowLongToBeatCategory[] | null
|
||||
>(`/games/${shop}/${objectId}/how-long-to-beat`, {
|
||||
needsAuth: false,
|
||||
});
|
||||
|
||||
if (howLongToBeat) {
|
||||
howLongToBeatEntriesTable.add({
|
||||
objectId,
|
||||
shop: "steam",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
categories: howLongToBeat,
|
||||
});
|
||||
}
|
||||
|
||||
setHowLongToBeat({ isLoading: false, data: howLongToBeat });
|
||||
} catch (err) {
|
||||
setHowLongToBeat({ isLoading: false, data: null });
|
||||
}
|
||||
// Directly fetch from API without checking cache
|
||||
window.electron.hydraApi
|
||||
.get<HowLongToBeatCategory[] | null>(
|
||||
`/games/${shop}/${objectId}/how-long-to-beat`,
|
||||
{
|
||||
needsAuth: false,
|
||||
}
|
||||
)
|
||||
.then((howLongToBeatData) => {
|
||||
setHowLongToBeat({ isLoading: false, data: howLongToBeatData });
|
||||
})
|
||||
.catch(() => {
|
||||
setHowLongToBeat({ isLoading: false, data: null });
|
||||
});
|
||||
}
|
||||
}, [objectId, shop, gameTitle]);
|
||||
}, [objectId, shop]);
|
||||
|
||||
return (
|
||||
<aside className="content-sidebar">
|
||||
@@ -240,14 +220,6 @@ export function Sidebar() {
|
||||
: (stats?.averageScore ?? null)
|
||||
}
|
||||
size={16}
|
||||
showCalculating={
|
||||
!!(
|
||||
stats &&
|
||||
(stats.averageScore === null || stats.averageScore === 0)
|
||||
)
|
||||
}
|
||||
calculatingText={t("calculating", { ns: "game_card" })}
|
||||
hideIcon={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ export const sectionVariants = {
|
||||
height: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
ease: [0.25, 0.1, 0.25, 1] as const,
|
||||
opacity: { duration: 0.1 },
|
||||
y: { duration: 0.1 },
|
||||
height: { duration: 0.2 },
|
||||
@@ -17,13 +17,13 @@ export const sectionVariants = {
|
||||
height: "auto",
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
ease: [0.25, 0.1, 0.25, 1] as const,
|
||||
opacity: { duration: 0.2, delay: 0.1 },
|
||||
y: { duration: 0.3 },
|
||||
height: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const gameCardVariants = {
|
||||
hidden: {
|
||||
@@ -37,7 +37,7 @@ export const gameCardVariants = {
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
ease: [0.25, 0.1, 0.25, 1] as const,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
@@ -46,10 +46,10 @@ export const gameCardVariants = {
|
||||
scale: 0.95,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
ease: [0.25, 0.1, 0.25, 1] as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const gameGridVariants = {
|
||||
hidden: {
|
||||
@@ -76,16 +76,16 @@ export const chevronVariants = {
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
ease: "easeInOut" as const,
|
||||
},
|
||||
},
|
||||
expanded: {
|
||||
rotate: 90,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
ease: "easeInOut" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const GAME_STATS_ANIMATION_DURATION_IN_MS = 3500;
|
||||
|
||||
@@ -14,7 +14,7 @@ export function SortOptions({ sortBy, onSortChange }: SortOptionsProps) {
|
||||
|
||||
return (
|
||||
<div className="sort-options__container">
|
||||
<span className="sort-options__label">Sort by:</span>
|
||||
<span className="sort-options__label">{t("sort_by")}</span>
|
||||
<div className="sort-options__options">
|
||||
<button
|
||||
className={`sort-options__option ${sortBy === "achievementCount" ? "active" : ""}`}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
@use "../../scss/globals.scss";
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.add-download-source-modal {
|
||||
&__container {
|
||||
display: flex;
|
||||
@@ -24,4 +33,9 @@
|
||||
&__validate-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: calc(globals.$spacing-unit / 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ 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 * as yup from "yup";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import type { DownloadSourceValidationResult } from "@types";
|
||||
import { downloadSourcesWorker } from "@renderer/workers";
|
||||
import { setIsImportingSources } from "@renderer/features";
|
||||
import { SyncIcon } from "@primer/octicons-react";
|
||||
import "./add-download-source-modal.scss";
|
||||
|
||||
interface AddDownloadSourceModalProps {
|
||||
@@ -52,13 +53,15 @@ export function AddDownloadSourceModal({
|
||||
|
||||
const { sourceUrl } = useContext(settingsContext);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (values: FormValues) => {
|
||||
const existingDownloadSource = await downloadSourcesTable
|
||||
.where({ url: values.url })
|
||||
.first();
|
||||
const exists = await window.electron.checkDownloadSourceExists(
|
||||
values.url
|
||||
);
|
||||
|
||||
if (existingDownloadSource) {
|
||||
if (exists) {
|
||||
setError("url", {
|
||||
type: "server",
|
||||
message: t("source_already_exists"),
|
||||
@@ -67,22 +70,11 @@ export function AddDownloadSourceModal({
|
||||
return;
|
||||
}
|
||||
|
||||
downloadSourcesWorker.postMessage([
|
||||
"VALIDATE_DOWNLOAD_SOURCE",
|
||||
values.url,
|
||||
]);
|
||||
|
||||
const channel = new BroadcastChannel(
|
||||
`download_sources:validate:${values.url}`
|
||||
const validationResult = await window.electron.validateDownloadSource(
|
||||
values.url
|
||||
);
|
||||
|
||||
channel.onmessage = (
|
||||
event: MessageEvent<DownloadSourceValidationResult>
|
||||
) => {
|
||||
setValidationResult(event.data);
|
||||
channel.close();
|
||||
};
|
||||
|
||||
setValidationResult(validationResult);
|
||||
setUrl(values.url);
|
||||
},
|
||||
[setError, t]
|
||||
@@ -100,44 +92,44 @@ export function AddDownloadSourceModal({
|
||||
}
|
||||
}, [visible, clearErrors, handleSubmit, onSubmit, setValue, sourceUrl]);
|
||||
|
||||
const putDownloadSource = async () => {
|
||||
const downloadSource = await downloadSourcesTable.where({ url }).first();
|
||||
if (!downloadSource) return;
|
||||
|
||||
window.electron
|
||||
.putDownloadSource(downloadSource.objectIds)
|
||||
.then(({ fingerprint }) => {
|
||||
downloadSourcesTable.update(downloadSource.id, { fingerprint });
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddDownloadSource = async () => {
|
||||
if (validationResult) {
|
||||
setIsLoading(true);
|
||||
dispatch(setIsImportingSources(true));
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:import:${url}`);
|
||||
|
||||
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
window.electron.createDownloadSources([url]);
|
||||
setIsLoading(false);
|
||||
|
||||
putDownloadSource();
|
||||
try {
|
||||
// Single call that handles: import → API sync → fingerprint
|
||||
await window.electron.addDownloadSource(url);
|
||||
|
||||
// Close modal and update UI
|
||||
onClose();
|
||||
onAddDownloadSource();
|
||||
channel.close();
|
||||
};
|
||||
} 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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Prevent closing while importing
|
||||
if (isLoading) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
title={t("add_download_source")}
|
||||
description={t("add_download_source_description")}
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
clickOutsideToClose={!isLoading}
|
||||
>
|
||||
<div className="add-download-source-modal__container">
|
||||
<TextField
|
||||
@@ -176,7 +168,10 @@ export function AddDownloadSourceModal({
|
||||
onClick={handleAddDownloadSource}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("import")}
|
||||
{isLoading && (
|
||||
<SyncIcon className="add-download-source-modal__spinner" />
|
||||
)}
|
||||
{isLoading ? t("importing") : t("import")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
.settings-all-debrid {
|
||||
&__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, CheckboxField, Link, TextField } from "@renderer/components";
|
||||
import "./settings-all-debrid.scss";
|
||||
|
||||
import { useAppSelector, useToast } from "@renderer/hooks";
|
||||
|
||||
import { settingsContext } from "@renderer/context";
|
||||
|
||||
const ALL_DEBRID_API_TOKEN_URL = "https://alldebrid.com/apikeys";
|
||||
|
||||
export function SettingsAllDebrid() {
|
||||
const userPreferences = useAppSelector(
|
||||
(state) => state.userPreferences.value
|
||||
);
|
||||
|
||||
const { updateUserPreferences } = useContext(settingsContext);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
useAllDebrid: false,
|
||||
allDebridApiKey: null as string | null,
|
||||
});
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
if (userPreferences) {
|
||||
setForm({
|
||||
useAllDebrid: Boolean(userPreferences.allDebridApiKey),
|
||||
allDebridApiKey: userPreferences.allDebridApiKey ?? null,
|
||||
});
|
||||
}
|
||||
}, [userPreferences]);
|
||||
|
||||
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = async (
|
||||
event
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
if (form.useAllDebrid) {
|
||||
if (!form.allDebridApiKey) {
|
||||
showErrorToast(t("alldebrid_missing_key"));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await window.electron.authenticateAllDebrid(
|
||||
form.allDebridApiKey
|
||||
);
|
||||
|
||||
if ("error_code" in result) {
|
||||
showErrorToast(t(result.error_code));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.isPremium) {
|
||||
showErrorToast(
|
||||
t("all_debrid_free_account_error", { username: result.username })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showSuccessToast(
|
||||
t("all_debrid_account_linked"),
|
||||
t("debrid_linked_message", { username: result.username })
|
||||
);
|
||||
} else {
|
||||
showSuccessToast(t("changes_saved"));
|
||||
}
|
||||
|
||||
updateUserPreferences({
|
||||
allDebridApiKey: form.useAllDebrid ? form.allDebridApiKey : null,
|
||||
});
|
||||
} catch (err: any) {
|
||||
showErrorToast(t("alldebrid_unknown_error"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled =
|
||||
(form.useAllDebrid && !form.allDebridApiKey) || isLoading;
|
||||
|
||||
return (
|
||||
<form className="settings-all-debrid__form" onSubmit={handleFormSubmit}>
|
||||
<p className="settings-all-debrid__description">
|
||||
{t("all_debrid_description")}
|
||||
</p>
|
||||
|
||||
<CheckboxField
|
||||
label={t("enable_all_debrid")}
|
||||
checked={form.useAllDebrid}
|
||||
onChange={() =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
useAllDebrid: !form.useAllDebrid,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
{form.useAllDebrid && (
|
||||
<TextField
|
||||
label={t("api_token")}
|
||||
value={form.allDebridApiKey ?? ""}
|
||||
type="password"
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, allDebridApiKey: event.target.value })
|
||||
}
|
||||
rightContent={
|
||||
<Button type="submit" disabled={isButtonDisabled}>
|
||||
{t("save_changes")}
|
||||
</Button>
|
||||
}
|
||||
placeholder="API Key"
|
||||
hint={
|
||||
<Trans i18nKey="debrid_api_token_hint" ns="settings">
|
||||
<Link to={ALL_DEBRID_API_TOKEN_URL} />
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useState, useCallback, useMemo } from "react";
|
||||
import { useFeature, useAppSelector } from "@renderer/hooks";
|
||||
import { SettingsTorBox } from "./settings-torbox";
|
||||
import { SettingsRealDebrid } from "./settings-real-debrid";
|
||||
import { SettingsAllDebrid } from "./settings-all-debrid";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronRightIcon, CheckCircleFillIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,7 +10,6 @@ import "./settings-debrid.scss";
|
||||
interface CollapseState {
|
||||
torbox: boolean;
|
||||
realDebrid: boolean;
|
||||
allDebrid: boolean;
|
||||
}
|
||||
|
||||
const sectionVariants = {
|
||||
@@ -21,7 +19,7 @@ const sectionVariants = {
|
||||
height: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
ease: [0.25, 0.1, 0.25, 1] as const,
|
||||
opacity: { duration: 0.1 },
|
||||
y: { duration: 0.1 },
|
||||
height: { duration: 0.2 },
|
||||
@@ -33,30 +31,30 @@ const sectionVariants = {
|
||||
height: "auto",
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: [0.25, 0.1, 0.25, 1],
|
||||
ease: [0.25, 0.1, 0.25, 1] as const,
|
||||
opacity: { duration: 0.2, delay: 0.1 },
|
||||
y: { duration: 0.3 },
|
||||
height: { duration: 0.3 },
|
||||
},
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
const chevronVariants = {
|
||||
collapsed: {
|
||||
rotate: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
ease: "easeInOut" as const,
|
||||
},
|
||||
},
|
||||
expanded: {
|
||||
rotate: 90,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeInOut",
|
||||
ease: "easeInOut" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
export function SettingsDebrid() {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -71,7 +69,6 @@ export function SettingsDebrid() {
|
||||
return {
|
||||
torbox: !userPreferences?.torBoxApiToken,
|
||||
realDebrid: !userPreferences?.realDebridApiToken,
|
||||
allDebrid: !userPreferences?.allDebridApiKey,
|
||||
};
|
||||
}, [userPreferences]);
|
||||
|
||||
@@ -178,51 +175,6 @@ export function SettingsDebrid() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-debrid__section">
|
||||
<div className="settings-debrid__section-header">
|
||||
<button
|
||||
type="button"
|
||||
className="settings-debrid__collapse-button"
|
||||
onClick={() => toggleSection("allDebrid")}
|
||||
aria-label={
|
||||
collapseState.allDebrid
|
||||
? "Expand All-Debrid section"
|
||||
: "Collapse All-Debrid section"
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
variants={chevronVariants}
|
||||
animate={collapseState.allDebrid ? "collapsed" : "expanded"}
|
||||
>
|
||||
<ChevronRightIcon size={16} />
|
||||
</motion.div>
|
||||
</button>
|
||||
<h3 className="settings-debrid__section-title">All-Debrid</h3>
|
||||
<span className="settings-debrid__beta-badge">BETA</span>
|
||||
{userPreferences?.allDebridApiKey && (
|
||||
<CheckCircleFillIcon
|
||||
size={16}
|
||||
className="settings-debrid__check-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={true} mode="wait">
|
||||
{!collapseState.allDebrid && (
|
||||
<motion.div
|
||||
key="alldebrid-content"
|
||||
variants={sectionVariants}
|
||||
initial="collapsed"
|
||||
animate="expanded"
|
||||
exit="collapsed"
|
||||
layout
|
||||
>
|
||||
<SettingsAllDebrid />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,8 @@ import { AddDownloadSourceModal } from "./add-download-source-modal";
|
||||
import { useAppDispatch, useRepacks, useToast } from "@renderer/hooks";
|
||||
import { DownloadSourceStatus } from "@shared";
|
||||
import { settingsContext } from "@renderer/context";
|
||||
import { downloadSourcesTable } from "@renderer/dexie";
|
||||
import { downloadSourcesWorker } from "@renderer/workers";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { setFilters, clearFilters } from "@renderer/features";
|
||||
import { generateUUID } from "@renderer/helpers";
|
||||
import "./settings-download-sources.scss";
|
||||
|
||||
export function SettingsDownloadSources() {
|
||||
@@ -52,11 +49,10 @@ export function SettingsDownloadSources() {
|
||||
const { updateRepacks } = useRepacks();
|
||||
|
||||
const getDownloadSources = async () => {
|
||||
await downloadSourcesTable
|
||||
.toCollection()
|
||||
.sortBy("createdAt")
|
||||
await window.electron
|
||||
.getDownloadSourcesList()
|
||||
.then((sources) => {
|
||||
setDownloadSources(sources.reverse());
|
||||
setDownloadSources(sources);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsFetchingSources(false);
|
||||
@@ -71,68 +67,67 @@ export function SettingsDownloadSources() {
|
||||
if (sourceUrl) setShowAddDownloadSourceModal(true);
|
||||
}, [sourceUrl]);
|
||||
|
||||
const handleRemoveSource = (downloadSource: DownloadSource) => {
|
||||
const handleRemoveSource = async (downloadSource: DownloadSource) => {
|
||||
setIsRemovingDownloadSource(true);
|
||||
const channel = new BroadcastChannel(
|
||||
`download_sources:delete:${downloadSource.id}`
|
||||
);
|
||||
|
||||
downloadSourcesWorker.postMessage([
|
||||
"DELETE_DOWNLOAD_SOURCE",
|
||||
downloadSource.id,
|
||||
]);
|
||||
try {
|
||||
await window.electron.deleteDownloadSource(downloadSource.id);
|
||||
await window.electron.removeDownloadSource(downloadSource.url);
|
||||
|
||||
channel.onmessage = () => {
|
||||
showSuccessToast(t("removed_download_source"));
|
||||
window.electron.removeDownloadSource(downloadSource.url);
|
||||
|
||||
getDownloadSources();
|
||||
setIsRemovingDownloadSource(false);
|
||||
channel.close();
|
||||
await getDownloadSources();
|
||||
updateRepacks();
|
||||
};
|
||||
} finally {
|
||||
setIsRemovingDownloadSource(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAllDownloadSources = () => {
|
||||
const handleRemoveAllDownloadSources = async () => {
|
||||
setIsRemovingDownloadSource(true);
|
||||
|
||||
const id = generateUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:delete_all:${id}`);
|
||||
try {
|
||||
await window.electron.deleteAllDownloadSources();
|
||||
await window.electron.removeDownloadSource("", true);
|
||||
|
||||
downloadSourcesWorker.postMessage(["DELETE_ALL_DOWNLOAD_SOURCES", id]);
|
||||
|
||||
channel.onmessage = () => {
|
||||
showSuccessToast(t("removed_download_sources"));
|
||||
window.electron.removeDownloadSource("", true);
|
||||
getDownloadSources();
|
||||
setIsRemovingDownloadSource(false);
|
||||
await getDownloadSources();
|
||||
setShowConfirmationDeleteAllSourcesModal(false);
|
||||
channel.close();
|
||||
updateRepacks();
|
||||
};
|
||||
} finally {
|
||||
setIsRemovingDownloadSource(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"));
|
||||
updateRepacks();
|
||||
};
|
||||
|
||||
const syncDownloadSources = async () => {
|
||||
setIsSyncingDownloadSources(true);
|
||||
|
||||
const id = generateUUID();
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${id}`);
|
||||
try {
|
||||
// Sync local sources (check for updates)
|
||||
await window.electron.syncDownloadSources();
|
||||
|
||||
downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]);
|
||||
// Refresh sources and repacks AFTER sync completes
|
||||
await getDownloadSources();
|
||||
await updateRepacks();
|
||||
|
||||
channel.onmessage = () => {
|
||||
showSuccessToast(t("download_sources_synced"));
|
||||
getDownloadSources();
|
||||
} catch (error) {
|
||||
console.error("Error syncing download sources:", error);
|
||||
// Still refresh the UI even if sync fails
|
||||
await getDownloadSources();
|
||||
await updateRepacks();
|
||||
} finally {
|
||||
setIsSyncingDownloadSources(false);
|
||||
channel.close();
|
||||
updateRepacks();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusTitle = {
|
||||
@@ -145,7 +140,12 @@ export function SettingsDownloadSources() {
|
||||
setShowAddDownloadSourceModal(false);
|
||||
};
|
||||
|
||||
const navigateToCatalogue = (fingerprint: string) => {
|
||||
const navigateToCatalogue = (fingerprint?: string) => {
|
||||
if (!fingerprint) {
|
||||
console.error("Cannot navigate: fingerprint is undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(clearFilters());
|
||||
dispatch(setFilters({ downloadSourceFingerprints: [fingerprint] }));
|
||||
|
||||
@@ -222,54 +222,58 @@ export function SettingsDownloadSources() {
|
||||
</div>
|
||||
|
||||
<ul className="settings-download-sources__list">
|
||||
{downloadSources.map((downloadSource) => (
|
||||
<li
|
||||
key={downloadSource.id}
|
||||
className={`settings-download-sources__item ${isSyncingDownloadSources ? "settings-download-sources__item--syncing" : ""}`}
|
||||
>
|
||||
<div className="settings-download-sources__item-header">
|
||||
<h2>{downloadSource.name}</h2>
|
||||
{downloadSources.map((downloadSource) => {
|
||||
return (
|
||||
<li
|
||||
key={downloadSource.id}
|
||||
className={`settings-download-sources__item ${isSyncingDownloadSources ? "settings-download-sources__item--syncing" : ""}`}
|
||||
>
|
||||
<div className="settings-download-sources__item-header">
|
||||
<h2>{downloadSource.name}</h2>
|
||||
|
||||
<div style={{ display: "flex" }}>
|
||||
<Badge>{statusTitle[downloadSource.status]}</Badge>
|
||||
<div style={{ display: "flex" }}>
|
||||
<Badge>{statusTitle[downloadSource.status]}</Badge>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="settings-download-sources__navigate-button"
|
||||
disabled={!downloadSource.fingerprint}
|
||||
onClick={() =>
|
||||
navigateToCatalogue(downloadSource.fingerprint)
|
||||
}
|
||||
>
|
||||
<small>
|
||||
{t("download_count", {
|
||||
count: downloadSource.downloadCount,
|
||||
countFormatted:
|
||||
downloadSource.downloadCount.toLocaleString(),
|
||||
})}
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="settings-download-sources__navigate-button"
|
||||
disabled={!downloadSource.fingerprint}
|
||||
onClick={() => navigateToCatalogue(downloadSource.fingerprint)}
|
||||
>
|
||||
<small>
|
||||
{t("download_count", {
|
||||
count: downloadSource.downloadCount,
|
||||
countFormatted:
|
||||
downloadSource.downloadCount.toLocaleString(),
|
||||
})}
|
||||
</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
label={t("download_source_url")}
|
||||
value={downloadSource.url}
|
||||
readOnly
|
||||
theme="dark"
|
||||
disabled
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleRemoveSource(downloadSource)}
|
||||
disabled={isRemovingDownloadSource}
|
||||
>
|
||||
<NoEntryIcon />
|
||||
{t("remove_download_source")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
<TextField
|
||||
label={t("download_source_url")}
|
||||
value={downloadSource.url}
|
||||
readOnly
|
||||
theme="dark"
|
||||
disabled
|
||||
rightContent={
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
onClick={() => handleRemoveSource(downloadSource)}
|
||||
disabled={isRemovingDownloadSource}
|
||||
>
|
||||
<NoEntryIcon />
|
||||
{t("remove_download_source")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
gameRunningSlice,
|
||||
subscriptionSlice,
|
||||
repacksSlice,
|
||||
downloadSourcesSlice,
|
||||
catalogueSearchSlice,
|
||||
} from "@renderer/features";
|
||||
|
||||
@@ -23,6 +24,7 @@ export const store = configureStore({
|
||||
gameRunning: gameRunningSlice.reducer,
|
||||
subscription: subscriptionSlice.reducer,
|
||||
repacks: repacksSlice.reducer,
|
||||
downloadSources: downloadSourcesSlice.reducer,
|
||||
catalogueSearch: catalogueSearchSlice.reducer,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
import { db, downloadSourcesTable, repacksTable } from "@renderer/dexie";
|
||||
|
||||
import { z } from "zod";
|
||||
import axios, { AxiosError, AxiosHeaders } from "axios";
|
||||
import { DownloadSourceStatus, formatName, pipe } from "@shared";
|
||||
import { GameRepack } from "@types";
|
||||
|
||||
const formatRepackName = pipe((name) => name.replace("[DL]", ""), formatName);
|
||||
|
||||
export const downloadSourceSchema = z.object({
|
||||
name: z.string().max(255),
|
||||
downloads: z.array(
|
||||
z.object({
|
||||
title: z.string().max(255),
|
||||
uris: z.array(z.string()),
|
||||
uploadDate: z.string().max(255),
|
||||
fileSize: z.string().max(255),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
type Payload =
|
||||
| ["IMPORT_DOWNLOAD_SOURCE", string]
|
||||
| ["DELETE_DOWNLOAD_SOURCE", number]
|
||||
| ["VALIDATE_DOWNLOAD_SOURCE", string]
|
||||
| ["SYNC_DOWNLOAD_SOURCES", string]
|
||||
| ["DELETE_ALL_DOWNLOAD_SOURCES", string];
|
||||
|
||||
export type SteamGamesByLetter = Record<string, { id: string; name: string }[]>;
|
||||
|
||||
const addNewDownloads = async (
|
||||
downloadSource: { id: number; name: string },
|
||||
downloads: z.infer<typeof downloadSourceSchema>["downloads"],
|
||||
steamGames: SteamGamesByLetter
|
||||
) => {
|
||||
const now = new Date();
|
||||
|
||||
const results = [] as (Omit<GameRepack, "id"> & {
|
||||
downloadSourceId: number;
|
||||
})[];
|
||||
|
||||
const objectIdsOnSource = new Set<string>();
|
||||
|
||||
for (const download of downloads) {
|
||||
const formattedTitle = formatRepackName(download.title);
|
||||
const [firstLetter] = formattedTitle;
|
||||
const games = steamGames[firstLetter] || [];
|
||||
|
||||
const gamesInSteam = games.filter((game) =>
|
||||
formattedTitle.startsWith(game.name)
|
||||
);
|
||||
|
||||
if (gamesInSteam.length === 0) continue;
|
||||
|
||||
for (const game of gamesInSteam) {
|
||||
objectIdsOnSource.add(String(game.id));
|
||||
}
|
||||
|
||||
results.push({
|
||||
objectIds: gamesInSteam.map((game) => String(game.id)),
|
||||
title: download.title,
|
||||
uris: download.uris,
|
||||
fileSize: download.fileSize,
|
||||
repacker: downloadSource.name,
|
||||
uploadDate: download.uploadDate,
|
||||
downloadSourceId: downloadSource.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
await repacksTable.bulkAdd(results);
|
||||
|
||||
await downloadSourcesTable.update(downloadSource.id, {
|
||||
objectIds: Array.from(objectIdsOnSource),
|
||||
});
|
||||
};
|
||||
|
||||
const getSteamGames = async () => {
|
||||
const response = await axios.get<SteamGamesByLetter>(
|
||||
`${import.meta.env.RENDERER_VITE_EXTERNAL_RESOURCES_URL}/steam-games-by-letter.json`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
const importDownloadSource = async (url: string) => {
|
||||
const response = await axios.get<z.infer<typeof downloadSourceSchema>>(url);
|
||||
|
||||
const steamGames = await getSteamGames();
|
||||
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
const now = new Date();
|
||||
|
||||
const id = await downloadSourcesTable.add({
|
||||
url,
|
||||
name: response.data.name,
|
||||
etag: response.headers["etag"],
|
||||
status: DownloadSourceStatus.UpToDate,
|
||||
downloadCount: response.data.downloads.length,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const downloadSource = await downloadSourcesTable.get(id);
|
||||
|
||||
await addNewDownloads(downloadSource, response.data.downloads, steamGames);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteDownloadSource = async (id: number) => {
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
await repacksTable.where({ downloadSourceId: id }).delete();
|
||||
await downloadSourcesTable.where({ id }).delete();
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAllDowloadSources = async () => {
|
||||
await db.transaction("rw", repacksTable, downloadSourcesTable, async () => {
|
||||
await repacksTable.clear();
|
||||
await downloadSourcesTable.clear();
|
||||
});
|
||||
};
|
||||
|
||||
self.onmessage = async (event: MessageEvent<Payload>) => {
|
||||
const [type, data] = event.data;
|
||||
|
||||
if (type === "VALIDATE_DOWNLOAD_SOURCE") {
|
||||
const response =
|
||||
await axios.get<z.infer<typeof downloadSourceSchema>>(data);
|
||||
|
||||
const { name } = downloadSourceSchema.parse(response.data);
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:validate:${data}`);
|
||||
|
||||
channel.postMessage({
|
||||
name,
|
||||
etag: response.headers["etag"],
|
||||
downloadCount: response.data.downloads.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (type === "DELETE_ALL_DOWNLOAD_SOURCES") {
|
||||
await deleteAllDowloadSources();
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:delete_all:${data}`);
|
||||
|
||||
channel.postMessage(true);
|
||||
}
|
||||
|
||||
if (type === "DELETE_DOWNLOAD_SOURCE") {
|
||||
await deleteDownloadSource(data);
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:delete:${data}`);
|
||||
|
||||
channel.postMessage(true);
|
||||
}
|
||||
|
||||
if (type === "IMPORT_DOWNLOAD_SOURCE") {
|
||||
await importDownloadSource(data);
|
||||
|
||||
const channel = new BroadcastChannel(`download_sources:import:${data}`);
|
||||
channel.postMessage(true);
|
||||
}
|
||||
|
||||
if (type === "SYNC_DOWNLOAD_SOURCES") {
|
||||
const channel = new BroadcastChannel(`download_sources:sync:${data}`);
|
||||
let newRepacksCount = 0;
|
||||
|
||||
try {
|
||||
const downloadSources = await downloadSourcesTable.toArray();
|
||||
const existingRepacks = await repacksTable.toArray();
|
||||
|
||||
if (downloadSources.some((source) => !source.fingerprint)) {
|
||||
await Promise.all(
|
||||
downloadSources.map(async (source) => {
|
||||
await deleteDownloadSource(source.id);
|
||||
await importDownloadSource(source.url);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
for (const downloadSource of downloadSources) {
|
||||
const headers = new AxiosHeaders();
|
||||
|
||||
if (downloadSource.etag) {
|
||||
headers.set("If-None-Match", downloadSource.etag);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(downloadSource.url, {
|
||||
headers,
|
||||
});
|
||||
|
||||
const source = downloadSourceSchema.parse(response.data);
|
||||
|
||||
const steamGames = await getSteamGames();
|
||||
|
||||
await db.transaction(
|
||||
"rw",
|
||||
repacksTable,
|
||||
downloadSourcesTable,
|
||||
async () => {
|
||||
await downloadSourcesTable.update(downloadSource.id, {
|
||||
etag: response.headers["etag"],
|
||||
downloadCount: source.downloads.length,
|
||||
status: DownloadSourceStatus.UpToDate,
|
||||
});
|
||||
|
||||
const repacks = source.downloads.filter(
|
||||
(download) =>
|
||||
!existingRepacks.some(
|
||||
(repack) => repack.title === download.title
|
||||
)
|
||||
);
|
||||
|
||||
await addNewDownloads(downloadSource, repacks, steamGames);
|
||||
|
||||
newRepacksCount += repacks.length;
|
||||
}
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const isNotModified = (err as AxiosError).response?.status === 304;
|
||||
|
||||
await downloadSourcesTable.update(downloadSource.id, {
|
||||
status: isNotModified
|
||||
? DownloadSourceStatus.UpToDate
|
||||
: DownloadSourceStatus.Errored,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
channel.postMessage(newRepacksCount);
|
||||
} catch (err) {
|
||||
channel.postMessage(-1);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import DownloadSourcesWorker from "./download-sources.worker?worker";
|
||||
|
||||
export const downloadSourcesWorker = new DownloadSourcesWorker();
|
||||
Reference in New Issue
Block a user