Merge pull request #1746 from hydralauncher/feat/HYD-858

Feat/hyd 858
This commit is contained in:
Chubby Granny Chaser
2025-05-30 14:18:29 +01:00
committed by GitHub
16 changed files with 428 additions and 118 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

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

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -30,9 +30,14 @@ export interface CloudSyncContext {
setShowCloudSyncFilesModal: React.Dispatch<React.SetStateAction<boolean>>;
getGameBackupPreview: () => Promise<void>;
getGameArtifacts: () => Promise<void>;
toggleArtifactFreeze: (
gameArtifactId: string,
freeze: boolean
) => Promise<void>;
restoringBackup: boolean;
uploadingBackup: boolean;
loadingPreview: boolean;
freezingArtifact: boolean;
}
export const cloudSyncContext = createContext<CloudSyncContext>({
@@ -47,10 +52,12 @@ export const cloudSyncContext = createContext<CloudSyncContext>({
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}

View File

@@ -201,6 +201,14 @@ declare global {
shop: GameShop,
downloadOptionTitle: string | null
) => Promise<void>;
toggleArtifactFreeze: (
gameArtifactId: string,
freeze: boolean
) => Promise<void>;
renameGameArtifact: (
gameArtifactId: string,
label: string
) => Promise<void>;
downloadGameArtifact: (
objectId: string,
shop: GameShop,

View File

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

View File

@@ -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<ModalProps, "children" | "title"> {}
@@ -25,9 +31,11 @@ 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");
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) {
</span>
);
}
if (restoringBackup) {
return (
<span className="cloud-sync-modal__backup-state-label">
@@ -113,7 +134,6 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
</span>
);
}
if (loadingPreview) {
return (
<span className="cloud-sync-modal__backup-state-label">
@@ -122,19 +142,15 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
</span>
);
}
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 (
<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">
<AnimatePresence>
{orderBy(artifacts, [(a) => !a.isFrozen], ["asc"]).map(
(artifact) => (
<motion.li
key={artifact.id}
className="cloud-sync-modal__artifact"
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<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),
})}
<PencilIcon />
</button>
<small>
{formatBytes(artifact.artifactLengthInBytes)}
</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">
<h3>
{artifact.label ??
t("backup_from", {
date: formatDate(artifact.createdAt),
})}
</h3>
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
</div>
<span className="cloud-sync-modal__artifact-meta">
<DeviceDesktopIcon size={14} />
{artifact.hostname}
</span>
<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">
<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">
<ClockIcon size={14} />
{formatDateTime(artifact.createdAt)}
</span>
</div>
<div className="cloud-sync-modal__artifact-actions">
<Button
type="button"
onClick={() => handleBackupInstallClick(artifact.id)}
disabled={disableActions}
>
{restoringBackup ? (
<SyncIcon className="cloud-sync-modal__sync-icon" />
) : (
<HistoryIcon />
)}
{t("install_backup")}
</Button>
<Button
type="button"
onClick={() => handleDeleteArtifactClick(artifact.id)}
theme="danger"
disabled={disableActions}
>
<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>
</motion.li>
)
)}
</AnimatePresence>
</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,
}: Readonly<CloudSyncRenameArtifactModalProps>) {
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<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>
);
}

View File

@@ -282,6 +282,7 @@ export interface GameArtifact {
hostname: string;
downloadCount: number;
label?: string;
isFrozen: boolean;
}
export interface ComparedAchievements {

View File

@@ -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==