feat: adding backup freezing

This commit is contained in:
Chubby Granny Chaser
2025-05-30 12:28:27 +01:00
parent 9e9adfcc07
commit dac29767bd
13 changed files with 406 additions and 127 deletions

View File

@@ -0,0 +1,73 @@
[ARankWorldDLC]
Achieved=1
CurProgress=0
MaxProgress=0
UnlockTime=1740593973
[BoughtAllItems]
Achieved=1
CurProgress=0
MaxProgress=0
UnlockTime=1740592806
[CompleteWorldDLC]
Achieved=1
CurProgress=0
MaxProgress=0
UnlockTime=1740593973
[DefeatBoss]
Achieved=1
CurProgress=0
MaxProgress=0
UnlockTime=1740593973
[DefeatBossAsChalice]
Achieved=1
CurProgress=0
MaxProgress=0
UnlockTime=1740593973
[DefeatBossDLCWeapon]
Achieved=1
CurProgress=0
MaxProgress=0
UnlockTime=1740593973
[DefeatSaltbaker]
Achieved=1
CurProgress=0
MaxProgress=0
UnlockTime=1740595074
[DefeatXBossesAsChalice]
Achieved=1
CurProgress=0
MaxProgress=0
UnlockTime=1740593973
[NewGamePlus]
Achieved=1
CurProgress=0
MaxProgress=0
UnlockTime=1740594289
[NoHitsTaken]
Achieved=1
CurProgress=0
MaxProgress=0
UnlockTime=1740593973
[SteamAchievements]
00000=BoughtAllItems
00001=CompleteWorldDLC
00002=ARankWorldDLC
00003=NoHitsTaken
00004=DefeatBossDLCWeapon
00005=DefeatBoss
00006=DefeatBossAsChalice
00007=DefeatXBossesAsChalice
00008=NewGamePlus
00009=DefeatSaltbaker
Count=10

View File

@@ -0,0 +1,3 @@
[SteamLeaderboards]
Count=0

View File

@@ -0,0 +1,6 @@
[UserStats]
ARanks=25
BossesDefeatedNormal=17
HangTime=2.75
Parries=7

View File

@@ -205,7 +205,19 @@
"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 {{lenght}} 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"
},
"activation": {
"title": "Activate Hydra",

View File

@@ -192,7 +192,19 @@
"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 {{lenght}} 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"
},
"activation": {
"title": "Ativação",

View File

@@ -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);

View File

@@ -88,6 +88,7 @@ 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";

View File

@@ -238,6 +238,8 @@ contextBridge.exposeInMainWorld("electron", {
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,

View File

@@ -205,6 +205,10 @@ declare global {
gameArtifactId: string,
freeze: boolean
) => Promise<void>;
renameGameArtifact: (
gameArtifactId: string,
label: string
) => Promise<void>;
downloadGameArtifact: (
objectId: string,
shop: GameShop,

View File

@@ -31,6 +31,23 @@
gap: globals.$spacing-unit;
}
&__artifact-label {
display: flex;
align-items: center;
gap: 4px;
color: globals.$body-color;
font-size: 16px;
font-weight: 600;
text-align: left;
background-color: transparent;
border: none;
padding: 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
&__artifacts {
display: flex;
gap: globals.$spacing-unit;

View File

@@ -7,19 +7,21 @@ import { formatBytes } from "@shared";
import {
ClockIcon,
DeviceDesktopIcon,
DownloadIcon,
HistoryIcon,
InfoIcon,
LockIcon,
PencilIcon,
PinIcon,
PinSlashIcon,
SyncIcon,
TrashIcon,
UnlockIcon,
UploadIcon,
} from "@primer/octicons-react";
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";
export interface CloudSyncModalProps
extends Omit<ModalProps, "children" | "title"> {}
@@ -28,6 +30,9 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
const [deletingArtifact, setDeletingArtifact] = useState(false);
const [backupDownloadProgress, setBackupDownloadProgress] =
useState<AxiosProgressEvent | null>(null);
const [artifactToRename, setArtifactToRename] = useState<GameArtifact | null>(
null
);
const { t } = useTranslation("game_details");
@@ -87,11 +92,17 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
downloadGameArtifact(artifactId);
};
const handleFreezeArtifactClick = async (artifactId: string) => {
await toggleArtifactFreeze(
artifactId,
!artifacts.find((artifact) => artifact.id === artifactId)?.isFrozen
);
const handleFreezeArtifactClick = async (
artifactId: string,
isFrozen: boolean
) => {
await toggleArtifactFreeze(artifactId, isFrozen);
if (isFrozen) {
showSuccessToast(t("backup_frozen"));
} else {
showSuccessToast(t("backup_unfrozen"));
}
};
useEffect(() => {
@@ -165,132 +176,147 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
window.electron.platform === "linux" && !game?.winePrefixPath;
return (
<Modal
visible={visible}
title={t("cloud_save")}
description={t("cloud_save_description")}
onClose={onClose}
large
>
<div className="cloud-sync-modal__header">
<div className="cloud-sync-modal__title-container">
<h2>{gameTitle}</h2>
<p>{backupStateLabel}</p>
<>
<CloudSyncRenameArtifactModal
visible={!!artifactToRename}
onClose={() => setArtifactToRename(null)}
artifact={artifactToRename}
/>
<button
<Modal
visible={visible}
title={t("cloud_save")}
description={t("cloud_save_description")}
onClose={onClose}
large
>
<div className="cloud-sync-modal__header">
<div className="cloud-sync-modal__title-container">
<h2>{gameTitle}</h2>
<p>{backupStateLabel}</p>
<button
type="button"
className="cloud-sync-modal__manage-files-button"
onClick={() => setShowCloudSyncFilesModal(true)}
disabled={disableActions}
>
{t("manage_files")}
</button>
</div>
<Button
type="button"
className="cloud-sync-modal__manage-files-button"
onClick={() => setShowCloudSyncFilesModal(true)}
disabled={disableActions}
onClick={() => uploadSaveGame(lastDownloadedOption?.title ?? null)}
tooltip={isMissingWinePrefix ? t("missing_wine_prefix") : undefined}
tooltipPlace="left"
disabled={
disableActions ||
!backupPreview?.overall.totalGames ||
artifacts.length >= backupsPerGameLimit ||
isMissingWinePrefix
}
>
{t("manage_files")}
</button>
{uploadingBackup ? (
<SyncIcon className="cloud-sync-modal__sync-icon" />
) : (
<UploadIcon />
)}
{t("create_backup")}
</Button>
</div>
<Button
type="button"
onClick={() => uploadSaveGame(lastDownloadedOption?.title ?? null)}
tooltip={isMissingWinePrefix ? t("missing_wine_prefix") : undefined}
tooltipPlace="left"
disabled={
disableActions ||
!backupPreview?.overall.totalGames ||
artifacts.length >= backupsPerGameLimit ||
isMissingWinePrefix
}
>
{uploadingBackup ? (
<SyncIcon className="cloud-sync-modal__sync-icon" />
) : (
<UploadIcon />
)}
{t("create_backup")}
</Button>
</div>
<div className="cloud-sync-modal__backups-header">
<h2>{t("backups")}</h2>
<small>
{artifacts.length} / {backupsPerGameLimit}
</small>
</div>
<div className="cloud-sync-modal__backups-header">
<h2>{t("backups")}</h2>
<small>
{artifacts.length} / {backupsPerGameLimit}
</small>
</div>
{artifacts.length > 0 ? (
<ul className="cloud-sync-modal__artifacts">
{artifacts.map((artifact) => (
<li key={artifact.id} className="cloud-sync-modal__artifact">
<div className="cloud-sync-modal__artifact-info">
<div className="cloud-sync-modal__artifact-header">
<button
type="button"
className="cloud-sync-modal__artifact-label"
onClick={() => setArtifactToRename(artifact)}
>
{artifact.label ??
t("backup_from", {
date: formatDate(artifact.createdAt),
})}
{artifacts.length > 0 ? (
<ul className="cloud-sync-modal__artifacts">
{artifacts.map((artifact) => (
<li key={artifact.id} className="cloud-sync-modal__artifact">
<div className="cloud-sync-modal__artifact-info">
<div className="cloud-sync-modal__artifact-header">
<button type="button">
{artifact.label ??
t("backup_from", {
date: formatDate(artifact.createdAt),
})}
<PencilIcon />
</button>
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
</div>
<PencilIcon />
</button>
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
<span className="cloud-sync-modal__artifact-meta">
<DeviceDesktopIcon size={14} />
{artifact.hostname}
</span>
<span className="cloud-sync-modal__artifact-meta">
<InfoIcon size={14} />
{artifact.downloadOptionTitle ??
t("no_download_option_info")}
</span>
<span className="cloud-sync-modal__artifact-meta">
<ClockIcon size={14} />
{formatDateTime(artifact.createdAt)}
</span>
</div>
<span className="cloud-sync-modal__artifact-meta">
<DeviceDesktopIcon size={14} />
{artifact.hostname}
</span>
<span className="cloud-sync-modal__artifact-meta">
<InfoIcon size={14} />
{artifact.downloadOptionTitle ?? t("no_download_option_info")}
</span>
<span className="cloud-sync-modal__artifact-meta">
<ClockIcon size={14} />
{formatDateTime(artifact.createdAt)}
</span>
</div>
<div className="cloud-sync-modal__artifact-actions">
<Button
type="button"
tooltip={
artifact.isFrozen
? t("unfreeze_backup")
: t("freeze_backup")
}
theme={artifact.isFrozen ? "danger" : "outline"}
onClick={() => handleFreezeArtifactClick(artifact.id)}
disabled={disableActions}
>
{artifact.isFrozen ? <UnlockIcon /> : <LockIcon />}
</Button>
<Button
type="button"
onClick={() => handleBackupInstallClick(artifact.id)}
disabled={disableActions}
tooltip={t("install_backup")}
theme="outline"
>
{restoringBackup ? (
<SyncIcon className="cloud-sync-modal__sync-icon" />
) : (
<DownloadIcon />
)}
</Button>
<Button
type="button"
onClick={() => handleDeleteArtifactClick(artifact.id)}
theme="danger"
disabled={disableActions || artifact.isFrozen}
>
<TrashIcon />
{t("delete_backup")}
</Button>
</div>
</li>
))}
</ul>
) : (
<p>{t("no_backups_created")}</p>
)}
</Modal>
<div className="cloud-sync-modal__artifact-actions">
<Button
type="button"
tooltip={
artifact.isFrozen
? t("unfreeze_backup")
: t("freeze_backup")
}
theme={artifact.isFrozen ? "primary" : "outline"}
onClick={() =>
handleFreezeArtifactClick(artifact.id, !artifact.isFrozen)
}
disabled={disableActions}
>
{artifact.isFrozen ? <PinSlashIcon /> : <PinIcon />}
</Button>
<Button
type="button"
onClick={() => handleBackupInstallClick(artifact.id)}
disabled={disableActions}
theme="outline"
>
{restoringBackup ? (
<SyncIcon className="cloud-sync-modal__sync-icon" />
) : (
<HistoryIcon />
)}
{t("install_backup")}
</Button>
<Button
type="button"
onClick={() => handleDeleteArtifactClick(artifact.id)}
disabled={disableActions || artifact.isFrozen}
theme="outline"
tooltip={t("delete_backup")}
>
<TrashIcon />
</Button>
</div>
</li>
))}
</ul>
) : (
<p>{t("no_backups_created")}</p>
)}
</Modal>
</>
);
}

View File

@@ -0,0 +1,8 @@
.cloud-sync-rename-artifact-modal {
&__form-actions {
display: flex;
gap: 8px;
margin-top: 16px;
justify-content: flex-end;
}
}

View File

@@ -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<ModalProps, "children" | "title"> {
artifact: GameArtifact | null;
}
export function CloudSyncRenameArtifactModal({
visible,
onClose,
artifact,
}: CloudSyncRenameArtifactModalProps) {
const { t } = useTranslation("game_details");
const validationSchema = yup.object({
label: yup
.string()
.required(t("required_field"))
.max(255, t("max_length_field", { lenght: 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<typeof validationSchema>) => {
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 (
<Modal
visible={visible}
title={t("rename_artifact")}
description={t("rename_artifact_description")}
onClose={onClose}
>
<form onSubmit={handleSubmit(onSubmit)}>
<TextField
label={t("artifact_name_label")}
placeholder={t("artifact_name_placeholder")}
{...register("label")}
error={errors.label?.message}
/>
<div className="cloud-sync-rename-artifact-modal__form-actions">
<Button theme="outline" onClick={onClose}>
{t("cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
{t("save_changes")}
</Button>
</div>
</form>
</Modal>
);
}