Skip to content

Commit 70c27bd

Browse files
committed
Add events
1 parent 1dcbfea commit 70c27bd

10 files changed

Lines changed: 276 additions & 16 deletions

File tree

‎components/NavBar.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { useState } from "react"
22
import FormattedLink from "./FormattedLink"
33

4-
const pages = ["Guides", "Characters", "Weapons", "Enemies", "Materials", "Artifacts", "Tools"]
4+
const pages = ["Guides", "Events", "Characters", "Weapons", "Enemies", "Materials", "Artifacts", "Tools"]
55

66
export default function NavBar({ location }: {location: string}) {
77
const [menuOpen, setMenuOpen] = useState(false)

‎pages/characters/[char].tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ function TalentTableSection({ table }: { table: (TalentTable | TalentValue)[] })
371371

372372
function hint(input: string): ReactElement {
373373
return <>
374-
{input.split("").map(x => <span key={x}>{x}{x.match(/[+/%]/) && <wbr />}</span>)}
374+
{input.split("").map((x, i) => <span key={i}>{x}{x.match(/[+/%]/) && <wbr />}</span>)}
375375
</>
376376
}
377377
function countUp<T>(arr: T[], v: T, i: number): number {

‎pages/events.tsx‎

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/* eslint-disable @next/next/no-img-element */
2+
import { GetStaticPropsContext, GetStaticPropsResult } from "next"
3+
import Head from "next/head"
4+
import { useEffect, useState } from "react"
5+
import FormattedLink from "../components/FormattedLink"
6+
import Main from "../components/Main"
7+
import { getEvents } from "../utils/data-cache"
8+
import { Event as GenshinEvent, EventType } from "../utils/types"
9+
import { formatTime, getDate, timeLeft } from "../utils/utils"
10+
import styles from "./style.module.css"
11+
12+
13+
interface Props {
14+
events: GenshinEvent[]
15+
}
16+
17+
type Server = "Asia" | "Europe" | "North America"
18+
19+
const timezones: Record<Server, string> = {
20+
"Asia": "+08:00",
21+
"Europe": "+01:00",
22+
"North America": "-05:00",
23+
}
24+
25+
export default function Events(props: Props & { location: string }) {
26+
const [now, setNow] = useState(() => Date.now())
27+
28+
const [server, setServer] = useState("Europe" as Server)
29+
const [serverTimezone, setServerTimezone] = useState("+08:00")
30+
31+
const [showPast, setShowPast] = useState(false)
32+
33+
useEffect(() => {
34+
const server = localStorage.getItem("server")
35+
36+
if (server && Object.keys(timezones).includes(server))
37+
setServer(server as Server)
38+
else {
39+
const clientOffset = new Date().getTimezoneOffset() / 60
40+
if (clientOffset > 5)
41+
setServer("Asia")
42+
else if (clientOffset > -2 || clientOffset < -11)
43+
setServer("Europe")
44+
else
45+
setServer("North America")
46+
}
47+
}, [])
48+
49+
useEffect(() => {
50+
setServerTimezone(timezones[server])
51+
localStorage.setItem("server", server)
52+
}, [server])
53+
54+
useEffect(() => {
55+
const timer = setTimeout(() => {
56+
setNow(Date.now())
57+
}, 10050 - (Date.now() % 10000))
58+
59+
return () => clearTimeout(timer)
60+
}, [now])
61+
62+
63+
const ongoing = props.events
64+
.filter(e => {
65+
const start = getStartTime(e, serverTimezone)
66+
const end = getEndTime(e, serverTimezone)
67+
68+
return start && start.getTime() <= now &&
69+
(
70+
(end && end.getTime() >= now) ||
71+
(!end && e.reminder == "daily")
72+
)
73+
}).sort((a, b) => {
74+
const endA = getEndTime(a, serverTimezone)
75+
const endB = getEndTime(b, serverTimezone)
76+
77+
if (!endA) return 1
78+
if (!endB) return -1
79+
80+
return endA.getTime() - endB.getTime()
81+
})
82+
83+
const upcoming = props.events
84+
.filter(e => {
85+
const start = getStartTime(e, serverTimezone)
86+
return start == false || start.getTime() > now
87+
})
88+
.sort((a, b) => {
89+
const startA = getStartTime(a, serverTimezone)
90+
const startB = getStartTime(b, serverTimezone)
91+
92+
if (!startA) return 1
93+
if (!startB) return -1
94+
95+
return startA.getTime() - startB.getTime()
96+
})
97+
98+
const past = props.events
99+
.filter(e => !ongoing.includes(e) && !upcoming.includes(e))
100+
.sort((a, b) => {
101+
const startA = getStartTime(a, serverTimezone)
102+
const startB = getStartTime(b, serverTimezone)
103+
104+
if (!startA) return -1
105+
if (!startB) return 1
106+
107+
return startB.getTime() - startA.getTime()
108+
})
109+
110+
const desc = "View ongoing and future events of Genshin Impact."
111+
return (
112+
<Main>
113+
<Head>
114+
<title>Events | Hu Tao</title>
115+
<meta name="twitter:card" content="summary" />
116+
<meta property="og:title" content="Events | Hu Tao" />
117+
<meta property="og:description" content={desc} />
118+
<meta property="description" content={desc} />
119+
</Head>
120+
121+
<h1 className="text-5xl font-bold pb-2">
122+
Events
123+
</h1>
124+
<div>
125+
Select server:
126+
<div>
127+
{Object.keys(timezones).map((t) => <span
128+
key={t}
129+
className={`${server == t ? "bg-green-500" : "bg-blue-500"} text-slate-50 text-center rounded-lg mr-2 px-1 py-0.5 cursor-pointer`}
130+
onClick={() => setServer(t as Server)}
131+
>
132+
{t}
133+
</span>)}
134+
</div>
135+
</div>
136+
137+
<h3 className="text-xl mt-3">Ongoing event{ongoing.length == 1 ? "" : "s"}</h3>
138+
<div className="flex flex-row flex-wrap pt-2 gap-2">
139+
{ongoing
140+
.map((event, i) => <EventCard key={i} e={event} now={now} serverTimezone={serverTimezone} hoverClass="hover:text-blue-800" className="bg-green-500 text-black" />)}
141+
</div>
142+
143+
<h3 className="text-xl mt-3">Upcoming event{upcoming.length == 1 ? "" : "s"}</h3>
144+
<div className="flex flex-row flex-wrap pt-2 gap-2">
145+
{upcoming
146+
.map((event, i) => <EventCard key={i} e={event} now={now} serverTimezone={serverTimezone} className="dark:bg-slate-800 bg-slate-300" />)}
147+
</div>
148+
149+
<h3 className="text-xl mt-3">Past event{past.length == 1 ? "" : "s"}</h3>
150+
{showPast ? <div className="flex flex-row flex-wrap pt-2 gap-2">
151+
{past
152+
.map((event, i) => <EventCard key={i} e={event} now={now} serverTimezone={serverTimezone} hoverClass="hover:text-blue-800" className="bg-red-400 text-black" />)}
153+
</div> : <span className="bg-blue-500 text-slate-50 text-center rounded-lg mr-2 mt-1 px-1 py-0.5 cursor-pointer" onClick={() => setShowPast(true)}>Show past events</span>}
154+
</Main>
155+
)
156+
}
157+
158+
function EventCard({ e, className, now, serverTimezone, hoverClass = "" }: { e: GenshinEvent, className: string, hoverClass?: string, now: number, serverTimezone: string }) {
159+
const start = getStartTime(e, serverTimezone)
160+
const end = getEndTime(e, serverTimezone)
161+
const child = <div className={`text-center max-w-md w-full rounded-xl pb-1 ${className} ${e.link ? `${hoverClass} transition`:""}`}>
162+
<div className={`${styles.mask} w-full`}>
163+
<img loading="lazy" className="rounded-t-xl" src={e.img ?? "/img/Event_No_Img.png"} width={448} alt={e.name} />
164+
</div>
165+
<div>
166+
<h3 className="font-semibold">{e.name}</h3>
167+
{start && <div className={e.prediction ? "opacity-60" : ""}>{e.prediction && <span className="font-bold cursor-help" title="Predicted based on past events like this">Predicted</span>} {e.type == EventType.Unlock ? "Unlock Time" : "Start Time"}: {formatTime(start)} ({timeLeft(start.getTime() - now)})</div>}
168+
{end && <div className={e.prediction ? "opacity-60" : ""}>{e.prediction && <span className="font-bold cursor-help" title="Predicted based on past events like this">Predicted</span>} End Time {formatTime(end)} ({timeLeft(end.getTime() - now)})</div>}
169+
170+
</div>
171+
</div>
172+
return e.link ? <FormattedLink href={e.link} className="font-normal">{child}</FormattedLink> : child
173+
}
174+
175+
function getStartTime(event: GenshinEvent, serverTimezone: string) {
176+
const serverTimeStart = event.start_server ?? (event.type == EventType.Banner || event.type == EventType.InGame || event.type == EventType.Unlock)
177+
const startTime = event.start != undefined && getDate(event.start, event.timezone ?? serverTimeStart ? serverTimezone : undefined)
178+
179+
return startTime
180+
}
181+
182+
function getEndTime(event: GenshinEvent, serverTimezone: string) {
183+
const serverTimeEnd = event.end_server ?? (event.type == EventType.Banner || event.type == EventType.InGame || event.type == EventType.Web)
184+
const endTime = event.end != undefined && getDate(event.end, event.timezone ?? serverTimeEnd ? serverTimezone : undefined)
185+
186+
return endTime
187+
}
188+
189+
190+
export async function getStaticProps(context: GetStaticPropsContext): Promise<GetStaticPropsResult<Props>> {
191+
const events = await getEvents()
192+
193+
if (!events) {
194+
return {
195+
notFound: true,
196+
revalidate: 15 * 60
197+
}
198+
}
199+
200+
return {
201+
props: {
202+
events
203+
},
204+
revalidate: 60 * 60 * 4
205+
}
206+
}

‎pages/index.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export default function Home({ location }: { location: string }) {
8080

8181
<div className="md:flex md:flex-row items-center max-w-4xl">
8282
<Card href="/weapons" title="Weapon Information &rarr;" desc="Check which weapon ascension material you need to level that shiny new weapon or which refinements/sub-stat it has" location={location} src={weapons.Sword} alsoSite />
83-
<Card title="Events" desc="Get reminded when new events come out" location={location} icon={<Alarm />} />
83+
<Card href="/events" title="Events &rarr;" desc="Get reminded when new events come out" location={location} icon={<Alarm />} alsoSite="List of events is available on site" />
8484
</div>
8585

8686
<div className="md:flex md:flex-row items-center max-w-4xl">

‎pages/style.module.css‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,20 @@
4747
min-height: 3rem;
4848
}
4949
}
50+
51+
.mask {
52+
--mask-height: 16px;
53+
overflow-y: hidden;
54+
height: 10rem;
55+
padding-bottom: var(--mask-height);
56+
--mask-image-content: linear-gradient(
57+
to bottom,
58+
black calc(100% - var(--mask-height)),
59+
transparent
60+
);
61+
--mask-size-content: 100% 100%;
62+
mask-image: var(--mask-image-content);
63+
mask-size: var(--mask-size-content);
64+
mask-position: 0 0, 100% 0;
65+
mask-repeat: no-repeat, no-repeat;
66+
}

‎pages/tools/reminders.tsx‎

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { config } from "../../utils/config"
1111
import * as gtag from "../../utils/gtag"
1212
import { parseUser } from "../../utils/parse-user"
1313
import { DiscordUser, Reminder } from "../../utils/types"
14-
import { parseDuration, send, timeLeft } from "../../utils/utils"
14+
import { formatTime, parseDuration, send, timeLeft } from "../../utils/utils"
1515

1616
interface Props {
1717
user: DiscordUser
@@ -72,7 +72,7 @@ export default class Reminders extends Component<Props & { location: string }, {
7272
Reminders
7373
</h1>
7474
<div className="text-base">Logged in as: <DiscordAvatar user={user} /> {user.username}<span className="text-xs">#{user.discriminator}</span></div>
75-
<div className="text-xl mt-3">{reminders.length} reminder{reminders.length == 1 ? "" : "s"}</div>
75+
<h3 className="text-xl mt-3">{reminders.length} reminder{reminders.length == 1 ? "" : "s"}</h3>
7676
{reminders.map(r => <ReminderCard
7777
r={r}
7878
key={r.id}
@@ -179,7 +179,7 @@ function CreateReminder({ isOpen, requestClose, addReminder }: { isOpen: boolean
179179
}}>
180180
<TextInput value={name} set={setName} placeholder="Enter a name" maxLength={128} label="Name:" />
181181
<TextInput value={duration} set={setDuration} placeholder="Enter a duration (ex. 10 hours 2 resin 5s)" maxLength={128} label="Duration:" />
182-
<div>Will trigger {parseDuration(duration) > 0 && <>in <span className="text-slate-100">{timeLeft(formatDuration(parseDuration(duration)), true)}</span></>} on <span className="text-slate-100">{target}</span></div>
182+
<div>Will trigger {parseDuration(duration) > 0 && <span className="text-slate-100">{timeLeft(parseDuration(duration), true)}</span>} on <span className="text-slate-100">{target}</span></div>
183183
<button
184184
className="bg-green-600 disabled:bg-slate-900 text-slate-50 disabled:text-slate-400 w-fit px-3 py-1 text-center rounded-lg mt-2 cursor-pointer"
185185
formAction="submit"
@@ -222,14 +222,6 @@ function TextInput({ value, set, maxLength, placeholder, label }: { value: strin
222222
</label></div>
223223
}
224224

225-
function formatTime(date: Date) {
226-
return date.toLocaleString(undefined, { day: "numeric", year: "numeric", month: "short", hour: "2-digit", minute: "2-digit", second: "2-digit" })
227-
}
228-
229-
function formatDuration(duration: number) {
230-
return duration
231-
}
232-
233225
function ReminderCard({ r, onDelete }: { r: Reminder, onDelete: () => void }) {
234226
const deleteReminder = async () => {
235227
const res = await send("/api/reminders/delete", { r })

‎public/img/Event_No_Img.png‎

525 KB
Loading

‎utils/data-cache.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Artifact, Character, Cost, CurveEnum, Enemy, Guide, Material, Weapon, WeaponCurveName } from "./types"
1+
import { Artifact, Character, Cost, CurveEnum, Enemy, Event, Guide, Material, Weapon, WeaponCurveName } from "./types"
22

33
const baseURL = "https://raw.githubusercontent.com/Tibowl/HuTao/master/src/data"
44

@@ -19,6 +19,8 @@ export type WeaponCurves = Record<WeaponCurveName, number[]>
1919

2020
type Enemies = Record<string, Enemy>
2121

22+
type Events = Event[]
23+
2224

2325
type Cache = {
2426
artifacts: Cacher<Artifacts>
@@ -31,6 +33,7 @@ type Cache = {
3133
weapons: Cacher<Weapons>
3234
weaponCurves: Cacher<WeaponCurves>
3335
enemies: Cacher<Enemies>
36+
events: Cacher<Events>
3437
}
3538

3639
interface Cacher<T> {
@@ -41,6 +44,8 @@ interface Cacher<T> {
4144
const cached: Partial<Cache> = {}
4245

4346
export const getGuides: (() => Promise<Guides | undefined>) = createGetCacheable("guides")
47+
export const getEvents: (() => Promise<Events | undefined>) = createGetCacheable("events")
48+
4449
export const getCostTemplates: (() => Promise<CostTemplates | undefined>) = createGetCacheable("costTemplates", "gamedata/cost_templates")
4550

4651
export const getArtifacts: (() => Promise<Artifacts | undefined>) = createGetCacheable("artifacts", "gamedata/artifacts")

‎utils/types.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,36 @@ export interface DiscordUser {
1212
premium_type: number
1313
}
1414

15+
// Events
16+
export interface Event {
17+
name: string
18+
type: EventType
19+
prediction?: boolean
20+
link?: string
21+
img?: string
22+
start?: string
23+
start_server?: boolean
24+
end?: string
25+
end_server?: boolean
26+
timezone?: string
27+
reminder?: EventReminderType
28+
remindtime?: string
29+
}
30+
31+
export enum EventType {
32+
Web = "Web",
33+
InGame = "In-game",
34+
Maintenance = "Maintenance",
35+
Stream = "Stream",
36+
Unlock = "Unlock",
37+
Banner = "Banner"
38+
}
39+
export enum EventReminderType {
40+
Daily = "daily",
41+
End = "end"
42+
}
43+
44+
1545
// Reminders
1646
export interface Reminder {
1747
id: number

‎utils/utils.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ export function getWeaponStatsAt(weapon: Weapon, level: number, ascension: numbe
6565
return stats
6666
}
6767

68+
export function getDate(timestamp: string, timezone = "+08:00"): Date {
69+
timestamp = timestamp.replace(" ", "T")
70+
if (!timestamp.includes("T")) timestamp += "T23:59:59"
71+
return new Date(`${timestamp}${timezone}`)
72+
}
73+
6874
export function stat(name: string, value: number): string {
6975
switch (name) {
7076
case "HP%":
@@ -256,5 +262,9 @@ export function timeLeft(diff: number, full = false, short = true): string {
256262
result.push(seconds + (short ? "s" : (seconds == 1 ? " second" : " seconds")))
257263
}
258264

259-
return result.join(", ") + (ago ? " ago" : "")
265+
return (ago ? `${result.join(", ")} ago` : `in ${result.join(", ")}`)
266+
}
267+
268+
export function formatTime(date: Date) {
269+
return date.toLocaleString(undefined, { day: "numeric", year: "numeric", month: "short", hour: "2-digit", minute: "2-digit", second: "2-digit" })
260270
}

0 commit comments

Comments
 (0)