mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 19:55:46 -05:00
Badge details page initial
This commit is contained in:
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -218,6 +218,10 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -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}` },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user