fix: fixing carousel

This commit is contained in:
Chubby Granny Chaser
2025-09-28 15:14:25 +01:00
parent cb25a55118
commit 83e7f676bf
32 changed files with 701 additions and 480 deletions

View File

@@ -3,5 +3,3 @@ MAIN_VITE_AUTH_URL=
MAIN_VITE_WS_URL=
RENDERER_VITE_REAL_DEBRID_REFERRAL_ID=
RENDERER_VITE_TORBOX_REFERRAL_CODE=
VITE_GG_DEALS_API_URL=https://api.gg.deals/v1/prices/by-steam-app-id
VITE_GG_DEALS_API_KEY=

View File

@@ -54,6 +54,8 @@
"diskusage": "^1.2.0",
"electron-log": "^5.2.4",
"electron-updater": "^6.6.2",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"file-type": "^20.5.0",
"framer-motion": "^12.15.0",
"i18next": "^23.11.2",
@@ -63,6 +65,8 @@
"lodash-es": "^4.17.21",
"parse-torrent": "^11.0.18",
"rc-virtual-list": "^3.18.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-hook-form": "^7.53.0",
"react-i18next": "^14.1.0",
"react-loading-skeleton": "^3.4.0",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Successfully signed in"
},
"home": {
"featured": "Featured",
"surprise_me": "Surprise me",
"no_results": "No results found",
"start_typing": "Starting typing to search...",
@@ -241,7 +240,6 @@
"keyshop_price": "Keyshop price",
"historical_retail": "Historical retail",
"historical_keyshop": "Historical keyshop",
"supported_languages": "Supported languages",
"language": "Language",
"caption": "Caption",
"audio": "Audio"
@@ -290,7 +288,6 @@
"change": "Update",
"notifications": "Notifications",
"enable_download_notifications": "When a download is complete",
"gg_deals_api_key_description": "gg deals api key. Used to show the lowest price. (https://gg.deals/api/)",
"enable_repack_list_notifications": "When a new repack is added",
"real_debrid_api_token_label": "Real-Debrid API token",
"quit_app_instead_hiding": "Don't hide Hydra when closing",

View File

@@ -4,7 +4,6 @@
"successfully_signed_in": "Autenticado com sucesso"
},
"home": {
"featured": "Destaques",
"hot": "Populares",
"weekly": "📅 Mais baixados da semana",
"achievements": "🏆 Pra platinar",
@@ -218,7 +217,6 @@
"keyshop_price": "Preço em keyshops",
"historical_retail": "Preço histórico de lojas oficiais",
"historical_keyshop": "Preço histórico em keyshops",
"supported_languages": "Idiomas suportados",
"language": "Idioma",
"caption": "Legenda",
"audio": "Áudio"
@@ -267,7 +265,6 @@
"change": "Explorar...",
"notifications": "Notificações",
"enable_download_notifications": "Quando um download for concluído",
"gg_deals_api_key_description": "gg deals api key. Usado para mostrar o menor preço. (https://gg.deals/api/)",
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada",
"real_debrid_api_token_label": "Token de API do Real-Debrid",
"quit_app_instead_hiding": "Encerrar o Hydra em vez de apenas minimizá-lo ao fechar",

View File

@@ -10,16 +10,16 @@
}
::-webkit-scrollbar-track {
background-color: rgba(255, 255, 255, 0.03);
background-color: rgba(0, 0, 0, 0.2);
}
::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.08);
background-color: rgba(255, 255, 255, 0.15);
border-radius: 24px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.16);
background-color: rgba(255, 255, 255, 0.25);
}
html,

View File

@@ -4,9 +4,14 @@
color: globals.$muted-color;
font-size: 10px;
padding: calc(globals.$spacing-unit / 2) globals.$spacing-unit;
border: solid 1px globals.$muted-color;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
display: flex;
gap: 4px;
align-items: center;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all ease 0.2s;
}

View File

@@ -2,16 +2,36 @@
.hero {
width: 100%;
height: 280px;
min-height: 280px;
max-height: 280px;
border-radius: 4px;
height: 180px;
min-height: 150px;
border-radius: 0;
color: #dadbe1;
overflow: hidden;
box-shadow: 0px 0px 15px 0px #000000;
cursor: pointer;
border: solid 1px globals.$border-color;
z-index: 1;
flex-shrink: 0;
@media (min-width: 480px) {
height: 220px;
min-height: 200px;
}
@media (min-width: 768px) {
height: 300px;
min-height: 300px;
}
@media (min-width: 1024px) and (min-height: 800px) {
height: 400px;
min-height: 400px;
}
@media (min-width: 1024px) and (max-height: 799px) {
height: 300px;
min-height: 250px;
}
&__media {
object-fit: cover;
@@ -47,10 +67,42 @@
&__content {
width: 100%;
height: 100%;
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3);
gap: calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2);
gap: calc(globals.$spacing-unit);
display: flex;
flex-direction: column;
justify-content: flex-end;
@media (min-width: 768px) {
padding: calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 3);
gap: calc(globals.$spacing-unit * 1.5);
}
@media (min-width: 1024px) {
padding: calc(globals.$spacing-unit * 4) calc(globals.$spacing-unit * 3);
gap: calc(globals.$spacing-unit * 2);
}
}
&__logo {
max-width: 100%;
height: auto;
width: 120px;
@media (min-width: 480px) {
width: 150px;
}
@media (min-width: 768px) {
width: 200px;
}
@media (min-width: 1024px) and (min-height: 800px) {
width: 250px;
}
@media (min-width: 1024px) and (max-height: 799px) {
width: 200px;
}
}
}

