mirror of
https://github.com/hydralauncher/hydra.git
synced 2025-03-09 15:40:26 +00:00
feat: migration to scss
This commit is contained in:
parent
c9e99d3852
commit
4b59a007f4
38 changed files with 402 additions and 383 deletions
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "Поиск",
|
||||
|
|
|
@ -38,6 +38,9 @@ export class UserPreferences {
|
|||
@Column("boolean", { default: false })
|
||||
startMinimized: boolean;
|
||||
|
||||
@Column("boolean", { default: false })
|
||||
disableNsfwAlert: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
|
|
|
@ -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!);
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
},
|
||||
};
|
|
@ -102,7 +102,7 @@ export const mergeAchievements = async (
|
|||
);
|
||||
});
|
||||
})
|
||||
.filter((achievement) => achievement)
|
||||
.filter((achievement) => Boolean(achievement))
|
||||
.map((achievement) => {
|
||||
return {
|
||||
displayName: achievement!.displayName,
|
||||
|
|
34
src/main/services/hydra-analytics.ts
Normal file
34
src/main/services/hydra-analytics.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -56,6 +56,7 @@ export const getUserData = () => {
|
|||
id: loggedUser.userId,
|
||||
username: "",
|
||||
bio: "",
|
||||
email: null,
|
||||
profileVisibility: "PUBLIC" as ProfileVisibility,
|
||||
subscription: loggedUser.subscription
|
||||
? {
|
||||
|
|
|
@ -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"],
|
||||
|
|
1
src/main/vite-env.d.ts
vendored
1
src/main/vite-env.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -147,7 +147,8 @@ export function GameDetailsContextProvider({
|
|||
if (
|
||||
result?.content_descriptors.ids.includes(
|
||||
SteamContentDescriptor.AdultOnlySexualContent
|
||||
)
|
||||
) &&
|
||||
!userPreferences?.disableNsfwAlert
|
||||
) {
|
||||
setHasNSFWContentBlocked(true);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
8
src/renderer/src/vite-env.d.ts
vendored
8
src/renderer/src/vite-env.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue