Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/frontend/app/api/daily-players/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { CACHE_SECONDS, getDailyPlayers } from '../../../utils/dailyPlayers';

export async function GET(request: NextRequest) {
return NextResponse.json(
await getDailyPlayers(request.nextUrl.searchParams.get('range') ?? '90d'),
await getDailyPlayers(
request.nextUrl.searchParams.get('range') ?? '90d',
request.nextUrl.searchParams.get('source') ?? 'teerank'
),
{
headers: {
'Cache-Control': `public, max-age=${CACHE_SECONDS}, stale-while-revalidate=86400`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default async function Index({
playerCount={gameType.playerCount}
clanCount={gameType.clanCount}
serverCount={gameType.gameServerCount}
showRecords={gameTypeName === 'DDraceNetwork'}
/>

{children}
Expand Down
57 changes: 57 additions & 0 deletions apps/frontend/app/gametype/[gameTypeName]/(lists)/records/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { z } from 'zod';
import { format } from 'date-fns';
import { paramsSchema } from '../../schema';
import { encodeString } from '../../../../../utils/encoding';
import { listRecentRecords } from '../../../../../utils/ddnetMap';
import { RecordList } from '../../../../../components/RecordList';
import { DDNetAttribution } from '../../../../../components/DDNetAttribution';

export async function generateMetadata({
params,
}: {
params: z.infer<typeof paramsSchema>;
}): Promise<Metadata> {
const { gameTypeName } = paramsSchema.parse(params);

return {
title: `Records - ${gameTypeName}`,
description: `Latest map records in ${gameTypeName}`,
alternates: {
canonical: `https://teerank.io/gametype/${encodeString(gameTypeName)}/records`,
},
};
}

export default async function Index({
params,
}: {
params: { [key: string]: string };
}) {
const { gameTypeName } = paramsSchema.parse(params);

if (gameTypeName !== 'DDraceNetwork') {
return notFound();
}

const records = await listRecentRecords();

return (
<>
<RecordList
records={records.map((record) => ({
map: record.map,
players: record.players,
time: record.time,
delta: record.delta,
dateLabel: format(record.date, 'MMM d, yyyy'),
}))}
showMap
showDelta
/>

<DDNetAttribution />
</>
);
}
9 changes: 9 additions & 0 deletions apps/frontend/app/gametype/[gameTypeName]/LayoutTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ export function LayoutTabs({
playerCount,
clanCount,
serverCount,
showRecords,
}: {
gameTypeName: string;
mapName?: string;
playerCount: number;
clanCount: number;
serverCount: number;
showRecords?: boolean;
}) {
const pathname = usePathname();

Expand Down Expand Up @@ -45,6 +47,13 @@ export function LayoutTabs({
isActive={pathname === `${urlPathname}/servers`}
href={{ pathname: `${urlPathname}/servers` }}
/>
{showRecords && (
<Tab
label="Records"
isActive={pathname === `${urlPathname}/records`}
href={{ pathname: `${urlPathname}/records` }}
/>
)}
</Tabs>
);
}
122 changes: 112 additions & 10 deletions apps/frontend/app/gametype/[gameTypeName]/map/[mapName]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,76 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { Fragment } from 'react';
import { format } from 'date-fns';
import { localizeUtcDay } from '@teerank/teerank/date';
import prisma from '../../../../../utils/prisma';
import { LayoutTabs } from '../../LayoutTabs';
import { paramsSchema } from './schema';
import { encodeString } from '../../../../../utils/encoding';
import { ActivityHeader } from '../../../../../components/ActivityHeader';
import { getMapActivity } from '../../../../../utils/activity';
import { getDdnetMapHeader } from '../../../../../utils/ddnetMap';
import { formatInteger } from '../../../../../utils/format';

function MapperLinks({ ddnetMap, mappers }: { ddnetMap: { mapper: string }; mappers: string[] }) {
if (mappers.length === 0) {
return <>{ddnetMap.mapper}</>;
}

return (
<>
{mappers.map((mapper, index) => (
<Fragment key={mapper}>
{index > 0 && ', '}
<Link
prefetch={false}
className="hover:underline"
href={{ pathname: `/mapper/${encodeString(mapper)}` }}
>
{mapper}
</Link>
</Fragment>
))}
</>
);
}

function DdnetMapStats({
ddnetMap,
recordsPath,
}: {
ddnetMap: {
category: string;
points: number;
releasedAt: Date | null;
finishCount: number;
tiles: string[];
};
recordsPath: string;
}) {
const tiles = ddnetMap.tiles.map((tile) => tile.toLowerCase().replace(/_/g, ' '));

return (
<>
<span
className={`px-4 ${tiles.length > 0 ? 'cursor-help' : ''}`}
title={tiles.length > 0 ? tiles.join(', ') : undefined}
>
{ddnetMap.category} · {ddnetMap.points} pts
</span>
{ddnetMap.releasedAt !== null && (
<span className="px-4">
Released {format(localizeUtcDay(ddnetMap.releasedAt), 'MMM yyyy')}
</span>
)}
<span className="px-4">
<Link prefetch={false} className="hover:underline" href={{ pathname: recordsPath }}>
{formatInteger(ddnetMap.finishCount)} finishes
</Link>
</span>
</>
);
}

export default async function Index({
children,
Expand All @@ -29,7 +94,10 @@ export default async function Index({
notFound();
}

const activity = await getMapActivity(map.id, { range: '1y' });
const [activity, ddnet] = await Promise.all([
getMapActivity(map.id, { range: '1y' }),
gameTypeName === 'DDraceNetwork' ? getDdnetMapHeader(map.id) : null,
]);

return (
<div className="flex flex-col gap-4 py-8">
Expand All @@ -39,15 +107,48 @@ export default async function Index({
activity={activity}
contentClassName="flex-col justify-center gap-2"
>
<h1 className="text-2xl font-bold">{mapName}</h1>
<span>
<Link
className="hover:underline"
href={{ pathname: `/gametype/${encodeString(gameTypeName)}` }}
>
{gameTypeName}
</Link>
</span>
<section className="flex flex-row items-center gap-6">
{ddnet !== null && ddnet.hasThumb && (
<img
src={`/map-thumb/${map.id}`}
width={144}
height={90}
alt={`${mapName} thumbnail`}
className="hidden rounded sm:block"
/>
)}
<div className="flex flex-col justify-center gap-2">
<h1 className="flex flex-row flex-wrap items-baseline gap-x-3">
<span className="text-2xl font-bold">{mapName}</span>
{ddnet !== null && ddnet.ddnetMap.stars > 0 && (
<span className="text-[#970]" title={`${ddnet.ddnetMap.stars} stars`}>
{'★'.repeat(ddnet.ddnetMap.stars)}
</span>
)}
{ddnet !== null && (
<span className="text-base font-normal">
by <MapperLinks ddnetMap={ddnet.ddnetMap} mappers={ddnet.mappers} />
</span>
)}
</h1>
<div className="flex flex-row divide-x">
<span className="pr-4">
<Link
className="hover:underline"
href={{ pathname: `/gametype/${encodeString(gameTypeName)}` }}
>
{gameTypeName}
</Link>
</span>
{ddnet !== null && (
<DdnetMapStats
ddnetMap={ddnet.ddnetMap}
recordsPath={`/gametype/${encodeString(gameTypeName)}/map/${encodeString(mapName)}/records`}
/>
)}
</div>
</div>
</section>
</ActivityHeader>
</header>

Expand All @@ -57,6 +158,7 @@ export default async function Index({
playerCount={map.playerCount}
clanCount={map.clanCount}
serverCount={map.gameServerCount}
showRecords={ddnet !== null}
/>

{children}
Expand Down
Loading
Loading