diff --git a/package.json b/package.json index b16e792f..2850454d 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "electron-log": "^5.2.4", "electron-updater": "^6.6.2", "file-type": "^20.5.0", + "framer-motion": "^12.15.0", "i18next": "^23.11.2", "i18next-browser-languagedetector": "^7.2.1", "jsdom": "^24.0.0", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 91946baf..b01268a3 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -205,7 +205,21 @@ "create_start_menu_shortcut": "Create Start Menu shortcut", "invalid_wine_prefix_path": "Invalid Wine prefix path", "invalid_wine_prefix_path_description": "The path to the Wine prefix is invalid. Please check the path and try again.", - "missing_wine_prefix": "Wine prefix is required to create a backup on Linux" + "missing_wine_prefix": "Wine prefix is required to create a backup on Linux", + "artifact_renamed": "Backup renamed successfully", + "rename_artifact": "Rename Backup", + "rename_artifact_description": "Rename the backup to a more descriptive name", + "artifact_name_label": "Backup name", + "artifact_name_placeholder": "Enter a name for the backup", + "save_changes": "Save changes", + "required_field": "This field is required", + "max_length_field": "This field must be less than {{length}} characters", + "freeze_backup": "Pin it so it's not overwritten by automatic backups", + "unfreeze_backup": "Unpin it", + "backup_frozen": "Backup pinned", + "backup_unfrozen": "Backup unpinned", + "backup_freeze_failed": "Failed to freeze backup", + "backup_freeze_failed_description": "You must leave at least one free slot for automatic backups" }, "activation": { "title": "Activate Hydra", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index af650966..a45af998 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -192,7 +192,21 @@ "automatically_extract_downloaded_files": "Extrair automaticamente os arquivos baixados", "create_start_menu_shortcut": "Criar atalho no Menu Iniciar", "invalid_wine_prefix_path": "Caminho do prefixo Wine inválido", - "invalid_wine_prefix_path_description": "O caminho para o prefixo Wine é inválido. Por favor, verifique o caminho e tente novamente." + "invalid_wine_prefix_path_description": "O caminho para o prefixo Wine é inválido. Por favor, verifique o caminho e tente novamente.", + "artifact_renamed": "Backup renomeado com sucesso", + "rename_artifact": "Renomear Backup", + "rename_artifact_description": "Renomeie o backup para um nome mais descritivo", + "artifact_name_label": "Nome do backup", + "artifact_name_placeholder": "Insira um nome para o backup", + "save_changes": "Salvar mudanças", + "required_field": "Este campo é obrigatório", + "max_length_field": "Este campo deve ter menos de {{length}} caracteres", + "freeze_backup": "Fixar para não ser apagado por backups automáticos", + "unfreeze_backup": "Remover dos fixados", + "backup_frozen": "Backup fixado", + "backup_unfrozen": "Backup removido dos fixados", + "backup_freeze_failed": "Falha ao fixar backup", + "backup_freeze_failed_description": "Você deve deixar pelo menos um espaço livre para backups automáticos" }, "activation": { "title": "Ativação", diff --git a/src/main/events/cloud-save/rename-game-artifact.ts b/src/main/events/cloud-save/rename-game-artifact.ts new file mode 100644 index 00000000..f8257c4b --- /dev/null +++ b/src/main/events/cloud-save/rename-game-artifact.ts @@ -0,0 +1,14 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const renameGameArtifact = async ( + _event: Electron.IpcMainInvokeEvent, + gameArtifactId: string, + label: string +) => { + await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}`, { + label, + }); +}; + +registerEvent("renameGameArtifact", renameGameArtifact); diff --git a/src/main/events/cloud-save/toggle-artifact-freeze.ts b/src/main/events/cloud-save/toggle-artifact-freeze.ts new file mode 100644 index 00000000..d532d459 --- /dev/null +++ b/src/main/events/cloud-save/toggle-artifact-freeze.ts @@ -0,0 +1,16 @@ +import { registerEvent } from "../register-event"; +import { HydraApi } from "@main/services"; + +const toggleArtifactFreeze = async ( + _event: Electron.IpcMainInvokeEvent, + gameArtifactId: string, + freeze: boolean +) => { + if (freeze) { + await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}/freeze`); + } else { + await HydraApi.put(`/profile/games/artifacts/${gameArtifactId}/unfreeze`); + } +}; + +registerEvent("toggleArtifactFreeze", toggleArtifactFreeze); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index ed77cadb..e4e6ed2e 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -87,6 +87,8 @@ import "./cloud-save/get-game-backup-preview"; import "./cloud-save/upload-save-game"; import "./cloud-save/delete-game-artifact"; import "./cloud-save/select-game-backup-path"; +import "./cloud-save/toggle-artifact-freeze"; +import "./cloud-save/rename-game-artifact"; import "./notifications/publish-new-repacks-notification"; import "./notifications/update-achievement-notification-window"; import "./notifications/show-achievement-test-notification"; diff --git a/src/main/services/ludusavi.ts b/src/main/services/ludusavi.ts index 133f764e..f067b507 100644 --- a/src/main/services/ludusavi.ts +++ b/src/main/services/ludusavi.ts @@ -16,7 +16,10 @@ export class Ludusavi { SystemPath.getPath("userData"), "ludusavi" ); - private static binaryPath = path.join(this.configPath, "ludusavi"); + private static binaryName = + process.platform === "win32" ? "ludusavi.exe" : "ludusavi"; + + private static binaryPath = path.join(this.configPath, this.binaryName); public static async getConfig() { const config = YAML.parse( @@ -40,7 +43,7 @@ export class Ludusavi { public static async copyBinaryToUserData() { if (!fs.existsSync(this.binaryPath)) { fs.cpSync( - path.join(this.ludusaviResourcesPath, "ludusavi"), + path.join(this.ludusaviResourcesPath, this.binaryName), this.binaryPath ); } diff --git a/src/preload/index.ts b/src/preload/index.ts index e660b7b9..2a8297f1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -236,6 +236,10 @@ contextBridge.exposeInMainWorld("electron", { downloadOptionTitle: string | null ) => ipcRenderer.invoke("uploadSaveGame", objectId, shop, downloadOptionTitle), + toggleArtifactFreeze: (gameArtifactId: string, freeze: boolean) => + ipcRenderer.invoke("toggleArtifactFreeze", gameArtifactId, freeze), + renameGameArtifact: (gameArtifactId: string, label: string) => + ipcRenderer.invoke("renameGameArtifact", gameArtifactId, label), downloadGameArtifact: ( objectId: string, shop: GameShop, diff --git a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx index 5671ca56..f9287a11 100644 --- a/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx +++ b/src/renderer/src/context/cloud-sync/cloud-sync.context.tsx @@ -30,9 +30,14 @@ export interface CloudSyncContext { setShowCloudSyncFilesModal: React.Dispatch>; getGameBackupPreview: () => Promise; getGameArtifacts: () => Promise; + toggleArtifactFreeze: ( + gameArtifactId: string, + freeze: boolean + ) => Promise; restoringBackup: boolean; uploadingBackup: boolean; loadingPreview: boolean; + freezingArtifact: boolean; } export const cloudSyncContext = createContext({ @@ -47,10 +52,12 @@ export const cloudSyncContext = createContext({ showCloudSyncFilesModal: false, setShowCloudSyncFilesModal: () => {}, getGameBackupPreview: async () => {}, + toggleArtifactFreeze: async () => {}, getGameArtifacts: async () => {}, restoringBackup: false, uploadingBackup: false, loadingPreview: false, + freezingArtifact: false, }); const { Provider } = cloudSyncContext; @@ -78,6 +85,7 @@ export function CloudSyncContextProvider({ const [uploadingBackup, setUploadingBackup] = useState(false); const [showCloudSyncFilesModal, setShowCloudSyncFilesModal] = useState(false); const [loadingPreview, setLoadingPreview] = useState(false); + const [freezingArtifact, setFreezingArtifact] = useState(false); const { showSuccessToast } = useToast(); @@ -119,6 +127,22 @@ export function CloudSyncContextProvider({ [objectId, shop] ); + const toggleArtifactFreeze = useCallback( + async (gameArtifactId: string, freeze: boolean) => { + setFreezingArtifact(true); + try { + await window.electron.toggleArtifactFreeze(gameArtifactId, freeze); + getGameArtifacts(); + } catch (err) { + logger.error("Failed to toggle artifact freeze", objectId, shop, err); + throw err; + } finally { + setFreezingArtifact(false); + } + }, + [objectId, shop, getGameArtifacts] + ); + useEffect(() => { const removeUploadCompleteListener = window.electron.onUploadComplete( objectId, @@ -192,6 +216,7 @@ export function CloudSyncContextProvider({ uploadingBackup, showCloudSyncFilesModal, loadingPreview, + freezingArtifact, setShowCloudSyncModal, uploadSaveGame, downloadGameArtifact, @@ -199,6 +224,7 @@ export function CloudSyncContextProvider({ setShowCloudSyncFilesModal, getGameBackupPreview, getGameArtifacts, + toggleArtifactFreeze, }} > {children} diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 509b34f3..79c72a1c 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -201,6 +201,14 @@ declare global { shop: GameShop, downloadOptionTitle: string | null ) => Promise; + toggleArtifactFreeze: ( + gameArtifactId: string, + freeze: boolean + ) => Promise; + renameGameArtifact: ( + gameArtifactId: string, + label: string + ) => Promise; downloadGameArtifact: ( objectId: string, shop: GameShop, diff --git a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.scss b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.scss index d096d16f..0d8ee4f0 100644 --- a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.scss +++ b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.scss @@ -31,6 +31,20 @@ gap: globals.$spacing-unit; } + &__artifact-label { + display: flex; + align-items: center; + gap: 8px; + color: globals.$body-color; + font-size: 16px; + font-weight: 600; + text-align: left; + padding: 0; + background-color: transparent; + border: none; + cursor: pointer; + } + &__artifacts { display: flex; gap: globals.$spacing-unit; diff --git a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx index bd70aec1..e30b244c 100644 --- a/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx +++ b/src/renderer/src/pages/game-details/cloud-sync-modal/cloud-sync-modal.tsx @@ -1,7 +1,6 @@ import { Button, Modal, ModalProps } from "@renderer/components"; import { useContext, useEffect, useMemo, useState } from "react"; import { cloudSyncContext, gameDetailsContext } from "@renderer/context"; - import "./cloud-sync-modal.scss"; import { formatBytes } from "@shared"; import { @@ -9,6 +8,9 @@ import { DeviceDesktopIcon, HistoryIcon, InfoIcon, + PencilIcon, + PinIcon, + PinSlashIcon, SyncIcon, TrashIcon, UploadIcon, @@ -17,6 +19,10 @@ import { useAppSelector, useDate, useToast } from "@renderer/hooks"; import { useTranslation } from "react-i18next"; import { AxiosProgressEvent } from "axios"; import { formatDownloadProgress } from "@renderer/helpers"; +import { CloudSyncRenameArtifactModal } from "../cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal"; +import { GameArtifact } from "@types"; +import { motion, AnimatePresence } from "framer-motion"; +import { orderBy } from "lodash-es"; export interface CloudSyncModalProps extends Omit {} @@ -25,9 +31,11 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { const [deletingArtifact, setDeletingArtifact] = useState(false); const [backupDownloadProgress, setBackupDownloadProgress] = useState(null); + const [artifactToRename, setArtifactToRename] = useState( + null + ); const { t } = useTranslation("game_details"); - const { formatDate, formatDateTime } = useDate(); const { @@ -36,9 +44,11 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { uploadingBackup, restoringBackup, loadingPreview, + freezingArtifact, uploadSaveGame, downloadGameArtifact, deleteGameArtifact, + toggleArtifactFreeze, setShowCloudSyncFilesModal, getGameBackupPreview, } = useContext(cloudSyncContext); @@ -50,10 +60,8 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { const handleDeleteArtifactClick = async (gameArtifactId: string) => { setDeletingArtifact(true); - try { await deleteGameArtifact(gameArtifactId); - showSuccessToast(t("backup_deleted")); } catch (err) { showErrorToast("backup_deletion_failed"); @@ -71,7 +79,6 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { setBackupDownloadProgress(progressEvent); } ); - return () => { removeBackupDownloadProgressListener(); }; @@ -82,6 +89,21 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { downloadGameArtifact(artifactId); }; + const handleFreezeArtifactClick = async ( + artifactId: string, + isFrozen: boolean + ) => { + try { + await toggleArtifactFreeze(artifactId, isFrozen); + showSuccessToast(isFrozen ? t("backup_frozen") : t("backup_unfrozen")); + } catch (err) { + showErrorToast( + t("backup_freeze_failed"), + t("backup_freeze_failed_description") + ); + } + }; + useEffect(() => { if (visible) { getGameBackupPreview(); @@ -100,7 +122,6 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { ); } - if (restoringBackup) { return ( @@ -113,7 +134,6 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { ); } - if (loadingPreview) { return ( @@ -122,19 +142,15 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { ); } - if (artifacts.length >= backupsPerGameLimit) { return t("max_number_of_artifacts_reached"); } - if (!backupPreview) { return t("no_backup_preview"); } - if (artifacts.length === 0) { return t("no_backups"); } - return ""; }, [ uploadingBackup, @@ -147,121 +163,168 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) { t, ]); - const disableActions = uploadingBackup || restoringBackup || deletingArtifact; + const disableActions = + uploadingBackup || restoringBackup || deletingArtifact || freezingArtifact; const isMissingWinePrefix = window.electron.platform === "linux" && !game?.winePrefixPath; return ( - -
-
-

{gameTitle}

-

{backupStateLabel}

+ <> + setArtifactToRename(null)} + artifact={artifactToRename} + /> - +
+ + + {uploadingBackup ? ( + + ) : ( + + )} + {t("create_backup")} +
- - +
+

{t("backups")}

+ + {artifacts.length} / {backupsPerGameLimit} + +
-
-

{t("backups")}

- - {artifacts.length} / {backupsPerGameLimit} - -
+ {artifacts.length > 0 ? ( +
    + + {orderBy(artifacts, [(a) => !a.isFrozen], ["asc"]).map( + (artifact) => ( + +
    +
    + + + {formatBytes(artifact.artifactLengthInBytes)} + +
    - {artifacts.length > 0 ? ( -
      - {artifacts.map((artifact) => ( -
    • -
      -
      -

      - {artifact.label ?? - t("backup_from", { - date: formatDate(artifact.createdAt), - })} -

      - {formatBytes(artifact.artifactLengthInBytes)} -
      + + + {artifact.hostname} + - - - {artifact.hostname} - + + + {artifact.downloadOptionTitle ?? + t("no_download_option_info")} + - - - {artifact.downloadOptionTitle ?? t("no_download_option_info")} - + + + {formatDateTime(artifact.createdAt)} + +
      - - - {formatDateTime(artifact.createdAt)} - -
    - -
    - - -
    - - ))} -
- ) : ( -

{t("no_backups_created")}

- )} -
+
+ + + +
+ + ) + )} + + + ) : ( +

{t("no_backups_created")}

+ )} + + ); } diff --git a/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.scss b/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.scss new file mode 100644 index 00000000..a0cf2a80 --- /dev/null +++ b/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.scss @@ -0,0 +1,8 @@ +.cloud-sync-rename-artifact-modal { + &__form-actions { + display: flex; + gap: 8px; + margin-top: 16px; + justify-content: flex-end; + } +} diff --git a/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.tsx b/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.tsx new file mode 100644 index 00000000..25525331 --- /dev/null +++ b/src/renderer/src/pages/game-details/cloud-sync-rename-artifact-modal/cloud-sync-rename-artifact-modal.tsx @@ -0,0 +1,101 @@ +import { useCallback, useContext, useEffect } from "react"; +import { Button, Modal, ModalProps, TextField } from "@renderer/components"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import type { GameArtifact } from "@types"; +import { cloudSyncContext } from "@renderer/context"; +import { logger } from "@renderer/logger"; +import * as yup from "yup"; +import { yupResolver } from "@hookform/resolvers/yup"; +import { InferType } from "yup"; +import { useToast } from "@renderer/hooks"; + +import "./cloud-sync-rename-artifact-modal.scss"; + +export interface CloudSyncRenameArtifactModalProps + extends Omit { + artifact: GameArtifact | null; +} + +export function CloudSyncRenameArtifactModal({ + visible, + onClose, + artifact, +}: Readonly) { + const { t } = useTranslation("game_details"); + + const validationSchema = yup.object({ + label: yup + .string() + .required(t("required_field")) + .max(255, t("max_length_field", { length: 255 })), + }); + + const { getGameArtifacts } = useContext(cloudSyncContext); + + const { + register, + handleSubmit, + setValue, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + label: artifact?.label ?? "", + }, + resolver: yupResolver(validationSchema), + }); + + const { showSuccessToast } = useToast(); + + useEffect(() => { + if (artifact) { + setValue("label", artifact.label ?? ""); + } + }, [artifact, setValue]); + + const onSubmit = useCallback( + async (data: InferType) => { + try { + if (!artifact) return; + + await window.electron.renameGameArtifact(artifact.id, data.label); + await getGameArtifacts(); + + showSuccessToast(t("artifact_renamed")); + + onClose(); + } catch (err) { + logger.error("Failed to rename artifact", err); + } + }, + [artifact, getGameArtifacts, onClose, showSuccessToast, t] + ); + + return ( + +
+ + +
+ + + +
+ +
+ ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 0a1cb713..139cc20e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -282,6 +282,7 @@ export interface GameArtifact { hostname: string; downloadCount: number; label?: string; + isFrozen: boolean; } export interface ComparedAchievements { diff --git a/yarn.lock b/yarn.lock index a745b484..d34598e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5646,6 +5646,15 @@ formdata-polyfill@^4.0.10: dependencies: fetch-blob "^3.1.2" +framer-motion@^12.15.0: + version "12.15.0" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.15.0.tgz#6892283fc7967b071f537d6d160ab49e3d5e73ae" + integrity sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg== + dependencies: + motion-dom "^12.15.0" + motion-utils "^12.12.1" + tslib "^2.4.0" + fs-extra@^10.0.0, fs-extra@^10.1.0: version "10.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" @@ -7304,6 +7313,18 @@ module-error@^1.0.1: resolved "https://registry.yarnpkg.com/module-error/-/module-error-1.0.2.tgz#8d1a48897ca883f47a45816d4fb3e3c6ba404d86" integrity sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA== +motion-dom@^12.15.0: + version "12.15.0" + resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.15.0.tgz#eca7c9d8c28976b8c920f175f92d5288f5a17785" + integrity sha512-D2ldJgor+2vdcrDtKJw48k3OddXiZN1dDLLWrS8kiHzQdYVruh0IoTwbJBslrnTXIPgFED7PBN2Zbwl7rNqnhA== + dependencies: + motion-utils "^12.12.1" + +motion-utils@^12.12.1: + version "12.12.1" + resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.12.1.tgz#63e28751325cb9d1cd684f3c273a570022b0010e" + integrity sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w== + ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" @@ -9041,7 +9062,7 @@ ts-node@^10.9.2: v8-compile-cache-lib "^3.0.1" yn "3.1.1" -tslib@^2.0.0, tslib@^2.1.0: +tslib@^2.0.0, tslib@^2.1.0, tslib@^2.4.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==