feat: migration to scss

This commit is contained in:
bumyy 2024-11-08 13:31:40 -03:00
parent c9e99d3852
commit 4b59a007f4
38 changed files with 402 additions and 383 deletions

View file

@ -25,7 +25,8 @@
"queued": "{{title}} (Queued)",
"game_has_no_executable": "Game has no executable selected",
"sign_in": "Sign in",
"friends": "Friends"
"friends": "Friends",
"need_help": "Need help?"
},
"header": {
"search": "Search games",
@ -254,7 +255,8 @@
"blocked_users": "Blocked users",
"user_unblocked": "User has been unblocked",
"enable_achievement_notifications": "When an achievement is unlocked",
"launch_minimized": "Launch Hydra minimized"
"launch_minimized": "Launch Hydra minimized",
"disable_nsfw_alert": "Disable NSFW alert"
},
"notifications": {
"download_complete": "Download complete",

View file

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

View file

@ -25,7 +25,8 @@
"queued": "{{title}} (Na fila)",
"game_has_no_executable": "Jogo não possui executável selecionado",
"sign_in": "Login",
"friends": "Amigos"
"friends": "Amigos",
"need_help": "Precisa de ajuda?"
},
"header": {
"search": "Buscar jogos",
@ -250,7 +251,8 @@
"blocked_users": "Usuários bloqueados",
"user_unblocked": "Usuário desbloqueado",
"enable_achievement_notifications": "Quando uma conquista é desbloqueada",
"launch_minimized": "Iniciar o Hydra minimizado"
"launch_minimized": "Iniciar o Hydra minimizado",
"disable_nsfw_alert": "Desativar alerta de conteúdo inapropriado"
},
"notifications": {
"download_complete": "Download concluído",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -102,7 +102,7 @@ export const mergeAchievements = async (
);
});
})
.filter((achievement) => achievement)
.filter((achievement) => Boolean(achievement))
.map((achievement) => {
return {
displayName: achievement!.displayName,

View file

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

View file

@ -1,9 +1,8 @@
import { Notification, app, nativeImage } from "electron";
import { Notification, app } from "electron";
import { t } from "i18next";
import { parseICO } from "icojs";
import trayIcon from "@resources/tray-icon.png?asset";
import { Game } from "@main/entity";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import { userPreferencesRepository } from "@main/repository";
import fs from "node:fs";
import axios from "axios";
import path from "node:path";
@ -11,37 +10,38 @@ import sound from "sound-play";
import { achievementSoundPath } from "@main/constants";
import icon from "@resources/icon.png?asset";
import { NotificationOptions, toXmlString } from "./xml";
import { logger } from "../logger";
const getGameIconNativeImage = async (gameId: number) => {
try {
const game = await gameRepository.findOne({
where: {
id: gameId,
},
async function downloadImage(url: string | null) {
if (!url) return undefined;
if (!url.startsWith("http")) return undefined;
const fileName = url.split("/").pop()!;
const outputPath = path.join(app.getPath("temp"), fileName);
const writer = fs.createWriteStream(outputPath);
const response = await axios.get(url, {
responseType: "stream",
});
response.data.pipe(writer);
return new Promise<string | undefined>((resolve) => {
writer.on("finish", () => {
resolve(outputPath);
});
if (!game?.iconUrl) return undefined;
const images = await parseICO(
Buffer.from(game.iconUrl.split("base64,")[1], "base64")
);
const highResIcon = images.find((image) => image.width >= 128);
if (!highResIcon) return undefined;
return nativeImage.createFromBuffer(Buffer.from(highResIcon.buffer));
} catch (err) {
return undefined;
}
};
writer.on("error", () => {
logger.error("Failed to download image", { url });
resolve(undefined);
});
});
}
export const publishDownloadCompleteNotification = async (game: Game) => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const icon = await getGameIconNativeImage(game.id);
if (userPreferences?.downloadNotificationsEnabled) {
new Notification({
title: t("download_complete", {
@ -51,7 +51,7 @@ export const publishDownloadCompleteNotification = async (game: Game) => {
ns: "notifications",
title: game.title,
}),
icon,
icon: await downloadImage(game.iconUrl),
}).show();
}
};
@ -73,28 +73,6 @@ export const publishNotificationUpdateReadyToInstall = async (
export const publishNewFriendRequestNotification = async () => {};
async function downloadImage(url: string | null) {
if (!url) return null;
if (!url.startsWith("http")) return null;
const fileName = url.split("/").pop()!;
const outputPath = path.join(app.getPath("temp"), fileName);
const writer = fs.createWriteStream(outputPath);
const response = await axios.get(url, {
responseType: "stream",
});
response.data.pipe(writer);
return new Promise<string>((resolve, reject) => {
writer.on("finish", () => {
resolve(outputPath);
});
writer.on("error", reject);
});
}
export const publishCombinedNewAchievementNotification = async (
achievementCount,
gameCount

View file

@ -56,6 +56,7 @@ export const getUserData = () => {
id: loggedUser.userId,
username: "",
bio: "",
email: null,
profileVisibility: "PUBLIC" as ProfileVisibility,
subscription: loggedUser.subscription
? {

View file

@ -85,6 +85,10 @@ export class WindowManager {
return callback(details);
}
if (details.url.includes("intercom.io")) {
return callback(details);
}
const headers = {
"access-control-allow-origin": ["*"],
"access-control-allow-methods": ["GET, POST, PUT, DELETE, OPTIONS"],

View file

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

View file

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

View file

@ -1,128 +0,0 @@
import {
ComplexStyleRule,
createContainer,
globalStyle,
style,
} from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "./theme.css";
export const appContainer = createContainer();
globalStyle("*", {
boxSizing: "border-box",
});
globalStyle("::-webkit-scrollbar", {
width: "9px",
backgroundColor: vars.color.darkBackground,
});
globalStyle("::-webkit-scrollbar-track", {
backgroundColor: "rgba(255, 255, 255, 0.03)",
});
globalStyle("::-webkit-scrollbar-thumb", {
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderRadius: "24px",
});
globalStyle("::-webkit-scrollbar-thumb:hover", {
backgroundColor: "rgba(255, 255, 255, 0.16)",
});
globalStyle("html, body, #root, main", {
height: "100%",
});
globalStyle("body", {
overflow: "hidden",
userSelect: "none",
fontFamily: "Noto Sans, sans-serif",
fontSize: vars.size.body,
color: vars.color.body,
margin: "0",
});
globalStyle("button", {
padding: "0",
backgroundColor: "transparent",
border: "none",
fontFamily: "inherit",
});
globalStyle("h1, h2, h3, h4, h5, h6, p", {
margin: 0,
});
globalStyle("p", {
lineHeight: "20px",
});
globalStyle("#root, main", {
display: "flex",
});
globalStyle("#root", {
flexDirection: "column",
});
globalStyle("main", {
overflow: "hidden",
});
globalStyle(
"input::-webkit-outer-spin-button, input::-webkit-inner-spin-button",
{
WebkitAppearance: "none",
margin: "0",
}
);
globalStyle("label", {
fontSize: vars.size.body,
});
globalStyle("input[type=number]", {
MozAppearance: "textfield",
});
globalStyle("img", {
WebkitUserDrag: "none",
} as Record<string, string>);
globalStyle("progress[value]", {
WebkitAppearance: "none",
});
export const container = style({
width: "100%",
height: "100%",
overflow: "hidden",
display: "flex",
flexDirection: "column",
containerName: appContainer,
containerType: "inline-size",
});
export const content = style({
overflowY: "auto",
alignItems: "center",
display: "flex",
flexDirection: "column",
position: "relative",
height: "100%",
background: `linear-gradient(0deg, ${vars.color.darkBackground} 50%, ${vars.color.background} 100%)`,
});
export const titleBar = style({
display: "flex",
width: "100%",
height: "35px",
minHeight: "35px",
backgroundColor: vars.color.darkBackground,
alignItems: "center",
padding: `0 ${SPACING_UNIT * 2}px`,
WebkitAppRegion: "drag",
zIndex: "4",
borderBottom: `1px solid ${vars.color.border}`,
} as ComplexStyleRule);

View file

@ -127,4 +127,10 @@ progress[value] {
-webkit-app-region: drag;
z-index: 4;
border-bottom: 1px solid globals.$border-color;
&__cloud-text {
background: linear-gradient(270deg, #16b195 50%, #3e62c0 100%);
background-clip: text;
color: transparent;
}
}

View file

@ -3,6 +3,7 @@ import { useCallback, useContext, useEffect, useRef } from "react";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import "./app.scss";
import Intercom from "@intercom/messenger-js-sdk";
import {
useAppDispatch,
@ -13,8 +14,6 @@ import {
useUserDetails,
} from "@renderer/hooks";
import * as styles from "./app.css";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import {
setSearch,
@ -36,6 +35,12 @@ export interface AppProps {
children: React.ReactNode;
}
console.log(import.meta.env);
Intercom({
app_id: import.meta.env.RENDERER_VITE_INTERCOM_APP_ID,
});
export function App() {
const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary, library } = useLibrary();
@ -56,8 +61,13 @@ export function App() {
hideFriendsModal,
} = useUserDetails();
const { userDetails, fetchUserDetails, updateUserDetails, clearUserDetails } =
useUserDetails();
const {
userDetails,
hasActiveSubscription,
fetchUserDetails,
updateUserDetails,
clearUserDetails,
} = useUserDetails();
const dispatch = useAppDispatch();
@ -206,7 +216,9 @@ export function App() {
useEffect(() => {
new MutationObserver(() => {
const modal = document.body.querySelector("[role=dialog]");
const modal = document.body.querySelector(
"[role=dialog]:not([data-intercom-frame='true'])"
);
dispatch(toggleDraggingDisabled(Boolean(modal)));
}).observe(document.body, {
@ -271,8 +283,13 @@ export function App() {
return (
<>
{window.electron.platform === "win32" && (
<div className={styles.titleBar}>
<h4>Hydra</h4>
<div className="title-bar">
<h4>
Hydra
{hasActiveSubscription && (
<span className="title-bar__cloud-text"> Cloud</span>
)}
</h4>
</div>
)}
@ -295,14 +312,14 @@ export function App() {
<main>
<Sidebar />
<article className={styles.container}>
<article className="container">
<Header
onSearch={handleSearch}
search={search}
onClear={handleClear}
/>
<section ref={contentRef} className={styles.content}>
<section ref={contentRef} className="container__content">
<Outlet />
</section>
</article>

View file

@ -4,7 +4,7 @@
color: globals.$muted-color;
font-size: 10px;
padding: calc(globals.$spacing-unit / 2) globals.$spacing-unit;
border: solid 1px globals.$border-color;
border: solid 1px globals.$muted-color;
border-radius: 4px;
display: flex;
align-items: center;

View file

@ -10,6 +10,7 @@
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
color: globals.$muted-color;
border-bottom: solid 1px globals.$border-color;
background-color: globals.$dark-background-color;
&--dragging-disabled {
-webkit-app-region: no-drag;
@ -20,7 +21,7 @@
}
&__search {
background-color: globals.$dark-background-color;
background-color: globals.$background-color;
display: inline-flex;
transition: all ease 0.2s;
width: 200px;

View file

@ -107,4 +107,30 @@
flex-direction: column;
padding-bottom: globals.$spacing-unit;
}
&__help-button {
color: globals.$muted-color;
padding: globals.$spacing-unit calc(globals.$spacing-unit * 2);
gap: 9px;
display: flex;
align-items: center;
cursor: pointer;
border-top: solid 1px globals.$border-color;
transition: background-color ease 0.1s;
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}
&__help-button-icon {
background: linear-gradient(0deg, #16b195 50%, #3e62c0 100%);
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
border-radius: 50%;
}
}

View file

@ -5,7 +5,12 @@ import { useLocation, useNavigate } from "react-router-dom";
import type { LibraryGame } from "@types";
import { TextField } from "@renderer/components";
import { useDownload, useLibrary, useToast } from "@renderer/hooks";
import {
useDownload,
useLibrary,
useToast,
useUserDetails,
} from "@renderer/hooks";
import { routes } from "./routes";
@ -17,6 +22,9 @@ import SteamLogo from "@renderer/assets/steam-logo.svg?react";
import { SidebarProfile } from "./sidebar-profile";
import { sortBy } from "lodash-es";
import cn from "classnames";
import { CommentDiscussionIcon } from "@primer/octicons-react";
import { show, update } from "@intercom/messenger-js-sdk";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
@ -44,6 +52,20 @@ export function Sidebar() {
return sortBy(library, (game) => game.title);
}, [library]);
const { userDetails, hasActiveSubscription } = useUserDetails();
useEffect(() => {
if (userDetails) {
update({
name: userDetails.displayName,
Username: userDetails.username,
Email: userDetails.email,
"Subscription expiration date": userDetails?.subscription?.expiresAt,
"Payment status": userDetails?.subscription?.status,
});
}
}, [userDetails, hasActiveSubscription]);
const { lastPacket, progress } = useDownload();
const { showWarningToast } = useToast();
@ -168,77 +190,91 @@ export function Sidebar() {
maxWidth: sidebarWidth,
}}
>
<SidebarProfile />
<div
style={{ display: "flex", flexDirection: "column", overflow: "hidden" }}
>
<SidebarProfile />
<div className="sidebar__content">
<section className="sidebar__section">
<ul className="sidebar__menu">
{routes.map(({ nameKey, path, render }) => (
<li
key={nameKey}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active": location.pathname === path,
})}
>
<button
type="button"
className="sidebar__menu-item-button"
onClick={() => handleSidebarItemClick(path)}
<div className="sidebar__content">
<section className="sidebar__section">
<ul className="sidebar__menu">
{routes.map(({ nameKey, path, render }) => (
<li
key={nameKey}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active": location.pathname === path,
})}
>
{render()}
<span>{t(nameKey)}</span>
</button>
</li>
))}
</ul>
</section>
<button
type="button"
className="sidebar__menu-item-button"
onClick={() => handleSidebarItemClick(path)}
>
{render()}
<span>{t(nameKey)}</span>
</button>
</li>
))}
</ul>
</section>
<section className="sidebar__section">
<small className="sidebar__section-title">{t("my_library")}</small>
<section className="sidebar__section">
<small className="sidebar__section-title">{t("my_library")}</small>
<TextField
ref={filterRef}
placeholder={t("filter")}
onChange={handleFilter}
theme="dark"
/>
<TextField
ref={filterRef}
placeholder={t("filter")}
onChange={handleFilter}
theme="dark"
/>
<ul className="sidebar__menu">
{filteredLibrary.map((game) => (
<li
key={game.id}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active":
location.pathname === `/game/${game.shop}/${game.objectID}`,
"sidebar__menu-item--muted": game.status === "removed",
})}
>
<button
type="button"
className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)}
<ul className="sidebar__menu">
{filteredLibrary.map((game) => (
<li
key={game.id}
className={cn("sidebar__menu-item", {
"sidebar__menu-item--active":
location.pathname ===
`/game/${game.shop}/${game.objectID}`,
"sidebar__menu-item--muted": game.status === "removed",
})}
>
{game.iconUrl ? (
<img
className="sidebar__game-icon"
src={game.iconUrl}
alt={game.title}
loading="lazy"
/>
) : (
<SteamLogo className="sidebar__game-icon" />
)}
<button
type="button"
className="sidebar__menu-item-button"
onClick={(event) => handleSidebarGameClick(event, game)}
>
{game.iconUrl ? (
<img
className="sidebar__game-icon"
src={game.iconUrl}
alt={game.title}
loading="lazy"
/>
) : (
<SteamLogo className="sidebar__game-icon" />
)}
<span className="sidebar__menu-item-button-label">
{getGameTitle(game)}
</span>
</button>
</li>
))}
</ul>
</section>
<span className="sidebar__menu-item-button-label">
{getGameTitle(game)}
</span>
</button>
</li>
))}
</ul>
</section>
</div>
</div>
{hasActiveSubscription && (
<button type="button" className="sidebar__help-button" onClick={show}>
<div className="sidebar__help-button-icon">
<CommentDiscussionIcon size={14} />
</div>
<span>{t("need_help")}</span>
</button>
)}
<button
type="button"
className="sidebar__handle"

View file

@ -1,6 +1,6 @@
@use "../../scss/globals.scss";
$toast-height: 80;
$toast-height: 80px;
.toast {
animation-duration: 0.2s;
@ -12,7 +12,7 @@ $toast-height: 80;
border: solid 1px globals.$border-color;
right: calc(globals.$spacing-unit * 2);
//bottom panel height + 16px
bottom: calc(26 + #{globals.$spacing-unit * 2});
bottom: calc(26px + #{globals.$spacing-unit * 2});
overflow: hidden;
display: flex;
flex-direction: column;
@ -24,7 +24,7 @@ $toast-height: 80;
&--closing {
animation-name: slide-out;
transform: translateY(calc($toast-height #{globals.$spacing-unit * 2}));
transform: translateY(calc($toast-height + #{globals.$spacing-unit * 2}));
}
&__content {
@ -38,10 +38,10 @@ $toast-height: 80;
&__progress {
width: 100%;
height: 5px;
::-webkit-progress-bar {
&::-webkit-progress-bar {
background-color: globals.$dark-background-color;
}
::-webkit-progress-value {
&::-webkit-progress-value {
background-color: globals.$muted-color;
}
}
@ -68,7 +68,7 @@ $toast-height: 80;
@keyframes slide-in {
0% {
transform: translateY(calc($toast-height #{globals.$spacing-unit * 2}));
transform: translateY(calc($toast-height + #{globals.$spacing-unit * 2}));
}
100% {
@ -76,12 +76,12 @@ $toast-height: 80;
}
}
@keyframes slide-in {
@keyframes slide-out {
0% {
transform: translateY(0);
}
100% {
transform: translateY(calc($toast-height #{globals.$spacing-unit * 2}));
transform: translateY(calc($toast-height + #{globals.$spacing-unit * 2}));
}
}

View file

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

View file

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

View file

@ -15,6 +15,14 @@ import * as styles from "./home.css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { buildGameDetailsPath } from "@renderer/helpers";
import { CatalogueCategory } from "@shared";
import { catalogueCacheTable, db } from "@renderer/dexie";
import { add } from "date-fns";
const categoryCacheDurationInSeconds = {
[CatalogueCategory.Hot]: 60 * 60 * 2,
[CatalogueCategory.Weekly]: 60 * 60 * 24,
[CatalogueCategory.Achievements]: 60 * 60 * 24,
};
export default function Home() {
const { t } = useTranslation("home");
@ -36,19 +44,43 @@ export default function Home() {
[CatalogueCategory.Achievements]: [],
});
const getCatalogue = useCallback((category: CatalogueCategory) => {
setCurrentCatalogueCategory(category);
setIsLoading(true);
const getCatalogue = useCallback(async (category: CatalogueCategory) => {
try {
const catalogueCache = await catalogueCacheTable
.where("expiresAt")
.above(new Date())
.and((cache) => cache.category === category)
.first();
window.electron
.getCatalogue(category)
.then((catalogue) => {
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
})
.catch(() => {})
.finally(() => {
setIsLoading(false);
setCurrentCatalogueCategory(category);
setIsLoading(true);
if (catalogueCache)
return setCatalogue((prev) => ({
...prev,
[category]: catalogueCache.games,
}));
const catalogue = await window.electron.getCatalogue(category);
db.transaction("rw", catalogueCacheTable, async () => {
await catalogueCacheTable.where("category").equals(category).delete();
await catalogueCacheTable.add({
category,
games: catalogue,
createdAt: new Date(),
updatedAt: new Date(),
expiresAt: add(new Date(), {
seconds: categoryCacheDurationInSeconds[category],
}),
});
});
setCatalogue((prev) => ({ ...prev, [category]: catalogue }));
} finally {
setIsLoading(false);
}
}, []);
const getRandomGame = useCallback(() => {

View file

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

View file

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

View file

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

View file

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