diff --git a/README.md b/README.md index 03da92c0..8033a710 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Sekai Viewer +![alt text](public/images/thumbnail.jpg) ![GitHub Repo stars](https://img.shields.io/github/stars/Sekai-World/sekai-viewer?style=social) ![GitHub forks](https://img.shields.io/github/forks/Sekai-World/sekai-viewer?style=social) @@ -11,6 +12,12 @@ This project was bootstrapped with [Create React App](https://github.com/faceboo ## Available Scripts +To get started, use + +### `pnpm i` + +to install packages + In the project directory, you can run: ### `npm start` diff --git a/src/pages/App.tsx b/src/pages/App.tsx index ba7d9f49..39a3e92e 100644 --- a/src/pages/App.tsx +++ b/src/pages/App.tsx @@ -103,6 +103,9 @@ const HomeView = lazy(() => import("./Home")); const MusicList = lazy(() => import("./music/MusicList")); const GachaList = lazy(() => import("./gacha/GachaList")); const EventList = lazy(() => import("./event/EventList")); +const FutureGachaList = lazy(() => import("./future/FutureGachaList")); +const FutureEventList = lazy(() => import("./future/FutureEventList")); +const FutureEventDetail = lazy(() => import("./future/FutureEventDetail")); const GachaDetail = lazy(() => import("./gacha/GachaDetail")); const CardDetail = lazy(() => import("./card/CardDetail")); const MusicDetail = lazy(() => import("./music/MusicDetail")); @@ -391,7 +394,9 @@ const DrawerContent: React.FC<{ onFoldButtonClick?: React.MouseEventHandler; }> = ({ open, onFoldButtonClick }) => { const { t } = useTranslation(); - + const { + settings: { isShowSpoiler }, + } = useRootStore(); const leftBtns: IListItemLinkProps[][] = React.useMemo( () => [ [ @@ -441,6 +446,24 @@ const DrawerContent: React.FC<{ text: t("common:gacha"), to: "/gacha", }, + { + children: [ + { + disabled: false, + text: t("common:futureevent"), + to: "/futureevent", + }, + { + disabled: false, + text: t("common:futuregacha"), + to: "/futuregacha", + }, + ], + disabled: !isShowSpoiler, + icon: , + text: t("common:futures"), + to: "/futureevent", + }, { disabled: false, icon: , @@ -1140,6 +1163,15 @@ const AppInner = observer((props: { theme: Theme }) => { + + + + + + + + + diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 352109da..2b6dded1 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -336,6 +336,19 @@ const Home: React.FC = () => { + + + + + + + {t("common:futuregacha")} + + + { + + + + + + + {t("common:futureevent")} + + + = observer(() => { + const { t } = useTranslation(); + const { eventId } = useParams<{ eventId: string }>(); + const { getTranslated } = useAssetI18n(); + const { + settings: { contentTransMode }, + region, + } = useRootStore(); + const [humanizeDuration] = useDurationI18n(); + const { getEvent } = useStrapi(); + const [events] = useCachedData("events", "jp" as ServerRegion); + const [eventDeckBonuses] = useCachedData("eventDeckBonuses"); + const [gameCharacterUnits] = + useCachedData("gameCharacterUnits"); + const [eventCardsCache] = useCachedData("eventCards"); + const [cards] = useCachedData("cards"); + const [virtualLives] = useCachedData("virtualLives"); + const [cheerfulCarnivalSummaries] = useCachedData( + "cheerfulCarnivalSummaries" + ); + const [cheerfulCarnivalTeams] = useCachedData( + "cheerfulCarnivalTeams" + ); + const [eventMusics] = useCachedData("eventMusics"); + + const [event, setEvent] = useState(); + const [eventCards, setEventCards] = useState([]); + const [boostCards, setBoostCards] = useState< + { card: ICardInfo; minBonus: number; maxBonus: number }[] + >([]); + const [eventDeckBonus, setEventDeckBonus] = useState([]); + const [eventAttrBonus, setEventAttrBonus] = useState(); + const [eventBonusCharas, setEventBonusCharas] = useState( + [] + ); + const [imgTabVal, setImgTabVal] = useState("0"); + // const [intervalId, setIntervalId] = useState(); + // const [nextRefreshTime, setNextRefreshTime] = useState(); + const [remainingTime, setRemainingTime] = useState(""); + const [pastTimePercent, setPastTimePercent] = useState(0); + const [visible, setVisible] = useState(false); + const [activeIdx, setActiveIdx] = useState(0); + const [eventBgm, setEventBgm] = useState(""); + const [linkedVirtualLive, setLinkedVirtualLive] = + useState(); + const [ccTeams, setCcTeams] = useState([]); + const [ccSummary, setCcSummary] = useState(); + const [eventCommentId, setEventCommentId] = useState(0); + const [eventMusic, setEventMusic] = useState(); + const [isCardsDialog, setIsCardsDialog] = useState(false); + + useEffect(() => { + if (event) { + const name = getTranslated(`event_name:${eventId}`, event.name); + document.title = t("title:eventDetail", { + name, + }); + getRemoteAssetURL( + `${event.bgmAssetbundleName.replace("bgm", "bgm_rip")}.mp3`, + setEventBgm + ); + } + }, [event, eventId, contentTransMode, getTranslated, t]); + + useEffect(() => { + if ( + events && + eventDeckBonuses && + eventCardsCache && + cards && + gameCharacterUnits && + virtualLives + ) { + const ev = events.find((elem) => elem.id === Number(eventId)); + setEvent(ev); + const edb = eventDeckBonuses.filter( + (elem) => elem.eventId === Number(eventId) + ); + setEventDeckBonus(edb); + const eab = edb.find((it) => it.gameCharacterUnitId === undefined); + setEventAttrBonus(eab); + const ec = eventCardsCache.filter( + (elem) => elem.eventId === Number(eventId) + ); + setEventCards(ec); + const ebc = edb + .filter((it) => + ev?.eventType !== "world_bloom" + ? it.cardAttr !== undefined && it.gameCharacterUnitId !== undefined + : it.gameCharacterUnitId !== undefined + ) + .map( + (elem) => + gameCharacterUnits.find( + (gcu) => gcu.id === elem.gameCharacterUnitId + )! + ); + setEventBonusCharas(ebc); + const masterRankBonus = { + rarity_1: [0, 0.5, 0.5], + rarity_2: [0, 1, 1], + rarity_3: [0, 5, 5], + rarity_4: [0, 10, 15], + rarity_birthday: [0, 7.5, 10], + } as Record; + const masterRankBonusIndex = + Number(eventId) >= 36 ? (Number(eventId) >= 54 ? 2 : 1) : 0; + setBoostCards(() => { + let result = cards + .filter( + (elem) => + (elem.releaseAt ?? elem.archivePublishedAt ?? 0) <= + ev!.aggregateAt + ) + .map((card) => { + const eventCard = ec.find( + (it) => it.cardId === card.id && it.bonusRate !== undefined + ); + let finalEventBonus = + eventCard === undefined ? 0 : eventCard.bonusRate!; + + finalEventBonus += edb.reduce((v, deckBonus) => { + if ( + deckBonus.cardAttr !== undefined && + deckBonus.cardAttr !== card.attr + ) { + return v; + } + + if (deckBonus.gameCharacterUnitId !== undefined) { + const gameCharacterUnit = gameCharacterUnits.find( + (it) => it.id === deckBonus.gameCharacterUnitId + )!; + if (gameCharacterUnit.gameCharacterId !== card.characterId) { + return v; + } + if ( + gameCharacterUnit.gameCharacterId >= 21 && + gameCharacterUnit.unit !== card.supportUnit + ) { + if (card.supportUnit !== "none") { + return v; + } + return Math.max( + v, + gameCharacterUnit.unit === "piapro" + ? deckBonus.bonusRate + : deckBonus.bonusRate - 10 + ); + } + } + return Math.max(v, deckBonus.bonusRate); + }, 0); + + let maxBonus = finalEventBonus; + if (card.cardRarityType !== undefined) { + maxBonus += + masterRankBonus[card.cardRarityType][masterRankBonusIndex]; + } + + return { + card: card, + maxBonus: maxBonus, + minBonus: finalEventBonus, + }; + }) + .filter((it) => it.minBonus >= 40); + + if (result.length) { + const sortKey = "cardRarityType"; + result = result.sort((a, b) => { + if (a.minBonus > b.minBonus) return -1; + if (a.minBonus < b.minBonus) return 1; + + if (a.maxBonus > b.maxBonus) return -1; + if (a.maxBonus < b.maxBonus) return 1; + + if (a.card[sortKey]! > b.card[sortKey]!) return -1; + if (a.card[sortKey]! < b.card[sortKey]!) return 1; + + return 0; + }); + } + return result; + }); + setLinkedVirtualLive( + virtualLives.find((elem) => elem.id === ev?.virtualLiveId) + ); + } + }, [ + events, + eventId, + eventDeckBonuses, + eventCardsCache, + cards, + gameCharacterUnits, + virtualLives, + ]); + + useEffect(() => { + if (event && eventMusics) { + setEventMusic(eventMusics.find((em) => em.eventId === event.id)); + } + }, [event, eventMusics]); + + useEffect(() => { + if (event) { + const job = async () => { + const eventStrapi = await getEvent(event.id); + if (eventStrapi) { + setEventCommentId(eventStrapi.id); + } + }; + + job(); + } + }, [event, getEvent]); + + useEffect(() => { + if ( + event && + event.eventType === "cheerful_carnival" && + cheerfulCarnivalSummaries && + cheerfulCarnivalTeams + ) { + setCcTeams( + cheerfulCarnivalTeams.filter((cct) => cct.eventId === event.id) + ); + setCcSummary( + cheerfulCarnivalSummaries.find((ccs) => ccs.eventId === event.id) + ); + } + }, [cheerfulCarnivalSummaries, cheerfulCarnivalTeams, event]); + + useEffect(() => { + if (!event) { + return; + } + + let interval: number | undefined; + + const update = () => { + if (Date.now() > event.aggregateAt) { + // event already ended + if (interval) { + window.clearInterval(interval); + interval = undefined; + } + setRemainingTime(t("event:alreadyEnded")); + setPastTimePercent(100); + return false; + } else if (Date.now() < event.startAt) { + if (interval) { + window.clearInterval(interval); + interval = undefined; + } + setRemainingTime(t("event:notStarted")); + setPastTimePercent(0); + return false; + } + + const progressPercent = + ((Date.now() - event.startAt) / (event.aggregateAt - event.startAt)) * + 100; + + setRemainingTime( + `${humanizeDuration(event.aggregateAt - Date.now(), { + round: true, + units: ["d", "h", "m"], + })} (${progressPercent.toFixed(1)}%)` + ); + + setPastTimePercent(progressPercent); + return true; + }; + + if (!update()) { + // event already ended + return; + } + + interval = window.setInterval(update, 60000); + + return () => { + if (interval) { + window.clearInterval(interval); + interval = undefined; + } + }; + }, [event, t, humanizeDuration]); + + const [eventLogo, setEventLogo] = useState(""); + const [eventBanner, setEventBanner] = useState(""); + const [eventBackground, setEventBackground] = useState(""); + const [eventCharacter, setEventCharacter] = useState(""); + // const [ccTeam1Logo, setCcTeam1Logo] = useState(""); + // const [ccTeam2Logo, setCcTeam2Logo] = useState(""); + + useEffect(() => { + if (event) { + getRemoteAssetURL( + `event/${event.assetbundleName}/logo_rip/logo.webp`, + setEventLogo, + "minio", + region + ); + getRemoteAssetURL( + `home/banner/${event.assetbundleName}_rip/${event.assetbundleName}.webp`, + setEventBanner, + "minio", + region + ); + getRemoteAssetURL( + `event/${event.assetbundleName}/screen_rip/bg.webp`, + setEventBackground, + "minio", + region + ); + getRemoteAssetURL( + `event/${event.assetbundleName}/screen_rip/character.webp`, + setEventCharacter, + "minio", + region + ); + } + }, [event, region]); + + // useEffect(() => { + // if (event && ccTeams.length) { + // getRemoteAssetURL( + // `event/${event.assetbundleName}/team_image_rip/${ccTeams[0].assetbundleName}.webp`, + // setCcTeam1Logo, + // window.isChinaMainland + // ); + // getRemoteAssetURL( + // `event/${event.assetbundleName}/team_image_rip/${ccTeams[1].assetbundleName}.webp`, + // setCcTeam2Logo, + // window.isChinaMainland + // ); + // } + // }, [ccTeams, cheerfulCarnivalSummaries, cheerfulCarnivalTeams, event]); + + const getEventImages: () => ImageDecorator[] = useCallback( + () => + eventBackground && eventCharacter + ? [ + { + alt: "event background", + downloadUrl: eventBackground.replace(".webp", ".png"), + src: eventBackground, + }, + { + alt: "event character", + downloadUrl: eventCharacter.replace(".webp", ".png"), + src: eventCharacter, + }, + ] + : [], + [eventBackground, eventCharacter] + ); + + return event && + eventDeckBonus.length && + gameCharacterUnits && + gameCharacterUnits.length && + eventCards.length ? ( + + + {getTranslated(`event_name:${eventId}`, event.name)} + + + + + setImgTabVal(v)} + variant="scrollable" + scrollButtons + > + + + + + + + + logo + + + banner + + + + +
{ + setActiveIdx(0); + setVisible(true); + }} + > + +
+
+ +
{ + setActiveIdx(1); + setVisible(true); + }} + > + +
+
+
+
+ + + + + {t("event:remainingTime")} ({t("event:progress")}) + + {remainingTime} + + + + + + {t("common:id")} + + {event.id} + + + + + + {t("common:title")} + + + + + + + + + + + + {t("common:type")} + + {t(`event:type.${event.eventType}`)} + + + {Date.now() >= event.startAt && ( + + + + + {t("common:eventTracker")} + + + + + + + + + + + + + )} + + + + {t("common:storyReader")} + + + + + + + + + + + + {!!eventMusic && ( + + + + + {t("event:newlyWrittenSong")} + + + + + + + + + + + + + )} + +
+ {t("event:title.boost")} + + + {!!eventAttrBonus && ( + + + + {t("event:boostAttribute")} + + + + + + + + {eventAttrBonus.cardAttr} + + + + + +{eventAttrBonus.bonusRate}% + + + + + )} + + + + + {t("event:boostCharacters")} + + + + + + + {eventBonusCharas.map((chara, idx) => ( + + {`character + + ))} + + + + + + + {eventDeckBonus + .filter( + (it) => + it.cardAttr === undefined && + it.gameCharacterUnitId !== undefined + ) + .reduce((v, it) => Math.max(v, it.bonusRate), 0)} + % + + + + + + + + + + {t("event:boostCards")} + + + + + + + + + + {t("common:card")} + + + + + + {t("event:eventCards")} + + + + {eventCards.map((card) => ( + + + + + + ))} + + + + + + {event.eventType === "cheerful_carnival" && + !!ccTeams.length && + ccSummary && ( + + + {t("event:title.cheerful_carnival")} + + + + + + + + + + + + + + + + + {t("event:cheerful_carnival_midterm_1")} + + + {new Date(ccSummary.midtermAnnounce1At).toLocaleString()} + + + + + + {t("event:cheerful_carnival_midterm_2")} + + + {new Date(ccSummary.midtermAnnounce2At).toLocaleString()} + + + + + + + )} + {t("event:title.timepoint")} + + + + + {t("event:startAt")} + + + {new Date(event.startAt).toLocaleString()} + + + + + + {t("event:closeAt")} + + + {new Date(event.aggregateAt).toLocaleString()} + + + + + + {t("event:endAt")} + + + {new Date(event.closedAt).toLocaleString()} + + + + + + {t("event:rankingAnnounceAt")} + + + {new Date(event.rankingAnnounceAt).toLocaleString()} + + + + + + {t("event:distributionStartAt")} + + + {new Date(event.distributionStartAt).toLocaleString()} + + + + + + {/* */} + {!!linkedVirtualLive && ( + + {t("common:virtualLive")} + + + + + )} + {t("event:title.rankingRewards")} + + + {event.eventRankingRewardRanges.map((rankingReward) => ( + + + + + {t("event:rankingReward.start")}: {rankingReward.fromRank} + + + {t("event:rankingReward.end")}: {rankingReward.toRank} + + + + + + + + + + + ))} + + + {!!eventCommentId && ( + + + {t("common:comment")} + + + + + + )} + setVisible(false)} + images={getEventImages()} + zIndex={2000} + activeIndex={activeIdx} + downloadable + downloadInNewWindow + onMaskClick={() => setVisible(false)} + zoomSpeed={0.25} + onChange={(_, idx) => setActiveIdx(idx)} + /> + { + setIsCardsDialog(false); + }} + fullWidth + > + {t("event:boostCards")} + + + {boostCards.map((card) => ( + + + + + + +{card.minBonus} + {card.maxBonus > card.minBonus ? `~${card.maxBonus}` : ""} + % + + + + + ))} + + + +
+ ) : ( +
+ Loading... If you saw this for a while, event {eventId} does not exist. +
+ ); +}); + +export default EventDetail; diff --git a/src/pages/future/FutureEventGridView.tsx b/src/pages/future/FutureEventGridView.tsx new file mode 100644 index 00000000..59b5e46d --- /dev/null +++ b/src/pages/future/FutureEventGridView.tsx @@ -0,0 +1,101 @@ +import { Card, CardContent, Typography, Grid } from "@mui/material"; +import { Skeleton } from "@mui/material"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useRouteMatch } from "react-router-dom"; +import { IEventInfo, ServerRegion } from "../../types"; +import { getRemoteAssetURL } from "../../utils"; +import { useAssetI18n } from "../../utils/i18n"; +import { ContentTrans } from "../../components/helpers/ContentTrans"; +import SpoilerTag from "../../components/widgets/SpoilerTag"; +import { observer } from "mobx-react-lite"; +import CardMediaCardImg from "../../components/styled/CardMediaCardImg"; + +const GridView: React.FC<{ data?: IEventInfo }> = observer(({ data }) => { + const { t } = useTranslation(); + const { getTranslated } = useAssetI18n(); + const { path } = useRouteMatch(); + + const [eventLogo, setEventLogo] = useState(""); + + useEffect(() => { + if (data) { + getRemoteAssetURL( + `event/${data.assetbundleName}/logo_rip/logo.webp`, + setEventLogo, + "minio", + "jp" as ServerRegion + ); + } + }, [data]); + + if (!data) { + // loading + return ( + + + + + + + + + + + + ); + } + return ( + + + + + + + + + + + + + {t(`event:type.${data.eventType}`)} + + + {new Date(data.startAt).toLocaleString()} ~ + + + {new Date(data.aggregateAt).toLocaleString()} + + + + + + + ); +}); + +export default GridView; diff --git a/src/pages/future/FutureEventList.tsx b/src/pages/future/FutureEventList.tsx new file mode 100644 index 00000000..450d2a48 --- /dev/null +++ b/src/pages/future/FutureEventList.tsx @@ -0,0 +1,274 @@ +import React, { Fragment, useEffect, useState, useCallback } from "react"; +import { IEventInfo } from "../../types"; +import { useLocalStorage, useToggle } from "../../utils"; +import InfiniteScroll from "../../components/helpers/InfiniteScroll"; + +import { useTranslation } from "react-i18next"; +import GridView from "./FutureEventGridView"; +import { ServerRegion } from "../../types"; +import { + GetApp, + GetAppOutlined, + Publish, + PublishOutlined, + Update, + FilterAlt as Filter, + FilterAltOutlined as FilterOutlined, +} from "@mui/icons-material"; +import { + Badge, + Collapse, + FormControl, + Grid, + TextField, + ToggleButtonGroup, + ToggleButton, +} from "@mui/material"; +import Pound from "~icons/mdi/pound"; +import { useRootStore } from "../../stores/root"; +import { observer } from "mobx-react-lite"; +import TypographyHeader from "../../components/styled/TypographyHeader"; +import ContainerContent from "../../components/styled/ContainerContent"; +import { useDebounce } from "use-debounce"; +import PaperContainer from "../../components/styled/PaperContainer"; +import TypographyCaption from "../../components/styled/TypographyCaption"; +import { useCachedData } from "../../utils/index"; + +type ViewGridType = "grid" | "agenda" | "comfy"; + +function getPaginatedEvents(events: IEventInfo[], page: number, limit: number) { + return events.slice(limit * (page - 1), limit * page); +} + +const ListCard: { [key: string]: React.FC<{ data?: IEventInfo }> } = { + grid: GridView, +}; + +const EventList: React.FC = observer(() => { + const { t } = useTranslation(); + const { + settings: { isShowSpoiler }, + } = useRootStore(); + + const [events, setEvents] = useState([]); + const [eventsCache] = useCachedData( + "events", + "jp" as ServerRegion + ); + const [viewGridType] = useState( + (localStorage.getItem("event-list-grid-view-type") || + "grid") as ViewGridType + ); + const [page, setPage] = useState(1); + const [limit] = useState(12); + const [lastQueryFin, setLastQueryFin] = useState(true); + const [isReady, setIsReady] = useState(false); + const [sortType, setSortType] = useLocalStorage( + "event-list-update-sort", + "asc" + ); + const [sortBy, setSortBy] = useLocalStorage( + "event-list-filter-sort-by", + "startAt" + ); + const [sortedCache, setSortedCache] = useState([]); + const [filterOpened, toggleFilterOpened] = useToggle(false); + const [searchTitle, setSearchTitle] = useState(""); + const [debouncedSearchTitle] = useDebounce(searchTitle, 500); + + useEffect(() => { + document.title = t("title:eventList"); + }, [t]); + + useEffect(() => { + setEvents((events) => [ + ...events, + ...getPaginatedEvents(sortedCache, page, limit), + ]); + setLastQueryFin(true); + }, [page, limit, setLastQueryFin, sortedCache]); + + useEffect(() => { + if (!eventsCache || !eventsCache.length) return; + let sortedCache = [...eventsCache]; + if (isShowSpoiler) { + sortedCache = sortedCache.filter( + // gets the events starting a year prior, in milliseconds + (e) => e.startAt >= new Date().getTime() - 31556952000 + ); + } + if (sortType === "desc") { + sortedCache = sortedCache.sort( + (a, b) => b[sortBy as "startAt"] - a[sortBy as "startAt"] + ); + } else if (sortType === "asc") { + sortedCache = sortedCache.sort( + (a, b) => a[sortBy as "startAt"] - b[sortBy as "startAt"] + ); + } + if (debouncedSearchTitle) { + sortedCache = sortedCache.filter((e) => + e.name.toLowerCase().includes(debouncedSearchTitle.toLowerCase()) + ); + } + setSortedCache(sortedCache); + setEvents([]); + setPage(0); + }, [ + eventsCache, + setPage, + sortType, + sortBy, + isShowSpoiler, + debouncedSearchTitle, + ]); + + useEffect(() => { + setIsReady(!!eventsCache?.length); + }, [eventsCache?.length]); + + const callback = useCallback( + ( + entries: readonly IntersectionObserverEntry[], + setHasMore: React.Dispatch> + ) => { + if (!isReady) return; + if ( + entries[0].isIntersecting && + lastQueryFin && + (!sortedCache.length || sortedCache.length > page * limit) + ) { + setPage((page) => page + 1); + setLastQueryFin(false); + } else if (sortedCache.length && sortedCache.length <= page * limit) { + setHasMore(false); + } + }, + [isReady, lastQueryFin, limit, page, sortedCache.length] + ); + + const handleUpdateSortType = useCallback( + (_: unknown, sort: string) => { + setSortType(sort || "asc"); + }, + [setSortType] + ); + + const handleUpdateSortBy = useCallback( + (_: unknown, sort: string) => { + setSortBy(sort || "id"); + }, + [setSortBy] + ); + + return ( + + {t("common: futureevent")} + + + + + + + + {sortType === "asc" ? : } + + + {sortType === "desc" ? : } + + + + + + + + + + + + + + + + + + toggleFilterOpened()} + > + {filterOpened ? : } + + + + + + + + + + {t("common:title")} + + + + setSearchTitle(e.target.value)} + sx={{ minWidth: "200px" }} + /> + + + + + + + + ViewComponent={ListCard[viewGridType]} + callback={callback} + data={events} + gridSize={ + ( + { + grid: { + xs: 12, + sm: 6, + md: 4, + lg: 3, + }, + agenda: { + xs: 12, + }, + comfy: { + xs: 12, + }, + } as const + )[viewGridType] + } + /> + + + ); +}); + +export default EventList; diff --git a/src/pages/future/FutureGachaGridView.tsx b/src/pages/future/FutureGachaGridView.tsx new file mode 100644 index 00000000..14004cfa --- /dev/null +++ b/src/pages/future/FutureGachaGridView.tsx @@ -0,0 +1,100 @@ +import { Card, CardContent, Typography } from "@mui/material"; +import { Skeleton } from "@mui/material"; +import React, { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { IGachaInfo, ServerRegion } from "../../types"; +import { getRemoteAssetURL } from "../../utils"; +import { ContentTrans } from "../../components/helpers/ContentTrans"; +import SpoilerTag from "../../components/widgets/SpoilerTag"; +import { observer } from "mobx-react-lite"; +import CardMediaCardImg from "../../components/styled/CardMediaCardImg"; + +const GridView: React.FC<{ data?: IGachaInfo }> = observer(({ data }) => { + const [url, setUrl] = useState(""); + + useEffect(() => { + if (data) { + getRemoteAssetURL( + `gacha/${data.assetbundleName}/logo_rip/logo.webp`, + setUrl, + "minio", + "jp" as ServerRegion + ); + } + }, [data]); + + if (!data) { + // loading + return ( + + + + + + + + + ); + } + + return ( + // kinda a brute forced way of doing it tbh, but I just removed the path and set it to send to gacha + + + + + + + + + {new Date(data.startAt).toLocaleString()} ~ + + + {new Date(data.endAt).toLocaleString()} + + + + + ); +}); + +export default GridView; diff --git a/src/pages/future/FutureGachaList.tsx b/src/pages/future/FutureGachaList.tsx new file mode 100644 index 00000000..9e955c2d --- /dev/null +++ b/src/pages/future/FutureGachaList.tsx @@ -0,0 +1,251 @@ +import React, { Fragment, useCallback, useEffect, useState } from "react"; +import { useLocalStorage, useToggle } from "../../utils"; +import InfiniteScroll from "../../components/helpers/InfiniteScroll"; + +import { useTranslation } from "react-i18next"; +import { IGachaInfo } from "../../types"; +import GridView from "./FutureGachaGridView"; +import { ServerRegion } from "../../types"; +import { + Update, + Publish, + PublishOutlined, + GetApp, + GetAppOutlined, + FilterAlt as Filter, + FilterAltOutlined as FilterOutlined, +} from "@mui/icons-material"; +import { + Badge, + Collapse, + FormControl, + Grid, + TextField, + ToggleButton, + ToggleButtonGroup, +} from "@mui/material"; +import Pound from "~icons/mdi/pound"; +import { useRootStore } from "../../stores/root"; +import { observer } from "mobx-react-lite"; +import TypographyHeader from "../../components/styled/TypographyHeader"; +import ContainerContent from "../../components/styled/ContainerContent"; +import PaperContainer from "../../components/styled/PaperContainer"; +import TypographyCaption from "../../components/styled/TypographyCaption"; +import { useDebounce } from "use-debounce"; +import { useCachedData } from "../../utils/index"; + +function getPaginatedGachas(gachas: IGachaInfo[], page: number, limit: number) { + return gachas.slice(limit * (page - 1), limit * page); +} + +const ListCard: React.FC<{ data?: IGachaInfo }> = GridView; + +const GachaList: React.FC = observer(() => { + const { t } = useTranslation(); + const { + settings: { isShowSpoiler }, + } = useRootStore(); + + const [gachas, setGachas] = useState([]); + const [gachasCache] = useCachedData( + "gachas", + "jp" as ServerRegion + ); + + const [page, setPage] = useState(1); + const [limit] = useState(12); + const [lastQueryFin, setLastQueryFin] = useState(true); + const [isReady, setIsReady] = useState(false); + const [sortType, setSortType] = useLocalStorage( + "gacha-list-update-sort", + "asc" + ); + const [sortBy, setSortBy] = useLocalStorage( + "gacha-list-filter-sort-by", + "startAt" + ); + const [sortedCache, setSortedCache] = useState([]); + const [filterOpened, toggleFilterOpened] = useToggle(false); + const [searchTitle, setSearchTitle] = useState(""); + const [debouncedSearchTitle] = useDebounce(searchTitle, 500); + + useEffect(() => { + document.title = t("title:gachaList"); + }, [t]); + + useEffect(() => { + setGachas((gachas) => [ + ...gachas, + ...getPaginatedGachas(sortedCache, page, limit), + ]); + setLastQueryFin(true); + }, [page, limit, setLastQueryFin, sortedCache]); + + useEffect(() => { + if (!gachasCache?.length) return; + let sortedCache = [...gachasCache]; + if (isShowSpoiler) { + sortedCache = sortedCache.filter( + // gets the events starting a year prior, in milliseconds + (g) => g.startAt >= new Date().getTime() - 31556952000 + ); + } + if (!isShowSpoiler) { + sortedCache = []; + } + if (sortType === "desc") { + sortedCache = sortedCache.sort( + (a, b) => b[sortBy as "startAt"] - a[sortBy as "startAt"] + ); + } else if (sortType === "asc") { + sortedCache = sortedCache.sort( + (a, b) => a[sortBy as "startAt"] - b[sortBy as "startAt"] + ); + } + if (debouncedSearchTitle) { + sortedCache = sortedCache.filter((g) => + g.name.toLowerCase().includes(debouncedSearchTitle.toLowerCase()) + ); + } + setSortedCache(sortedCache); + setGachas([]); + setPage(0); + }, [debouncedSearchTitle, gachasCache, isShowSpoiler, sortBy, sortType]); + + useEffect(() => { + setIsReady(!!gachasCache?.length); + }, [gachasCache?.length]); + + const callback = useCallback( + ( + entries: readonly IntersectionObserverEntry[], + setHasMore: React.Dispatch> + ) => { + if (!isReady) return; + if ( + entries[0].isIntersecting && + lastQueryFin && + (!sortedCache.length || sortedCache.length > page * limit) + ) { + setPage((page) => page + 1); + setLastQueryFin(false); + } else if (sortedCache.length && sortedCache.length <= page * limit) { + setHasMore(false); + } + }, + [isReady, lastQueryFin, limit, page, sortedCache.length] + ); + + const handleUpdateSortType = useCallback( + (_: unknown, sort: string) => { + setSortType(sort || "asc"); + }, + [setSortType] + ); + + const handleUpdateSortBy = useCallback( + (_: unknown, sort: string) => { + setSortBy(sort || "id"); + }, + [setSortBy] + ); + + return ( + + {t("common:futuregacha")} + + + + + + + + {sortType === "asc" ? : } + + + {sortType === "desc" ? : } + + + + + + + + + + + + + + + + + + toggleFilterOpened()} + > + {filterOpened ? : } + + + + + + + + + + {t("common:title")} + + + + setSearchTitle(e.target.value)} + sx={{ minWidth: "200px" }} + /> + + + + + + + + ViewComponent={ListCard} + callback={callback} + data={gachas} + gridSize={{ + xs: 12, + sm: 6, + md: 4, + lg: 3, + }} + /> + + + ); +}); + +export default GachaList; diff --git a/src/types.d.ts b/src/types.d.ts index 6bbeea5c..857b6222 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1595,7 +1595,6 @@ export interface IMusicOriginal { musicId: number; videoLink: string; } - export interface IIngameCutinCharacters { id: number; ingameCutinCharacterType: string; diff --git a/src/utils/index.ts b/src/utils/index.ts index b60f02ae..88fcad37 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -101,6 +101,10 @@ export function useRefState( return [state, stateRef, setState]; } +export function useCachedData( + name: string, + paramRegion: ServerRegion +): [T[] | undefined, boolean, any]; export function useCachedData< T extends | IGachaInfo @@ -158,10 +162,68 @@ export function useCachedData< | IGachaTicket | IMusicOriginal | IIngameCutinCharacters, ->(name: string): [T[] | undefined, boolean, any] { - // const [cached, cachedRef, setCached] = useRefState([]); - const { region } = useRootStore(); - +> +(name: string): [T[] | undefined, boolean, any]; +export function useCachedData< + T extends + | IGachaInfo + | ICardInfo + | IGameChara + | IMusicInfo + | ISkillInfo + | ICardRarity + | ICharacterRank + | IMusicVocalInfo + | IOutCharaProfile + | IUserInformationInfo + | IMusicDifficultyInfo + | IMusicTagInfo + | IReleaseCondition + | IMusicDanceMembers + | IEventInfo + | IEventDeckBonus + | IGameCharaUnit + | IResourceBoxInfo + | IHonorInfo + | ICardEpisode + | ITipInfo + | ICharaProfile + | IUnitProfile + | IUnitStory + | IMobCharacter + | ICharacter2D + | IEventStory + | IHonorMission + | INormalMission + | IBeginnerMission + | IHonorGroup + | ICharacterMission + | IEventCard + | IMusicAchievement + | IGachaCeilItem + | ICharacter3D + | ICostume3DModel + | IAreaItemLevel + | IAreaItem + | ICheerfulCarnivalSummary + | ICheerfulCarnivalTeam + | IArea + | IActionSet + | ISpecialStory + | IBondsHonor + | IBondsHonorWord + | IBond + | IBondsReward + | IEventRarityBonusRate + | IMasterLesson + | IMasterLessonReward + | IEventMusic + | IGachaTicket + | IMusicOriginal + | IIngameCutinCharacters, +>(name: string, paramRegion?: ServerRegion): [T[] | undefined, boolean, any] { + // eslint-disable-next-line react-hooks/rules-of-hooks + const region = paramRegion ? paramRegion : useRootStore().region; const fetchCached = useCallback(async (name: string) => { const [region, filename] = name.split("|"); const urlBase = masterUrl["ww"][region as ServerRegion]; @@ -868,7 +930,6 @@ export async function getGachaRemoteImages( img: filenames.filter((name) => name.includes("img_gacha")), }; } - export async function getRemoteImageSize( url: string ): Promise<{ width: number; height: number }> {