initial index
This commit is contained in:
parent
62cc828d6a
commit
88015a98cd
21 changed files with 343 additions and 56 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -7,4 +7,4 @@ node_modules
|
|||
.env
|
||||
|
||||
.DS_Store
|
||||
*/.DS_Store
|
||||
**/.DS_Store
|
||||
|
|
30
app/components/faction-bar.css.ts
Normal file
30
app/components/faction-bar.css.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import type { ComplexStyleRule } from "@vanilla-extract/css";
|
||||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const bar = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
overflow: "hidden",
|
||||
borderRadius: "0.4rem",
|
||||
border: "2px solid #2d2d2d",
|
||||
});
|
||||
|
||||
const shared: ComplexStyleRule = {
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
export const left = style({
|
||||
...shared,
|
||||
backgroundColor: "#991cba",
|
||||
});
|
||||
export const center = style({
|
||||
...shared,
|
||||
backgroundColor: "#1564cc",
|
||||
borderBottom: "1px solid #2d2d2d",
|
||||
});
|
||||
export const right = style({
|
||||
...shared,
|
||||
backgroundColor: "#d30101",
|
||||
});
|
32
app/components/faction-bar.tsx
Normal file
32
app/components/faction-bar.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { useMemo } from "react";
|
||||
import type { Population } from "~/utils/saerro";
|
||||
import { totalPopulation } from "~/utils/saerro";
|
||||
import * as styles from "./faction-bar.css";
|
||||
|
||||
export const FactionBar = ({
|
||||
population: { vs, nc, tr },
|
||||
}: {
|
||||
population: Population;
|
||||
}) => {
|
||||
const { vsPercent, ncPercent, trPercent } = useMemo(() => {
|
||||
const total = totalPopulation({ vs, nc, tr, total: 0 });
|
||||
return {
|
||||
vsPercent: Math.floor((vs / total) * 100) || 0,
|
||||
ncPercent: Math.floor((nc / total) * 100) || 0,
|
||||
trPercent: Math.floor((tr / total) * 100) || 0,
|
||||
};
|
||||
}, [vs, nc, tr]);
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<div className={styles.left} style={{ flexGrow: vs + 1 }}>
|
||||
{vsPercent}%
|
||||
</div>
|
||||
<div className={styles.center} style={{ flexGrow: nc + 1 }}>
|
||||
{ncPercent}%
|
||||
</div>
|
||||
<div className={styles.right} style={{ flexGrow: tr + 1 }}>
|
||||
{trPercent}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
8
app/components/index-world-container.css.ts
Normal file
8
app/components/index-world-container.css.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const container = style({
|
||||
display: "flex",
|
||||
flexBasis: "100%",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "center",
|
||||
});
|
22
app/components/index-world-container.tsx
Normal file
22
app/components/index-world-container.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { useMemo } from "react";
|
||||
import type { Health, World } from "~/utils/saerro";
|
||||
import { IndexWorld } from "./index-world";
|
||||
import * as styles from "./index-world-container.css";
|
||||
|
||||
export const WorldContainer = ({
|
||||
worlds,
|
||||
health,
|
||||
}: {
|
||||
worlds: World[];
|
||||
health: Health;
|
||||
}) => (
|
||||
<div className={styles.container}>
|
||||
{worlds.map((world) => (
|
||||
<IndexWorld
|
||||
key={world.id}
|
||||
world={world}
|
||||
health={health.worlds.find((w) => world.name.toLowerCase() === w.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
70
app/components/index-world.css.ts
Normal file
70
app/components/index-world.css.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const container = style({
|
||||
background: "#333",
|
||||
flexBasis: "30%",
|
||||
margin: "0.5rem",
|
||||
});
|
||||
|
||||
export const header = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
transition: "background-color 0.2s ease-in-out",
|
||||
backgroundColor: "#222",
|
||||
":hover": {
|
||||
backgroundColor: "#383838",
|
||||
},
|
||||
});
|
||||
export const headerName = style({
|
||||
padding: "0.5rem",
|
||||
fontSize: "1.5rem",
|
||||
});
|
||||
export const headerDetailsLink = style({
|
||||
fontVariant: "small-caps",
|
||||
fontSize: "0.8rem",
|
||||
color: "#aaa",
|
||||
paddingRight: "0.5rem",
|
||||
});
|
||||
export const headerMarkers = style({
|
||||
fontSize: "0.8rem",
|
||||
flex: 1,
|
||||
fontWeight: "bold",
|
||||
color: "#aaa",
|
||||
});
|
||||
|
||||
export const circle = style({
|
||||
display: "inline-block",
|
||||
width: "0.4rem",
|
||||
height: "0.4rem",
|
||||
borderRadius: "50%",
|
||||
marginLeft: "0.2rem",
|
||||
});
|
||||
|
||||
export const details = style({
|
||||
padding: "0.5rem",
|
||||
});
|
||||
|
||||
export const population = style({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-evenly",
|
||||
});
|
||||
|
||||
export const popFaction = style({
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
});
|
||||
|
||||
export const popImage = style({
|
||||
height: "1.5rem",
|
||||
marginRight: "0.5rem",
|
||||
});
|
||||
|
||||
export const totalPop = style({
|
||||
fontWeight: "bold",
|
||||
fontSize: "1.2rem",
|
||||
});
|
|
@ -1,18 +1,61 @@
|
|||
import { World } from "~/utils/saerro";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { Health, totalPopulation, World } from "~/utils/saerro";
|
||||
import { humanTimeAgo } from "~/utils/strings";
|
||||
import { worlds } from "~/utils/worlds";
|
||||
import * as styles from "./index-world.css";
|
||||
import vsLogo from "~/images/vs-100.png";
|
||||
import ncLogo from "~/images/nc-100.png";
|
||||
import trLogo from "~/images/tr-100.png";
|
||||
import { FactionBar } from "./faction-bar";
|
||||
|
||||
export type IndexWorldProps = {
|
||||
world: World;
|
||||
health?: Health["worlds"][number];
|
||||
};
|
||||
|
||||
export const IndexWorld = ({ world }: IndexWorldProps) => {
|
||||
export const IndexWorld = ({ world, health }: IndexWorldProps) => {
|
||||
const { platform, location } = worlds[String(world.id || "default")];
|
||||
|
||||
const timeSinceLastEvent = humanTimeAgo(
|
||||
new Date().getTime() - new Date(health?.lastEvent || 0).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>
|
||||
{world.name} (total: {world.population.total})
|
||||
</h1>
|
||||
<p>VS: {world.population.vs}</p>
|
||||
<p>NC: {world.population.nc}</p>
|
||||
<p>TR: {world.population.tr}</p>
|
||||
<div className={styles.container}>
|
||||
<Link to={`/worlds/${world.id}`} className={styles.header}>
|
||||
<div className={styles.headerName}>{world.name}</div>
|
||||
<div className={styles.headerMarkers}>
|
||||
[{location}] [{platform}]{" "}
|
||||
<div
|
||||
className={styles.circle}
|
||||
style={{
|
||||
backgroundColor: health?.status === "UP" ? "limegreen" : "red",
|
||||
}}
|
||||
title={`Status: ${health?.status} || Last event: ${timeSinceLastEvent}`}
|
||||
></div>
|
||||
</div>
|
||||
<div className={styles.headerDetailsLink}>DETAILS ⇨</div>
|
||||
</Link>
|
||||
<div className={styles.details}>
|
||||
<div className={styles.population}>
|
||||
<div className={styles.totalPop}>
|
||||
{totalPopulation(world.population)}
|
||||
</div>
|
||||
<div className={styles.popFaction}>
|
||||
<img className={styles.popImage} src={vsLogo} alt="VS" />{" "}
|
||||
{world.population.vs}
|
||||
</div>
|
||||
<div className={styles.popFaction}>
|
||||
<img className={styles.popImage} src={ncLogo} alt="NC" />{" "}
|
||||
{world.population.nc}
|
||||
</div>
|
||||
<div className={styles.popFaction}>
|
||||
<img className={styles.popImage} src={trLogo} alt="TR" />{" "}
|
||||
{world.population.tr}
|
||||
</div>
|
||||
</div>
|
||||
<FactionBar population={world.population} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
BIN
app/images/nc-100.png
Normal file
BIN
app/images/nc-100.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
BIN
app/images/nc.png
Normal file
BIN
app/images/nc.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 796 KiB |
BIN
app/images/tr-100.png
Normal file
BIN
app/images/tr-100.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.3 KiB |
BIN
app/images/tr.png
Normal file
BIN
app/images/tr.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 457 KiB |
BIN
app/images/vs-100.png
Normal file
BIN
app/images/vs-100.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
BIN
app/images/vs.png
Normal file
BIN
app/images/vs.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 MiB |
7
app/root.css.ts
Normal file
7
app/root.css.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { style } from "@vanilla-extract/css";
|
||||
|
||||
export const root = style({
|
||||
fontFamily: "Arial, Helvetica, sans-serif",
|
||||
background: "#101010",
|
||||
color: "#efefef",
|
||||
});
|
|
@ -8,8 +8,13 @@ import {
|
|||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "@remix-run/react";
|
||||
import * as styles from "./root.css";
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://unpkg.com/modern-css-reset@1.4.0/dist/reset.min.css",
|
||||
},
|
||||
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
|
||||
];
|
||||
|
||||
|
@ -22,7 +27,7 @@ export default function App() {
|
|||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
<body className={styles.root}>
|
||||
<Outlet />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { json, type V2_MetaFunction } from "@remix-run/cloudflare";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { IndexWorld } from "~/components/index-world";
|
||||
import type { IndexResponse } from "~/utils/saerro";
|
||||
import { WorldContainer } from "~/components/index-world-container";
|
||||
import { indexQuery } from "~/utils/saerro";
|
||||
|
||||
export const loader = async () => {
|
||||
|
@ -9,18 +9,22 @@ export const loader = async () => {
|
|||
};
|
||||
|
||||
export const meta: V2_MetaFunction = () => {
|
||||
return [{ title: "PS2.LIVE" }];
|
||||
return [
|
||||
{ title: "PS2.LIVE" },
|
||||
{
|
||||
name: "description",
|
||||
content: "PlanetSide 2 Live Population Stats",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
|
||||
<div>
|
||||
<h1>PS2.LIVE</h1>
|
||||
<h2>Worlds</h2>
|
||||
{data.allWorlds.map((world) => (
|
||||
<IndexWorld key={world.id} world={world} />
|
||||
))}
|
||||
<WorldContainer worlds={data.allWorlds} health={data.health} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import type { LoaderArgs, V2_MetaFunction } from "@remix-run/cloudflare";
|
||||
import { json } from "@remix-run/cloudflare";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import type { WorldResponse, Zone } from "~/utils/saerro";
|
||||
import type { Zone } from "~/utils/saerro";
|
||||
import { totalPopulation } from "~/utils/saerro";
|
||||
import { allClasses, allVehicles, worldQuery } from "~/utils/saerro";
|
||||
import { pascalCaseToTitleCase, toTitleCase } from "~/utils/strings";
|
||||
import { worlds } from "~/utils/worlds";
|
||||
|
||||
export const loader = async ({ params }: LoaderArgs) => {
|
||||
return json(await worldQuery(params.id as string));
|
||||
|
@ -12,50 +14,34 @@ export const loader = async ({ params }: LoaderArgs) => {
|
|||
export const meta: V2_MetaFunction<typeof loader> = ({ data }) => {
|
||||
const date = new Date();
|
||||
const id = data?.world.id;
|
||||
const timeZone =
|
||||
id === 1
|
||||
? "America/Los_Angeles"
|
||||
: id === 17 || id === 19 || id === 1000
|
||||
? "America/New_York"
|
||||
: id === 40
|
||||
? "Asia/Tokyo"
|
||||
: "UTC";
|
||||
const datetimeHumanFriendly = date.toLocaleString("en-GB", {
|
||||
timeZone,
|
||||
hour12: true,
|
||||
const worldInfo = worlds[String(id || "default")];
|
||||
const datetimeHumanFriendly = date.toLocaleString(worldInfo.locale, {
|
||||
timeZone: worldInfo.timeZone,
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
return [
|
||||
{ title: `${data?.world.name || "Unknown world"} | PS2.LIVE` },
|
||||
{
|
||||
name: "description",
|
||||
content: `${data?.world.name} currently has ${
|
||||
data?.world.population.total
|
||||
} players online as of ${datetimeHumanFriendly} local server time (<t:${Math.round(
|
||||
date.getTime() / 1000
|
||||
)}:R>). VS: ${data?.world.population.vs}, NC: ${
|
||||
data?.world.population.nc
|
||||
}, TR: ${
|
||||
data?.world.population.tr
|
||||
} -- See more detailed stats on ps2.live.`,
|
||||
title: `${
|
||||
data?.world.name || "Unknown world"
|
||||
} | PlanetSide 2 Live Population Stats`,
|
||||
},
|
||||
{
|
||||
name: "timestamp",
|
||||
content: date.toISOString(),
|
||||
name: "description",
|
||||
content: `${data?.world.name} currently has ${data?.world.population.total} players online as of ${datetimeHumanFriendly} ${data?.world.name} time. VS: ${data?.world.population.vs}, NC: ${data?.world.population.nc}, TR: ${data?.world.population.tr} -- See more detailed stats on ps2.live.`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function World() {
|
||||
const { world } = useLoaderData<WorldResponse>();
|
||||
const { world } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.6" }}>
|
||||
<h1>{world.name}</h1>
|
||||
<h2>Total Population</h2>
|
||||
<p>
|
||||
{world.population.total} players ({world.population.vs} VS,{" "}
|
||||
{totalPopulation(world.population)} players ({world.population.vs} VS,{" "}
|
||||
{world.population.nc} NC, {world.population.tr} TR)
|
||||
</p>
|
||||
<div>
|
||||
|
@ -73,7 +59,7 @@ const ZoneInfo = ({ zone }: { zone: Zone }) => {
|
|||
<section>
|
||||
<h3>{zone.name}</h3>
|
||||
<p>
|
||||
{zone.population.total} players ({zone.population.vs} VS,{" "}
|
||||
{totalPopulation(zone.population)} players ({zone.population.vs} VS,{" "}
|
||||
{zone.population.nc} NC, {zone.population.tr} TR)
|
||||
</p>
|
||||
<p>
|
||||
|
@ -88,13 +74,14 @@ const ZoneInfo = ({ zone }: { zone: Zone }) => {
|
|||
</ul>
|
||||
</p>
|
||||
<p>
|
||||
{zone.vehicles?.total} vehicles...
|
||||
{totalPopulation(zone.vehicles as any)} vehicles...
|
||||
<ul>
|
||||
{allVehicles.map((vehicle, idx) => (
|
||||
<li key={idx}>
|
||||
<b>{toTitleCase(vehicle)}</b>: {zone.vehicles?.[vehicle].total}{" "}
|
||||
total, {zone.vehicles?.[vehicle].vs} VS,{" "}
|
||||
{zone.vehicles?.[vehicle].nc} NC, {zone.vehicles?.[vehicle].tr} TR
|
||||
<b>{toTitleCase(vehicle)}</b>:{" "}
|
||||
{totalPopulation(zone.vehicles?.[vehicle] as any)} total,{" "}
|
||||
{zone.vehicles?.[vehicle].vs} VS, {zone.vehicles?.[vehicle].nc}{" "}
|
||||
NC, {zone.vehicles?.[vehicle].tr} TR
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
@ -44,6 +44,7 @@ export type Health = {
|
|||
worlds: {
|
||||
name: string;
|
||||
status: string;
|
||||
lastEvent: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
|
@ -55,19 +56,16 @@ export type IndexResponse = {
|
|||
export const indexQuery = async (): Promise<IndexResponse> => {
|
||||
const query = `{
|
||||
health {
|
||||
ingestReachable
|
||||
ingest
|
||||
database
|
||||
worlds {
|
||||
name
|
||||
status
|
||||
lastEvent
|
||||
}
|
||||
}
|
||||
allWorlds {
|
||||
id
|
||||
name
|
||||
population {
|
||||
total
|
||||
nc
|
||||
tr
|
||||
vs
|
||||
|
@ -77,7 +75,6 @@ export const indexQuery = async (): Promise<IndexResponse> => {
|
|||
id
|
||||
name
|
||||
population {
|
||||
total
|
||||
nc
|
||||
tr
|
||||
vs
|
||||
|
@ -91,8 +88,6 @@ export const indexQuery = async (): Promise<IndexResponse> => {
|
|||
|
||||
indexData.allWorlds.sort((a, b) => a.id - b.id);
|
||||
|
||||
console.log(indexData);
|
||||
|
||||
return indexData;
|
||||
};
|
||||
|
||||
|
@ -169,3 +164,6 @@ export const worldQuery = async (worldID: string): Promise<WorldResponse> => {
|
|||
|
||||
return worldData;
|
||||
};
|
||||
|
||||
export const totalPopulation = ({ nc, vs, tr }: Population): number =>
|
||||
nc + vs + tr;
|
||||
|
|
|
@ -7,3 +7,24 @@ export const toTitleCase = (str: string) => {
|
|||
export const pascalCaseToTitleCase = (str: string) => {
|
||||
return toTitleCase(str.replace(/([A-Z])/g, " $1"));
|
||||
};
|
||||
|
||||
export const humanTimeAgo = (ms: number) => {
|
||||
const millis = Math.floor(ms % 1000);
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
if (seconds > 0) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
return `${millis}ms`;
|
||||
};
|
||||
|
|
59
app/utils/worlds.ts
Normal file
59
app/utils/worlds.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
export const worlds: Record<
|
||||
string,
|
||||
{ timeZone: string; locale: string; location: string; platform: string }
|
||||
> = {
|
||||
"1": {
|
||||
timeZone: "America/Los_Angeles",
|
||||
locale: "en-US",
|
||||
location: "US-W",
|
||||
platform: "PC",
|
||||
},
|
||||
"10": {
|
||||
timeZone: "UTC",
|
||||
locale: "en-GB",
|
||||
location: "EU",
|
||||
platform: "PC",
|
||||
},
|
||||
"13": {
|
||||
timeZone: "UTC",
|
||||
locale: "en-GB",
|
||||
location: "EU",
|
||||
platform: "PC",
|
||||
},
|
||||
"17": {
|
||||
timeZone: "America/New_York",
|
||||
locale: "en-US",
|
||||
location: "US-E",
|
||||
platform: "PC",
|
||||
},
|
||||
"19": {
|
||||
timeZone: "America/New_York",
|
||||
locale: "en-US",
|
||||
location: "US-E",
|
||||
platform: "PC",
|
||||
},
|
||||
"40": {
|
||||
timeZone: "Asia/Tokyo",
|
||||
locale: "en-GB",
|
||||
location: "JP",
|
||||
platform: "PC",
|
||||
},
|
||||
"1000": {
|
||||
timeZone: "America/New_York",
|
||||
locale: "en-US",
|
||||
location: "US-E",
|
||||
platform: "PS4",
|
||||
},
|
||||
"2000": {
|
||||
timeZone: "UTC",
|
||||
locale: "en-GB",
|
||||
location: "EU",
|
||||
platform: "PS4",
|
||||
},
|
||||
default: {
|
||||
timeZone: "UTC",
|
||||
locale: "en-US",
|
||||
location: "???",
|
||||
platform: "???",
|
||||
},
|
||||
};
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"name": "ps2.live",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
|
|
Loading…
Add table
Reference in a new issue