Badge details page initial

This commit is contained in:
Kalle
2022-06-27 09:36:29 +03:00
parent 3fd8e4338f
commit 0f7518e055
9 changed files with 161 additions and 28 deletions

View File

@@ -123,7 +123,7 @@ export function navItemsGrouped(isPlusMember: boolean): {
{
title: "misc",
items: [
{ name: "badges", disabled: true },
{ name: "badges", disabled: false },
{ name: "links", disabled: true },
...(isPlusMember
? [

View File

@@ -19,7 +19,7 @@ export function countsByUserId(userId: User["id"]) {
return countsByUserIdStm.all({ userId }) as CountsByUserId;
}
// xxx: but how does admin give to rights to badge if those with 0 owners don't show?
// xxx: make it so that it returns all badges not just owned
const allWithAtLeastOneOwnerStm = sql.prepare(`
select "Badge"."id", "Badge"."code", "Badge"."displayName"
from "Badge"
@@ -35,3 +35,27 @@ export type AllWithAtLeastOneOwner = Array<
export function allWithAtLeastOneOwner() {
return allWithAtLeastOneOwnerStm.all() as AllWithAtLeastOneOwner;
}
export type OwnersByBadge = Array<
Pick<User, "id" | "discordId" | "discordName" | "discordDiscriminator"> & {
count: number;
}
>;
const ownersByBadgeIdStm = sql.prepare(`
select
count("BadgeOwner"."badgeId") as count,
"User"."id",
"User"."discordId",
"User"."discordName",
"User"."discordDiscriminator"
from "BadgeOwner"
join "User" on "User"."id" = "BadgeOwner"."userId"
where "BadgeOwner"."badgeId" = $id
group by "User"."id"
order by count desc
`);
export function ownersByBadgeId(id: Badge["id"]) {
return ownersByBadgeIdStm.all({ id }) as OwnersByBadge;
}

View File

@@ -1,43 +1,52 @@
import type { LinksFunction, LoaderFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Link, Outlet, useLoaderData } from "@remix-run/react";
import { Link, Outlet, useLoaderData, useParams } from "@remix-run/react";
import { Main } from "~/components/Main";
import { db } from "~/db";
import type { AllWithAtLeastOneOwner } from "~/db/models/badges.server";
import styles from "~/styles/badges.css";
import { jsonCached } from "~/utils/remix";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
interface BadgesLoaderData {
export interface BadgesLoaderData {
badges: AllWithAtLeastOneOwner;
}
export const loader: LoaderFunction = () => {
// xxx: add cache
return json<BadgesLoaderData>({ badges: db.badges.allWithAtLeastOneOwner() });
return jsonCached<BadgesLoaderData>(
{ badges: db.badges.allWithAtLeastOneOwner() },
120
);
};
// xxx: https://web.dev/replace-gifs-with-videos/ check possibility to replace gif
export default function BadgesPageLayout() {
const data = useLoaderData<BadgesLoaderData>();
const params = useParams();
const badgeIdBeingViewed = params["id"] ? Number(params["id"]) : undefined;
return (
<Main>
<div className="badges__container">
<Outlet />
<div className="badges__small-badges">
{data.badges.map((badge) => (
<Link key={badge.id} to={String(badge.id)}>
<img
src={`/gif/badges/${badge.code}.gif`}
alt={badge.displayName}
title={badge.displayName}
width="64"
height="64"
/>
</Link>
))}
{data.badges
.filter((b) => !badgeIdBeingViewed || b.id !== badgeIdBeingViewed)
.map((badge) => (
<Link key={badge.id} to={String(badge.id)}>
<img
// xxx: from constants
src={`/gif/badges/${badge.code}.gif`}
alt={badge.displayName}
title={badge.displayName}
width="64"
height="64"
/>
</Link>
))}
</div>
</div>
</Main>

View File

@@ -1,9 +1,70 @@
import type { LoaderFunction } from "@remix-run/node";
import { useLoaderData, useMatches, useParams } from "@remix-run/react";
import clsx from "clsx";
import { Redirect } from "~/components/Redirect";
import { db } from "~/db";
import type { OwnersByBadge } from "~/db/models/badges.server";
import type { Badge } from "~/db/types";
import { jsonCached } from "~/utils/remix";
import { discordFullName } from "~/utils/strings";
import { BADGES_PAGE } from "~/utils/urls";
import type { BadgesLoaderData } from "../badges";
export const loader: LoaderFunction = () => {
return null;
export interface BadgeDetailsLoaderData {
owners: OwnersByBadge;
}
export const loader: LoaderFunction = ({ params }) => {
const badgeId = Number(params["id"]);
if (Number.isNaN(badgeId)) {
throw new Response(null, { status: 404 });
}
return jsonCached<BadgeDetailsLoaderData>(
{ owners: db.badges.ownersByBadgeId(badgeId) },
120
);
};
export default function BadgesPage() {
return <>names here or something...</>;
export default function BadgeDetailsPage() {
const [, parentRoute] = useMatches();
const { badges } = parentRoute!.data as BadgesLoaderData;
const params = useParams();
const data = useLoaderData<BadgeDetailsLoaderData>();
const badge = badges.find((b) => b.id === Number(params["id"]));
if (!badge) return <Redirect to={BADGES_PAGE} />;
return (
<div className="stack md items-center">
<img
src={`/gif/badges/${badge.code}.gif`}
alt={badge.displayName}
title={badge.displayName}
width="200"
height="200"
/>
<div className="badges__explanation">{badgeExplanationText(badge)}</div>
<ul className="badges__owners">
{data.owners.map((owner) => (
<li key={owner.id}>
<span
className={clsx("badges__count", { invisible: owner.count <= 1 })}
>
×{owner.count}
</span>
<span>{discordFullName(owner)}</span>
</li>
))}
</ul>
</div>
);
}
export function badgeExplanationText(
badge: Pick<Badge, "displayName" | "code"> & { count?: number }
) {
const countString =
badge.count && badge.count > 1 ? ` (x${badge.count})` : "";
return `Awarded for winning ${badge.displayName}${countString}`;
}

View File

@@ -11,6 +11,7 @@ import { assertUnreachable } from "~/utils/types";
import { TwitchIcon } from "~/components/icons/Twitch";
import { TwitterIcon } from "~/components/icons/Twitter";
import { YouTubeIcon } from "~/components/icons/YouTube";
import { badgeExplanationText } from "../badges/$id";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
@@ -155,7 +156,7 @@ function BadgeContainer(props: { badges: UserPageLoaderData["badges"] }) {
height="48"
/>
{badge.count > 1 ? (
<div className="u__small-badge-count">x{badge.count}</div>
<div className="u__small-badge-count">×{badge.count}</div>
) : null}
</div>
))}
@@ -168,8 +169,3 @@ function BadgeContainer(props: { badges: UserPageLoaderData["badges"] }) {
</div>
);
}
function badgeExplanationText(badge: Unpacked<UserPageLoaderData["badges"]>) {
const countString = badge.count > 1 ? ` (x${badge.count})` : "";
return `Awarded for winning ${badge.displayName}${countString}`;
}

View File

@@ -1,8 +1,11 @@
.badges__container {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--s-2);
background-color: var(--bg-badge);
border-radius: var(--rounded);
gap: var(--s-6);
}
.badges__small-badges {
@@ -11,3 +14,31 @@
justify-content: center;
gap: var(--s-2);
}
.badges__explanation {
color: var(--theme);
font-weight: var(--semi-bold);
text-align: center;
}
.badges__owners {
display: flex;
flex-wrap: wrap;
justify-content: center;
padding: 0;
font-size: var(--fonts-sm);
gap: var(--s-2);
}
.badges__owners > li {
display: flex;
flex-direction: column;
align-items: center;
line-height: 1.1;
list-style: none;
}
.badges__count {
color: var(--theme-vibrant);
font-size: var(--fonts-xs);
}

View File

@@ -218,6 +218,10 @@
display: none;
}
.invisible {
visibility: hidden;
}
.flex {
display: flex;
}

View File

@@ -1,3 +1,4 @@
import { json } from "@remix-run/node";
import { z } from "zod";
export function notFoundIfFalsy<T>(value: T | null | undefined): T {
@@ -49,3 +50,9 @@ export function validate(condition: any): asserts condition {
export function makeTitle(title: string) {
return `${title} | sendou.ink`;
}
export function jsonCached<T>(data: T, cachedForInMinutes: number) {
return json(data, {
headers: { "Cache-Control": `max-age=${cachedForInMinutes * 60}` },
});
}

View File

@@ -6,6 +6,7 @@ export const LOG_IN_URL = "/auth";
export const LOG_OUT_URL = "/auth/logout";
export const PLUS_SUGGESTIONS_PAGE = "/plus/suggestions";
export const ADMIN_PAGE = "/admin";
export const BADGES_PAGE = "/badges";
export const STOP_IMPERSONATING_URL = "/auth/impersonate/stop";
export const userPage = (discordId: string) => `/u/${discordId}`;