initial index

This commit is contained in:
41666 2023-05-22 21:04:29 -04:00
parent 62cc828d6a
commit 88015a98cd
21 changed files with 343 additions and 56 deletions

2
.gitignore vendored
View file

@ -7,4 +7,4 @@ node_modules
.env
.DS_Store
*/.DS_Store
**/.DS_Store

View 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",
});

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

View file

@ -0,0 +1,8 @@
import { style } from "@vanilla-extract/css";
export const container = style({
display: "flex",
flexBasis: "100%",
flexWrap: "wrap",
justifyContent: "center",
});

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

View 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",
});

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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
View file

@ -0,0 +1,7 @@
import { style } from "@vanilla-extract/css";
export const root = style({
fontFamily: "Arial, Helvetica, sans-serif",
background: "#101010",
color: "#efefef",
});

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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: "???",
},
};

View file

@ -1,4 +1,5 @@
{
"name": "ps2.live",
"private": true,
"sideEffects": false,
"scripts": {