View File

@@ -53,6 +53,7 @@ export function Hero() {
width="250px"
alt={game.description ?? ""}
loading="eager"
className="hero__logo"
/>
<p className="hero__description">{game.description}</p>
</div>

View File

@@ -38,14 +38,12 @@ export const gameDetailsContext = createContext<GameDetailsContext>({
isGameRunning: false,
isLoading: false,
objectId: undefined,
gameColor: "",
showRepacksModal: false,
showGameOptionsModal: false,
stats: null,
achievements: null,
hasNSFWContentBlocked: false,
lastDownloadedOption: null,
setGameColor: () => {},
selectGameExecutable: async () => null,
updateGame: async () => {},
setShowGameOptionsModal: () => {},
@@ -82,7 +80,6 @@ export function GameDetailsContextProvider({
const [stats, setStats] = useState<GameStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [gameColor, setGameColor] = useState("");
const [isGameRunning, setIsGameRunning] = useState(false);
const [showRepacksModal, setShowRepacksModal] = useState(false);
const [showGameOptionsModal, setShowGameOptionsModal] = useState(false);
@@ -286,7 +283,6 @@ export function GameDetailsContextProvider({
isGameRunning,
isLoading,
objectId,
gameColor,
showGameOptionsModal,
showRepacksModal,
stats,
@@ -294,7 +290,6 @@ export function GameDetailsContextProvider({
hasNSFWContentBlocked,
lastDownloadedOption,
setHasNSFWContentBlocked,
setGameColor,
selectGameExecutable,
updateGame,
setShowRepacksModal,

View File

@@ -16,14 +16,12 @@ export interface GameDetailsContext {
isGameRunning: boolean;
isLoading: boolean;
objectId: string | undefined;
gameColor: string;
showRepacksModal: boolean;
showGameOptionsModal: boolean;
stats: GameStats | null;
achievements: UserAchievement[] | null;
hasNSFWContentBlocked: boolean;
lastDownloadedOption: GameRepack | null;
setGameColor: React.Dispatch<React.SetStateAction<string>>;
selectGameExecutable: () => Promise<string | null>;
updateGame: () => Promise<void>;
setShowRepacksModal: React.Dispatch<React.SetStateAction<boolean>>;

View File

@@ -119,15 +119,8 @@ export function AchievementsContent({
const containerRef = useRef<HTMLDivElement | null>(null);
const [isHeaderStuck, setIsHeaderStuck] = useState(false);
const {
gameTitle,
objectId,
shop,
shopDetails,
achievements,
gameColor,
setGameColor,
} = useContext(gameDetailsContext);
const { gameTitle, objectId, shop, shopDetails, achievements } =
useContext(gameDetailsContext);
const dispatch = useAppDispatch();
@@ -136,22 +129,6 @@ export function AchievementsContent({
dispatch(setHeaderTitle(gameTitle));
}, [dispatch, gameTitle]);
const handleHeroLoad = async () => {
const output = await average(
shopDetails?.assets?.libraryHeroImageUrl ?? "",
{
amount: 1,
format: "hex",
}
);
const backgroundColor = output
? (new Color(output).darken(0.7).toString() as string)
: "";
setGameColor(backgroundColor);
};
const onScroll: React.UIEventHandler<HTMLElement> = (event) => {
const heroHeight = heroRef.current?.clientHeight ?? 150;
@@ -191,7 +168,6 @@ export function AchievementsContent({
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
className="achievements-content__achievements-list__image"
alt={gameTitle}
onLoad={handleHeroLoad}
/>
<section
@@ -199,12 +175,7 @@ export function AchievementsContent({
onScroll={onScroll}
className="achievements-content__achievements-list__section"
>
<div
className="achievements-content__achievements-list__section__container"
style={{
background: `linear-gradient(0deg, #151515 0%, ${gameColor} 100%)`,
}}
>
<div className="achievements-content__achievements-list__section__container">
<div
ref={heroRef}
className="achievements-content__achievements-list__section__container__hero"

View File

@@ -9,29 +9,39 @@
align-items: center;
}
&__media {
&__viewport {
width: 100%;
height: 100%;
display: block;
flex-shrink: 0;
flex-grow: 0;
transition: translate 0.3s ease-in-out;
border-radius: 4px;
align-self: center;
}
&__animation-container {
width: 100%;
height: 100%;
display: flex;
position: relative;
overflow: hidden;
border-radius: 8px;
@media (min-width: 1280px) {
width: 60%;
}
}
&__container-inner {
display: flex;
height: 100%;
}
&__slide {
flex: 0 0 100%;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
}
&__media {
width: 100%;
height: 100%;
display: block;
border-radius: 4px;
object-fit: cover;
}
&__preview {
width: 100%;
padding: globals.$spacing-unit 0;
@@ -68,6 +78,7 @@
border-radius: 4px;
border: solid 1px globals.$border-color;
overflow: hidden;
position: relative;
&:hover {
opacity: 0.8;
@@ -83,49 +94,73 @@
display: flex;
}
&__play-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.4);
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
color: white;
transition: all 0.2s ease;
pointer-events: none;
.gallery-slider__preview-button:hover & {
background-color: rgba(0, 0, 0, 0.6);
transform: translate(-50%, -50%) scale(1.1);
}
}
&__button {
position: absolute;
align-self: center;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
background-color: rgba(0, 0, 0, 0.4);
transition: all 0.2s ease-in-out;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 50%;
color: globals.$muted-color;
width: 48px;
height: 48px;
border: none;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
&:hover {
background-color: rgba(0, 0, 0, 0.6);
}
&:active {
transform: scale(0.95);
transform: translateY(-50%) scale(0.95);
}
&--left {
left: 0;
margin-left: globals.$spacing-unit;
transform: translateX(calc(-1 * (48px + globals.$spacing-unit)));
&.gallery-slider__button--visible {
transform: translateX(0);
left: globals.$spacing-unit;
transform: translateY(-50%) translateX(-100px);
opacity: 0;
.gallery-slider__viewport:hover & {
transform: translateY(-50%) translateX(0);
opacity: 1;
}
}
&--right {
right: 0;
margin-right: globals.$spacing-unit;
transform: translateX(calc(48px + globals.$spacing-unit));
&.gallery-slider__button--visible {
transform: translateX(0);
right: globals.$spacing-unit;
transform: translateY(-50%) translateX(100px);
opacity: 0;
.gallery-slider__viewport:hover & {
transform: translateY(-50%) translateX(0);
opacity: 1;
}
}
&--hidden {
opacity: 0;
}
}
}

View File

@@ -1,90 +1,144 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useContext, useCallback, useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRightIcon, ChevronLeftIcon } from "@primer/octicons-react";
import {
ChevronRightIcon,
ChevronLeftIcon,
PlayIcon,
} from "@primer/octicons-react";
import useEmblaCarousel from "embla-carousel-react";
import { gameDetailsContext } from "@renderer/context";
import "./gallery-slider.scss";
export function GallerySlider() {
const { shopDetails } = useContext(gameDetailsContext);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const mediaContainerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation("game_details");
const hasScreenshots = shopDetails && shopDetails.screenshots?.length;
const hasMovies = shopDetails && shopDetails.movies?.length;
const mediaCount = useMemo(() => {
if (!shopDetails) return 0;
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false });
const [selectedIndex, setSelectedIndex] = useState(0);
if (shopDetails.screenshots && shopDetails.movies) {
return shopDetails.screenshots.length + shopDetails.movies.length;
} else if (shopDetails.movies) {
return shopDetails.movies.length;
} else if (shopDetails.screenshots) {
return shopDetails.screenshots.length;
}
const scrollPrev = useCallback(() => {
if (emblaApi) emblaApi.scrollPrev();
}, [emblaApi]);
return 0;
}, [shopDetails]);
const scrollNext = useCallback(() => {
if (emblaApi) emblaApi.scrollNext();
}, [emblaApi]);
const [mediaIndex, setMediaIndex] = useState(0);
const [showArrows, setShowArrows] = useState(false);
const scrollTo = useCallback(
(index: number) => {
if (emblaApi) emblaApi.scrollTo(index);
},
[emblaApi]
);
const showNextImage = () => {
setMediaIndex((index: number) => {
if (index === mediaCount - 1) return 0;
const scrollToPreview = useCallback(
(index: number, event: React.MouseEvent<HTMLButtonElement>) => {
scrollTo(index);
return index + 1;
});
};
const button = event.currentTarget;
const previewContainer = button.parentElement;
const showPrevImage = () => {
setMediaIndex((index: number) => {
if (index === 0) return mediaCount - 1;
if (previewContainer) {
const containerRect = previewContainer.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
return index - 1;
});
};
const isOffScreenLeft = buttonRect.left < containerRect.left;
const isOffScreenRight = buttonRect.right > containerRect.right;
useEffect(() => {
setMediaIndex(0);
}, [shopDetails]);
useEffect(() => {
if (hasMovies && mediaContainerRef.current) {
mediaContainerRef.current.childNodes.forEach((node, index) => {
if (node instanceof HTMLVideoElement) {
if (index !== mediaIndex) {
node.pause();
}
if (isOffScreenLeft || isOffScreenRight) {
button.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}
}
},
[scrollTo]
);
useEffect(() => {
if (!emblaApi) return;
let isInitialLoad = true;
const onSelect = () => {
const newIndex = emblaApi.selectedScrollSnap();
setSelectedIndex(newIndex);
if (!isInitialLoad) {
const videos = document.querySelectorAll(".gallery-slider__media");
videos.forEach((video) => {
if (video instanceof HTMLVideoElement) {
video.pause();
}
});
}
isInitialLoad = false;
};
emblaApi.on("select", onSelect);
onSelect();
return () => {
emblaApi.off("select", onSelect);
};
}, [emblaApi]);
const mediaItems = useMemo(() => {
const items: Array<{
id: string;
type: "video" | "image";
src?: string;
poster?: string;
videoSrc?: string;
alt: string;
}> = [];
if (shopDetails?.movies) {
shopDetails.movies.forEach((video, index) => {
items.push({
id: String(video.id),
type: "video",
poster: video.thumbnail,
videoSrc: video.mp4.max.startsWith("http://")
? video.mp4.max.replace("http://", "https://")
: video.mp4.max,
alt: t("video", { number: String(index + 1) }),
});
});
}
}, [hasMovies, mediaContainerRef, mediaIndex]);
useEffect(() => {
if (scrollContainerRef.current) {
const container = scrollContainerRef.current;
const totalWidth = container.scrollWidth - container.clientWidth;
const itemWidth = totalWidth / (mediaCount - 1);
const scrollLeft = mediaIndex * itemWidth;
container.scrollLeft = scrollLeft;
if (shopDetails?.screenshots) {
shopDetails.screenshots.forEach((image, index) => {
items.push({
id: String(image.id),
type: "image",
src: image.path_full,
alt: t("screenshot", { number: String(index + 1) }),
});
});
}
}, [shopDetails, mediaIndex, mediaCount]);
return items;
}, [shopDetails, t]);
const previews = useMemo(() => {
const screenshotPreviews =
shopDetails?.screenshots?.map(({ id, path_thumbnail }) => ({
id,
thumbnail: path_thumbnail,
type: "image" as const,
})) ?? [];
if (shopDetails?.movies) {
const moviePreviews = shopDetails.movies.map(({ id, thumbnail }) => ({
id,
thumbnail,
type: "video" as const,
}));
return [...moviePreviews, ...screenshotPreviews];
@@ -93,96 +147,87 @@ export function GallerySlider() {
return screenshotPreviews;
}, [shopDetails]);
if (!hasScreenshots) {
return null;
}
return (
<>
{hasScreenshots && (
<div className="gallery-slider__container">
<div
onMouseEnter={() => setShowArrows(true)}
onMouseLeave={() => setShowArrows(false)}
className="gallery-slider__animation-container"
ref={mediaContainerRef}
>
{shopDetails.movies &&
shopDetails.movies.map((video) => (
<div className="gallery-slider__container">
<div className="gallery-slider__viewport" ref={emblaRef}>
<div className="gallery-slider__container-inner">
{mediaItems.map((item) => (
<div key={item.id} className="gallery-slider__slide">
{item.type === "video" ? (
<video
key={video.id}
controls
className="gallery-slider__media"
poster={video.thumbnail}
style={{ translate: `${-100 * mediaIndex}%` }}
poster={item.poster}
loop
muted
autoPlay
tabIndex={-1}
>
<source src={video.mp4.max.replace("http", "https")} />
<source src={item.videoSrc} />
</video>
))}
{hasScreenshots &&
shopDetails.screenshots?.map((image, i) => (
) : (
<img
key={image.id}
className="gallery-slider__media"
src={image.path_full}
style={{ translate: `${-100 * mediaIndex}%` }}
alt={t("screenshot", { number: i + 1 })}
src={item.src}
alt={item.alt}
loading="lazy"
/>
))}
<button
onClick={showPrevImage}
type="button"
className={`gallery-slider__button gallery-slider__button--left ${
showArrows
? "gallery-slider__button--visible"
: "gallery-slider__button--hidden"
}`}
aria-label={t("previous_screenshot")}
tabIndex={0}
>
<ChevronLeftIcon size={36} />
</button>
<button
onClick={showNextImage}
type="button"
className={`gallery-slider__button gallery-slider__button--right ${
showArrows
? "gallery-slider__button--visible"
: "gallery-slider__button--hidden"
}`}
aria-label={t("next_screenshot")}
tabIndex={0}
>
<ChevronRightIcon size={36} />
</button>
</div>
<div className="gallery-slider__preview" ref={scrollContainerRef}>
{previews.map((media, i) => (
<button
key={media.id}
type="button"
className={`gallery-slider__preview-button ${
mediaIndex === i
? "gallery-slider__preview-button--active"
: ""
}`}
onClick={() => setMediaIndex(i)}
aria-label={t("open_screenshot", { number: i + 1 })}
>
<img
src={media.thumbnail}
className="gallery-slider__preview-image"
alt={t("screenshot", { number: i + 1 })}
/>
</button>
))}
</div>
)}
</div>
))}
</div>
)}
</>
<button
onClick={scrollPrev}
type="button"
className="gallery-slider__button gallery-slider__button--left"
aria-label={t("previous_screenshot")}
tabIndex={0}
>
<ChevronLeftIcon size={36} />
</button>
<button
onClick={scrollNext}
type="button"
className="gallery-slider__button gallery-slider__button--right"
aria-label={t("next_screenshot")}
tabIndex={0}
>
<ChevronRightIcon size={36} />
</button>
</div>
<div className="gallery-slider__preview">
{previews.map((media, i) => (
<button
key={media.id}
type="button"
className={`gallery-slider__preview-button ${
selectedIndex === i
? "gallery-slider__preview-button--active"
: ""
}`}
onClick={(e) => scrollToPreview(i, e)}
aria-label={t("open_screenshot", { number: String(i + 1) })}
>
<img
src={media.thumbnail}
className="gallery-slider__preview-image"
alt={t("screenshot", { number: String(i + 1) })}
/>
{media.type === "video" && (
<div className="gallery-slider__play-overlay">
<PlayIcon size={20} />
</div>
)}
</button>
))}
</div>
</div>
);
}

View File

@@ -1,6 +1,4 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { average } from "color.js";
import Color from "color";
import { HeroPanel } from "./hero";
import { DescriptionHeader } from "./description-header/description-header";
@@ -21,14 +19,8 @@ export function GameDetailsContent() {
const { t } = useTranslation("game_details");
const {
objectId,
shopDetails,
game,
gameColor,
setGameColor,
hasNSFWContentBlocked,
} = useContext(gameDetailsContext);
const { objectId, shopDetails, game, hasNSFWContentBlocked } =
useContext(gameDetailsContext);
const { showHydraCloudModal } = useSubscription();
@@ -58,22 +50,6 @@ export function GameDetailsContent() {
const [backdropOpacity, setBackdropOpacity] = useState(1);
const handleHeroLoad = async () => {
const output = await average(
shopDetails?.assets?.libraryHeroImageUrl ?? "",
{
amount: 1,
format: "hex",
}
);
const backgroundColor = output
? new Color(output).darken(0.7).toString()
: "";
setGameColor(backgroundColor);
};
useEffect(() => {
setBackdropOpacity(1);
}, [objectId]);
@@ -106,12 +82,10 @@ export function GameDetailsContent() {
src={shopDetails?.assets?.libraryHeroImageUrl ?? ""}
className="game-details__hero-image"
alt={game?.title}
onLoad={handleHeroLoad}
/>
<div
className="game-details__hero-backdrop"
style={{
backgroundColor: gameColor,
flex: 1,
}}
/>

View File

@@ -18,7 +18,6 @@ $hero-height: 300px;
&__wrapper {
display: flex;
flex-direction: column;
overflow: hidden;
width: 100%;
height: 100%;
transition: all ease 0.3s;
@@ -64,8 +63,8 @@ $hero-height: 300px;
&__hero-image {
width: 100%;
height: $hero-height;
min-height: $hero-height;
height: calc($hero-height + 72px);
min-height: calc($hero-height + 72px);
object-fit: cover;
object-position: top;
transition: all ease 0.2s;
@@ -74,8 +73,8 @@ $hero-height: 300px;
@media (min-width: 1250px) {
object-position: center;
height: 350px;
min-height: 350px;
height: calc(350px + 72px);
min-height: calc(350px + 72px);
}
}
@@ -97,7 +96,6 @@ $hero-height: 300px;
height: 100%;
display: flex;
flex-direction: column;
overflow: auto;
z-index: 1;
}
@@ -105,6 +103,7 @@ $hero-height: 300px;
display: flex;
width: 100%;
flex: 1;
min-width: 0;
background: linear-gradient(
0deg,
globals.$background-color 50%,
@@ -115,6 +114,8 @@ $hero-height: 300px;
&__description-content {
width: 100%;
height: 100%;
min-width: 0;
flex: 1;
}
&__description {

View File

@@ -5,18 +5,24 @@
height: 72px;
min-height: 72px;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 3);
background-color: globals.$dark-background-color;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: solid 1px rgba(255, 255, 255, 0.15);
display: flex;
align-items: center;
justify-content: space-between;
transition: all ease 0.2s;
border-bottom: solid 1px globals.$border-color;
position: sticky;
overflow: hidden;
top: 0;
z-index: 2;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
&--stuck {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0px 0px 15px 0px rgba(0, 0, 0, 0.8);
}

View File

@@ -14,7 +14,7 @@ export function HeroPanel() {
const { formatDate } = useDate();
const { game, repacks, gameColor } = useContext(gameDetailsContext);
const { game, repacks } = useContext(gameDetailsContext);
const { lastPacket } = useDownload();
@@ -50,7 +50,7 @@ export function HeroPanel() {
game?.download?.status === "paused";
return (
<div style={{ backgroundColor: gameColor }} className="hero-panel">
<div className="hero-panel">
<div className="hero-panel__content">{getInfo()}</div>
<div className="hero-panel__actions">
<HeroPanelActions />

View File

@@ -2,8 +2,7 @@
.sidebar-section {
&__button {
height: 72px;
padding: calc(globals.$spacing-unit * 2) calc(globals.$spacing-unit * 2);
padding: calc(globals.$spacing-unit * 2.5) calc(globals.$spacing-unit * 2);
display: flex;
align-items: center;
background-color: globals.$background-color;

View File

@@ -0,0 +1,85 @@
@use "../../../scss/globals.scss";
.game-language-section {
background-color: rgba(255, 255, 255, 0.02);
overflow: hidden;
&__header {
display: flex;
background-color: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid globals.$border-color;
}
&__header-item {
display: flex;
align-items: center;
color: globals.$muted-color;
font-size: globals.$small-font-size;
font-weight: 600;
flex: 1;
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2.5);
&--center {
justify-content: flex-start;
flex: 0 0 60px;
}
}
&__content {
display: flex;
flex-direction: column;
}
&__row {
display: flex;
transition: background-color 0.2s ease;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
&:hover {
background-color: rgba(255, 255, 255, 0.03);
}
&:last-child {
border-bottom: none;
}
}
&__cell {
padding: calc(globals.$spacing-unit * 1.5) calc(globals.$spacing-unit * 2.5);
font-size: globals.$body-font-size;
color: globals.$body-color;
display: flex;
align-items: center;
flex: 1;
&--language {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&--center {
justify-content: flex-start;
flex: 0 0 60px;
}
}
&__check {
color: globals.$body-color;
opacity: 0.8;
}
&__cross {
color: globals.$body-color;
opacity: 0.8;
}
@media (max-width: 320px) {
&__header,
&__cell {
padding: calc(globals.$spacing-unit * 1) calc(globals.$spacing-unit * 0.5);
font-size: calc(globals.$small-font-size * 0.9);
}
}
}

View File

@@ -1,65 +1,71 @@
import { useContext } from "react";
import { useContext, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { CheckIcon, XIcon } from "@primer/octicons-react";
import { gameDetailsContext } from "@renderer/context/game-details/game-details.context";
import { SidebarSection } from "../sidebar-section/sidebar-section";
import "./game-language-section.scss";
export function GameLanguageSection() {
const { t } = useTranslation("game_details");
const { shopDetails, objectId } = useContext(gameDetailsContext);
const { shopDetails } = useContext(gameDetailsContext);
const getLanguages = () => {
let languages = shopDetails?.supported_languages;
if (!languages) return [];
languages = languages?.split("<br>")[0];
const arrayIdiomas = languages?.split(",");
const listLanguages: {
language: string;
caption: string;
audio: string;
}[] = [];
arrayIdiomas?.forEach((lang) => {
const objectLanguage = {
language: lang.replace("<strong>*</strong>", ""),
caption: "✔",
audio: lang.includes("*") ? "✔" : "",
};
listLanguages.push(objectLanguage);
});
return listLanguages;
};
const languages = useMemo(() => {
const supportedLanguages = shopDetails?.supported_languages;
if (!supportedLanguages) return [];
const languagesString = supportedLanguages.split("<br>")[0];
const languageArray = languagesString?.split(",") || [];
return languageArray.map((lang) => ({
language: lang.replace("<strong>*</strong>", "").trim(),
hasAudio: lang.includes("*"),
}));
}, [shopDetails?.supported_languages]);
if (languages.length === 0) {
return null;
}
return (
<SidebarSection title={t("language")}>
<div>
<h4>{t("supported_languages")}</h4>
<table className="table-languages">
<thead>
<tr>
<th>{t("language")}</th>
<th>{t("caption")}</th>
<th>{t("audio")}</th>
</tr>
</thead>
<tbody>
{getLanguages().map((lang) => (
<tr key={lang.language}>
<td>{lang.language}</td>
<td>{lang.caption}</td>
<td>{lang.audio}</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<a
target="_blank"
rel="noopener noreferrer"
className="list__item"
href={`https://store.steampowered.com/app/${objectId}`}
>
Link Steam
</a>
<div className="game-language-section">
<div className="game-language-section__header">
<div className="game-language-section__header-item">
<span>{t("language")}</span>
</div>
<div className="game-language-section__header-item game-language-section__header-item--center">
<span>{t("caption")}</span>
</div>
<div className="game-language-section__header-item game-language-section__header-item--center">
<span>{t("audio")}</span>
</div>
</div>
<div className="game-language-section__content">
{languages.map((lang) => (
<div key={lang.language} className="game-language-section__row">
<div
className="game-language-section__cell game-language-section__cell--language"
title={lang.language}
>
{lang.language}
</div>
<div className="game-language-section__cell game-language-section__cell--center">
<CheckIcon size={14} className="game-language-section__check" />
</div>
<div className="game-language-section__cell game-language-section__cell--center">
{lang.hasAudio ? (
<CheckIcon
size={14}
className="game-language-section__check"
/>
) : (
<XIcon size={14} className="game-language-section__cross" />
)}
</div>
</div>
))}
</div>
</div>
</SidebarSection>
);

View File

@@ -1,86 +0,0 @@
import { useCallback, useContext, useEffect, useState } from "react";
import { SidebarSection } from "../sidebar-section/sidebar-section";
import { useTranslation } from "react-i18next";
import { gameDetailsContext } from "@renderer/context/game-details/game-details.context";
import { useAppSelector } from "@renderer/hooks";
export function GamePricesSection() {
const userPreferences = useAppSelector(
(state) => state.userPreferences.value
);
const { t } = useTranslation("game_details");
const [priceData, setPriceData] = useState<any>(null);
const [isLoadingPrices, setIsLoadingPrices] = useState(false);
const { objectId } = useContext(gameDetailsContext);
const fetchGamePrices = useCallback(async (steamAppId: string) => {
setIsLoadingPrices(true);
try {
const apiKey =
userPreferences?.ggDealsApiKey || import.meta.env.VITE_GG_DEALS_API_KEY;
if (!apiKey) {
setPriceData(null);
setIsLoadingPrices(false);
return;
}
const url = `${import.meta.env.VITE_GG_DEALS_API_URL}/?ids=${steamAppId}&key=${apiKey}&region=br`;
const response = await fetch(url);
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
setPriceData(data.data?.[steamAppId] ?? null);
} catch (error) {
setPriceData(null);
} finally {
setIsLoadingPrices(false);
}
}, []);
useEffect(() => {
if (objectId) {
fetchGamePrices(objectId.toString());
}
}, [objectId, fetchGamePrices]);
return (
<SidebarSection title={t("prices")}>
{isLoadingPrices ? (
<div>{t("loading")}</div>
) : priceData ? (
<div>
<ul className="">
<li>
<b>{t("retail_price")}</b>: {t("currency_symbol")}
{priceData.prices.currentRetail}
</li>
<li>
<b>{t("keyshop_price")}</b>: {t("currency_symbol")}
{priceData.prices.currentKeyshops}
</li>
<li>
<b>{t("historical_retail")}</b>: {t("currency_symbol")}
{priceData.prices.historicalRetail}
</li>
<li>
<b>{t("historical_keyshop")}</b>: {t("currency_symbol")}
{priceData.prices.historicalKeyshops}
</li>
<li>
<a
href={priceData.url}
target="_blank"
rel="noopener noreferrer"
className="list__item"
>
{t("view_all_prices")}
</a>
</li>
</ul>
</div>
) : (
<div>{t("no_prices_found")}</div>
)}
</SidebarSection>
);
}

View File

@@ -3,17 +3,30 @@
.content-sidebar {
border-left: solid 1px globals.$border-color;
background-color: globals.$dark-background-color;
width: 100%;
height: 100%;
flex-shrink: 0;
width: 280px;
@media (min-width: 1024px) {
max-width: 300px;
width: 100%;
width: 320px;
}
@media (min-width: 1280px) {
width: 100%;
max-width: 400px;
width: 380px;
}
@media (min-width: 1440px) {
width: 420px;
}
@media (max-width: 768px) {
width: 35%;
min-width: 220px;
}
@media (max-width: 480px) {
width: 40%;
min-width: 200px;
}
}
@@ -194,25 +207,3 @@
.achievements-placeholder__blur {
filter: blur(4px);
}
.table-languages {
width: 100%;
border-collapse: collapse;
text-align: left;
th,
td {
padding: globals.$spacing-unit;
border-bottom: solid 1px globals.$border-color;
}
th {
font-size: globals.$small-font-size;
color: globals.$muted-color;
font-weight: normal;
}
td {
font-size: globals.$body-font-size;
}
}

View File

@@ -21,7 +21,6 @@ import { SidebarSection } from "../sidebar-section/sidebar-section";
import { buildGameAchievementPath } from "@renderer/helpers";
import { useSubscription } from "@renderer/hooks/use-subscription";
import "./sidebar.scss";
import { GamePricesSection } from "./game-prices-section";
import { GameLanguageSection } from "./game-language-section";
const achievementsPlaceholder: UserAchievement[] = [
@@ -117,9 +116,6 @@ export function Sidebar() {
return (
<aside className="content-sidebar">
<GameLanguageSection />
<GamePricesSection />
{userDetails === null && (
<SidebarSection title={t("achievements")}>
<div className="achievements-placeholder">
@@ -268,6 +264,8 @@ export function Sidebar() {
}}
/>
</SidebarSection>
<GameLanguageSection />
</aside>
);
}

View File

@@ -6,8 +6,8 @@
height: 100%;
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 3);
padding: calc(globals.$spacing-unit * 3);
gap: calc(globals.$spacing-unit * 2);
padding: 0;
flex: 1;
overflow-y: auto;
}
@@ -17,6 +17,7 @@
gap: globals.$spacing-unit;
justify-content: space-between;
align-items: center;
padding: calc(globals.$spacing-unit * 3);
}
&__buttons-list {
@@ -27,25 +28,6 @@
gap: globals.$spacing-unit;
}
&__cards {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: calc(globals.$spacing-unit * 2);
transition: all ease 0.2s;
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1250px) {
grid-template-columns: repeat(3, 1fr);
}
@media (min-width: 1600px) {
grid-template-columns: repeat(4, 1fr);
}
}
&__card-skeleton {
width: 100%;
height: 180px;
@@ -99,5 +81,26 @@
&__title {
display: flex;
gap: globals.$spacing-unit;
padding: 0 calc(globals.$spacing-unit * 3);
}
&__cards {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: calc(globals.$spacing-unit * 2);
transition: all ease 0.2s;
padding: 0 calc(globals.$spacing-unit * 3) calc(globals.$spacing-unit * 3);
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 1250px) {
grid-template-columns: repeat(3, 1fr);
}
@media (min-width: 1600px) {
grid-template-columns: repeat(4, 1fr);
}
}
}

View File

@@ -97,8 +97,6 @@ export default function Home() {
return (
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
<section className="home__content">
<h2>{t("featured")}</h2>
<Hero />
<section className="home__header">

View File

@@ -31,10 +31,14 @@ export function FriendsBox() {
return (
<div>
<div className="friends-box__section-header">
<h2>{t("friends")}</h2>
{userStats && (
<span>{numberFormatter.format(userStats.friendsCount)}</span>
)}
<div className="profile-content__section-title-group">
<h2>{t("friends")}</h2>
{userStats && (
<span className="profile-content__section-badge">
{numberFormatter.format(userStats.friendsCount)}
</span>
)}
</div>
</div>
<div className="friends-box__box">

View File

@@ -151,5 +151,29 @@
@container #{globals.$app-container} (min-width: 3000px) {
grid-template-columns: repeat(12, 1fr);
}
&--drag-over {
background: rgba(255, 255, 255, 0.05);
border: 2px dashed rgba(255, 255, 255, 0.3);
position: relative;
transition: all ease 0.2s;
&::before {
content: "Drop here to " attr(data-action);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: globals.$muted-color;
font-size: 14px;
font-weight: 500;
z-index: 10;
pointer-events: none;
background: rgba(0, 0, 0, 0.8);
padding: 8px 16px;
border-radius: 4px;
backdrop-filter: blur(10px);
}
}
}
}

View File

@@ -7,10 +7,26 @@
position: relative;
display: flex;
transition: all ease 0.2s;
cursor: grab;
&:hover {
transform: scale(1.05);
}
&:active {
cursor: grabbing;
transform: scale(1.02);
}
&[draggable="true"] {
cursor: grab;
&:active {
cursor: grabbing;
opacity: 0.8;
transform: scale(1.02) rotate(2deg);
}
}
}
&__cover {
@@ -75,29 +91,47 @@
}
&__favorite-icon {
color: white;
background-color: rgba(0, 0, 0, 0.7);
color: rgba(255, 255, 255, 0.8);
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: solid 1px rgba(255, 255, 255, 0.15);
border-radius: 50%;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all ease 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.5);
border-color: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
}
&__pin-button {
color: white;
background-color: rgba(0, 0, 0, 0.7);
border: none;
color: rgba(255, 255, 255, 0.8);
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: solid 1px rgba(255, 255, 255, 0.15);
border-radius: 50%;
padding: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all ease 0.2s;
&:hover {
background-color: rgba(0, 0, 0, 0.9);
background: rgba(0, 0, 0, 0.5);
border-color: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
&:disabled {
@@ -107,14 +141,25 @@
}
&__playtime {
background-color: globals.$background-color;
color: globals.$muted-color;
border: solid 1px globals.$border-color;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: rgba(255, 255, 255, 0.8);
border: solid 1px rgba(255, 255, 255, 0.15);
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all ease 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.5);
border-color: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
}
&__manual-playtime {
color: globals.$warning-color;

View File

@@ -35,7 +35,6 @@ export function SettingsGeneral() {
const [form, setForm] = useState({
downloadsPath: "",
ggDealsApiKey: "",
downloadNotificationsEnabled: false,
repackUpdatesNotificationsEnabled: false,
friendRequestNotificationsEnabled: false,
@@ -101,7 +100,6 @@ export function SettingsGeneral() {
setForm((prev) => ({
...prev,
downloadsPath: userPreferences.downloadsPath ?? defaultDownloadsPath,
ggDealsApiKey: userPreferences.ggDealsApiKey ?? "",
downloadNotificationsEnabled:
userPreferences.downloadNotificationsEnabled ?? false,
repackUpdatesNotificationsEnabled:
@@ -208,12 +206,6 @@ export function SettingsGeneral() {
}
/>
<TextField
label={t("gg_deals_api_key_description")}
value={form.ggDealsApiKey}
onChange={(e) => handleChange({ ggDealsApiKey: e.target.value })}
/>
<SelectField
label={t("language")}
value={form.language}

View File

@@ -1,10 +1,10 @@
$background-color: #1c1c1c;
$dark-background-color: #151515;
$background-color: #121212;
$dark-background-color: #0d0d0d;
$muted-color: #c0c1c7;
$body-color: #8e919b;
$muted-color: #f0f1f7;
$body-color: #d0d1d7;
$border-color: rgba(255, 255, 255, 0.15);
$border-color: rgba(255, 255, 255, 0.08);
$success-color: #1c9749;
$danger-color: #801d1e;
$error-color: #e11d48;

View File

@@ -84,7 +84,6 @@ export type AchievementCustomNotificationPosition =
export interface UserPreferences {
downloadsPath?: string | null;
ggDealsApiKey?: string | null;
language?: string;
realDebridApiToken?: string | null;
torBoxApiToken?: string | null;

View File

@@ -912,6 +912,11 @@
dependencies:
regenerator-runtime "^0.14.0"
"@babel/runtime@^7.9.2":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
"@babel/template@^7.22.15", "@babel/template@^7.24.0":
version "7.24.0"
resolved "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz"
@@ -2043,6 +2048,21 @@
resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438"
integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==
"@react-dnd/asap@^5.0.1":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488"
integrity sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==
"@react-dnd/invariant@^4.0.1":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/invariant/-/invariant-4.0.2.tgz#b92edffca10a26466643349fac7cdfb8799769df"
integrity sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==
"@react-dnd/shallowequal@^4.0.1":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz#d1b4befa423f692fa4abf1c79209702e7d8ae4b4"
integrity sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==
"@reduxjs/toolkit@^2.2.3":
version "2.2.5"
resolved "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.5.tgz"
@@ -4451,6 +4471,15 @@ dmg-license@^1.0.11:
smart-buffer "^4.0.2"
verror "^1.10.0"
dnd-core@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-16.0.1.tgz#a1c213ed08961f6bd1959a28bb76f1a868360d19"
integrity sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==
dependencies:
"@react-dnd/asap" "^5.0.1"
"@react-dnd/invariant" "^4.0.1"
redux "^4.2.0"
doctrine@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
@@ -4609,6 +4638,29 @@ electron@^32.3.3:
"@types/node" "^20.9.0"
extract-zip "^2.0.1"
embla-carousel-autoplay@^8.6.0:
version "8.6.0"
resolved "https://registry.yarnpkg.com/embla-carousel-autoplay/-/embla-carousel-autoplay-8.6.0.tgz#bc86c97de00d52ec34b05058736ef50af6e0d0e4"
integrity sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==
embla-carousel-react@^8.6.0:
version "8.6.0"
resolved "https://registry.yarnpkg.com/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz#b737042a32761c38d6614593653b3ac619477bd1"
integrity sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==
dependencies:
embla-carousel "8.6.0"
embla-carousel-reactive-utils "8.6.0"
embla-carousel-reactive-utils@8.6.0:
version "8.6.0"
resolved "https://registry.yarnpkg.com/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz#607f1d8ab9921c906a555c206251b2c6db687223"
integrity sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==
embla-carousel@8.6.0:
version "8.6.0"
resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-8.6.0.tgz#abcedff2bff36992ea8ac27cd30080ca5b6a3f58"
integrity sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
@@ -5735,6 +5787,13 @@ hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2:
dependencies:
function-bind "^1.1.2"
hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
dependencies:
react-is "^16.7.0"
hosted-git-info@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz#827b82867e9ff1c8d0c4d9d53880397d2c86d224"
@@ -7506,6 +7565,24 @@ rc-virtual-list@^3.18.3:
rc-resize-observer "^1.0.0"
rc-util "^5.36.0"
react-dnd-html5-backend@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz#87faef15845d512a23b3c08d29ecfd34871688b6"
integrity sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==
dependencies:
dnd-core "^16.0.1"
react-dnd@^16.0.1:
version "16.0.1"
resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-16.0.1.tgz#2442a3ec67892c60d40a1559eef45498ba26fa37"
integrity sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==
dependencies:
"@react-dnd/invariant" "^4.0.1"
"@react-dnd/shallowequal" "^4.0.1"
dnd-core "^16.0.1"
fast-deep-equal "^3.1.3"
hoist-non-react-statics "^3.3.2"
react-dom@^18.2.0:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"
@@ -7527,7 +7604,7 @@ react-i18next@^14.1.0:
"@babel/runtime" "^7.23.9"
html-parse-stringify "^3.0.1"
react-is@^16.13.1:
react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -7641,6 +7718,13 @@ redux-thunk@^3.1.0:
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3"
integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==
redux@^4.2.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197"
integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==
dependencies:
"@babel/runtime" "^7.9.2"
redux@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/redux/-/redux-5.0.1.tgz#97fa26881ce5746500125585d5642c77b6e9447b"