From 0f7518e055ecab19db4e3e7ebe25726945d0b29f Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 27 Jun 2022 09:36:29 +0300 Subject: [PATCH] Badge details page initial --- app/components/layout/Menu.tsx | 2 +- app/db/models/badges.server.ts | 26 ++++++++++- app/routes/badges.tsx | 41 +++++++++++------- app/routes/badges/$id.tsx | 69 ++++++++++++++++++++++++++++-- app/routes/u.$identifier/index.tsx | 8 +--- app/styles/badges.css | 31 ++++++++++++++ app/styles/common.css | 4 ++ app/utils/remix.ts | 7 +++ app/utils/urls.ts | 1 + 9 files changed, 161 insertions(+), 28 deletions(-) diff --git a/app/components/layout/Menu.tsx b/app/components/layout/Menu.tsx index aac8d5f2c..16d248ecd 100644 --- a/app/components/layout/Menu.tsx +++ b/app/components/layout/Menu.tsx @@ -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 ? [ diff --git a/app/db/models/badges.server.ts b/app/db/models/badges.server.ts index 6af34c845..bd32f58e0 100644 --- a/app/db/models/badges.server.ts +++ b/app/db/models/badges.server.ts @@ -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 & { + 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; +} diff --git a/app/routes/badges.tsx b/app/routes/badges.tsx index 45bf078a4..d49f66362 100644 --- a/app/routes/badges.tsx +++ b/app/routes/badges.tsx @@ -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({ badges: db.badges.allWithAtLeastOneOwner() }); + return jsonCached( + { 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(); + const params = useParams(); + + const badgeIdBeingViewed = params["id"] ? Number(params["id"]) : undefined; return (
- {data.badges.map((badge) => ( - - {badge.displayName} - - ))} + {data.badges + .filter((b) => !badgeIdBeingViewed || b.id !== badgeIdBeingViewed) + .map((badge) => ( + + {badge.displayName} + + ))}
diff --git a/app/routes/badges/$id.tsx b/app/routes/badges/$id.tsx index a3bf86ea5..11034ee94 100644 --- a/app/routes/badges/$id.tsx +++ b/app/routes/badges/$id.tsx @@ -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( + { 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(); + + const badge = badges.find((b) => b.id === Number(params["id"])); + if (!badge) return ; + + return ( +
+ {badge.displayName} +
{badgeExplanationText(badge)}
+
    + {data.owners.map((owner) => ( +
  • + + ×{owner.count} + + {discordFullName(owner)} +
  • + ))} +
+
+ ); +} + +export function badgeExplanationText( + badge: Pick & { count?: number } +) { + const countString = + badge.count && badge.count > 1 ? ` (x${badge.count})` : ""; + return `Awarded for winning ${badge.displayName}${countString}`; } diff --git a/app/routes/u.$identifier/index.tsx b/app/routes/u.$identifier/index.tsx index a5cf425dc..0d44567b2 100644 --- a/app/routes/u.$identifier/index.tsx +++ b/app/routes/u.$identifier/index.tsx @@ -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 ? ( -
x{badge.count}
+
×{badge.count}
) : null} ))} @@ -168,8 +169,3 @@ function BadgeContainer(props: { badges: UserPageLoaderData["badges"] }) { ); } - -function badgeExplanationText(badge: Unpacked) { - const countString = badge.count > 1 ? ` (x${badge.count})` : ""; - return `Awarded for winning ${badge.displayName}${countString}`; -} diff --git a/app/styles/badges.css b/app/styles/badges.css index cb415bc25..1e6e6a0aa 100644 --- a/app/styles/badges.css +++ b/app/styles/badges.css @@ -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); +} diff --git a/app/styles/common.css b/app/styles/common.css index 2cd46f9f1..7bffcd4a6 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -218,6 +218,10 @@ display: none; } +.invisible { + visibility: hidden; +} + .flex { display: flex; } diff --git a/app/utils/remix.ts b/app/utils/remix.ts index 27c239956..cabf5acbb 100644 --- a/app/utils/remix.ts +++ b/app/utils/remix.ts @@ -1,3 +1,4 @@ +import { json } from "@remix-run/node"; import { z } from "zod"; export function notFoundIfFalsy(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(data: T, cachedForInMinutes: number) { + return json(data, { + headers: { "Cache-Control": `max-age=${cachedForInMinutes * 60}` }, + }); +} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index c96ae6105..bf3083f27 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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}`;