Small clean up

This commit is contained in:
Kalle 2026-03-10 20:16:57 +02:00
parent 268a7e6aaa
commit bd8a8410a4
8 changed files with 209 additions and 169 deletions

View File

@ -1,6 +1,7 @@
import clsx from "clsx";
import * as React from "react";
import type { Tables } from "~/db/tables";
import { useIsMounted } from "~/hooks/useIsMounted";
import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls";
import styles from "./Avatar.module.css";
@ -17,6 +18,88 @@ const dimensions = {
lg: 125,
} as const;
const identiconCache = new Map<string, string>();
function hashString(str: string) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
}
return hash;
}
function generateColors(hash: number) {
const hue = hash % 360;
const saturation = 65 + ((hash >>> 8) % 20);
const lightness = 50 + ((hash >>> 16) % 20);
return {
background: `hsl(${hue}, ${saturation - 50}%, ${lightness - 40}%)`,
foreground: `hsl(${hue}, ${saturation}%, ${lightness}%)`,
};
}
function generateIdenticon(input: string, size = 128, gridSize = 5) {
const cacheKey = `${input}-${size}-${gridSize}`;
const cached = identiconCache.get(cacheKey);
if (cached) return cached;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
const dpr = window.devicePixelRatio || 1;
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
ctx.scale(dpr, dpr);
ctx.imageSmoothingEnabled = false;
const insetRatio = 1 / Math.sqrt(2);
const cellSize = Math.floor((size * insetRatio) / gridSize);
const actualSize = cellSize * gridSize;
const offset = Math.floor((size - actualSize) / 2);
const halfGrid = Math.ceil(gridSize / 2);
const patternHash = hashString(input);
const colorHash = hashString(input.split("").reverse().join(""));
const colors = generateColors(colorHash);
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = colors.foreground;
const path = new Path2D();
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < halfGrid; col++) {
const bitIndex = row * halfGrid + col;
const shouldFill = (patternHash >>> bitIndex) & 1;
if (shouldFill) {
const x = offset + col * cellSize;
const y = offset + row * cellSize;
path.rect(x, y, cellSize, cellSize);
const mirrorCol = gridSize - 1 - col;
if (col !== mirrorCol) {
const mirrorX = offset + mirrorCol * cellSize;
path.rect(mirrorX, y, cellSize, cellSize);
}
}
}
}
ctx.fill(path);
const dataUrl = canvas.toDataURL();
identiconCache.set(cacheKey, dataUrl);
return dataUrl;
}
export function Avatar({
user,
url,
@ -35,94 +118,12 @@ export function Avatar({
} & React.ButtonHTMLAttributes<HTMLImageElement>) {
const [isErrored, setIsErrored] = React.useState(false);
const [loaded, setLoaded] = React.useState(false);
const [isClient, setIsClient] = React.useState(false);
React.useEffect(() => {
setIsClient(true);
}, []);
function hashString(str: string) {
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) >>> 0;
}
return hash;
}
function generateColors(hash: number) {
const hue = hash % 360;
const saturation = 65 + ((hash >>> 8) % 20);
const lightness = 50 + ((hash >>> 16) % 20);
return {
background: `hsl(${hue}, ${saturation - 50}%, ${lightness - 40}%)`,
foreground: `hsl(${hue}, ${saturation}%, ${lightness}%)`,
};
}
function generateIdenticon(input: string, size = 128, gridSize = 5) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
const dpr = window.devicePixelRatio || 1;
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
ctx.scale(dpr, dpr);
ctx.imageSmoothingEnabled = false;
const insetRatio = 1 / Math.sqrt(2);
const cellSize = Math.floor((size * insetRatio) / gridSize);
const actualSize = cellSize * gridSize;
const offset = Math.floor((size - actualSize) / 2);
const halfGrid = Math.ceil(gridSize / 2);
const patternHash = hashString(input);
const colorHash = hashString(input.split("").reverse().join(""));
const colors = generateColors(colorHash);
ctx.fillStyle = colors.background;
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = colors.foreground;
const path = new Path2D();
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < halfGrid; col++) {
const bitIndex = row * halfGrid + col;
const shouldFill = (patternHash >>> bitIndex) & 1;
if (shouldFill) {
const x = offset + col * cellSize;
const y = offset + row * cellSize;
path.rect(x, y, cellSize, cellSize);
const mirrorCol = gridSize - 1 - col;
if (col !== mirrorCol) {
const mirrorX = offset + mirrorCol * cellSize;
path.rect(mirrorX, y, cellSize, cellSize);
}
}
}
}
ctx.fill(path);
return canvas.toDataURL();
}
const isClient = useIsMounted();
const isIdenticon =
!url && (!user?.discordAvatar || isErrored || identiconInput);
const identiconSource = () => {
if (identiconInput) return identiconInput;
if (user) return user.discordId;
return "unknown";
};
const identiconSource = identiconInput ?? user?.discordId ?? "unknown";
const src = url
? url
@ -133,7 +134,7 @@ export function Avatar({
size: size === "lg" || size === "xmd" ? "lg" : "sm",
})
: isClient
? generateIdenticon(identiconSource(), dimensions[size], 7)
? generateIdenticon(identiconSource, dimensions[size], 7)
: BLANK_IMAGE_URL;
return (

View File

@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import type { RootLoaderData } from "~/root";
import {
EVENTS_PAGE,
@ -50,7 +51,8 @@ export function MobileNav({ sidebarData }: { sidebarData: SidebarData }) {
const hasUnseenNotifications = unseenIds.length > 0;
const hasFriendInSendouQ =
sidebarData?.friends.some((f) => f.subtitle === "SendouQ") ?? false;
sidebarData?.friends.some((f) => f.subtitle === SENDOUQ_ACTIVITY_LABEL) ??
false;
const closePanel = () => setActivePanel("closed");

View File

@ -72,6 +72,71 @@ export function SideNavHeader({
);
}
function ListItemContent({
children,
user,
imageUrl,
overlayIconUrl,
subtitle,
badge,
badgeVariant,
suppressSubtitleHydrationWarning,
}: {
children: React.ReactNode;
user?: Pick<Tables["User"], "discordId" | "discordAvatar">;
imageUrl?: string;
overlayIconUrl?: string;
subtitle?: React.ReactNode;
badge?: React.ReactNode;
badgeVariant?: "default" | "warning";
suppressSubtitleHydrationWarning?: boolean;
}) {
return (
<>
{user ? (
<Avatar user={user} size="xxsm" />
) : imageUrl ? (
<div className={styles.listLinkImageContainer}>
<img src={imageUrl} alt="" className={styles.listLinkImage} />
{overlayIconUrl ? (
<img
src={overlayIconUrl}
alt=""
className={styles.listLinkOverlayIcon}
/>
) : null}
</div>
) : null}
<div className={styles.listLinkContent}>
<span className={styles.listLinkTitle}>{children}</span>
{subtitle || badge ? (
<div className={styles.listLinkSubtitleRow}>
{subtitle ? (
<span
className={styles.listLinkSubtitle}
suppressHydrationWarning={suppressSubtitleHydrationWarning}
>
{subtitle}
</span>
) : null}
{typeof badge === "string" ? (
<span
className={clsx(styles.listLinkBadge, {
[styles.listLinkBadgeWarning]: badgeVariant === "warning",
})}
>
{badge}
</span>
) : (
badge
)}
</div>
) : null}
</div>
</>
);
}
export function ListLink({
children,
to,
@ -102,46 +167,17 @@ export function ListLink({
onClick={onClick}
aria-current={isActive ? "page" : undefined}
>
{user ? (
<Avatar user={user} size="xxsm" />
) : imageUrl ? (
<div className={styles.listLinkImageContainer}>
<img src={imageUrl} alt="" className={styles.listLinkImage} />
{overlayIconUrl ? (
<img
src={overlayIconUrl}
alt=""
className={styles.listLinkOverlayIcon}
/>
) : null}
</div>
) : null}
<div className={styles.listLinkContent}>
<span className={styles.listLinkTitle}>{children}</span>
{subtitle || badge ? (
<div className={styles.listLinkSubtitleRow}>
{subtitle ? (
<span
className={styles.listLinkSubtitle}
suppressHydrationWarning
>
{subtitle}
</span>
) : null}
{typeof badge === "string" ? (
<span
className={clsx(styles.listLinkBadge, {
[styles.listLinkBadgeWarning]: badgeVariant === "warning",
})}
>
{badge}
</span>
) : (
badge
)}
</div>
) : null}
</div>
<ListItemContent
user={user}
imageUrl={imageUrl}
overlayIconUrl={overlayIconUrl}
subtitle={subtitle}
badge={badge}
badgeVariant={badgeVariant}
suppressSubtitleHydrationWarning
>
{children}
</ListItemContent>
</Link>
);
}
@ -161,26 +197,14 @@ export function ListButton({
}) {
return (
<Button className={styles.listButton}>
{user ? <Avatar user={user} size="xxsm" /> : null}
<div className={styles.listLinkContent}>
<span className={styles.listLinkTitle}>{children}</span>
{subtitle || badge ? (
<div className={styles.listLinkSubtitleRow}>
{subtitle ? (
<span className={styles.listLinkSubtitle}>{subtitle}</span>
) : null}
{badge ? (
<span
className={clsx(styles.listLinkBadge, {
[styles.listLinkBadgeWarning]: badgeVariant === "warning",
})}
>
{badge}
</span>
) : null}
</div>
) : null}
</div>
<ListItemContent
user={user}
subtitle={subtitle}
badge={badge}
badgeVariant={badgeVariant}
>
{children}
</ListItemContent>
</Button>
);
}

View File

@ -10,6 +10,7 @@ import {
SendouMenuSection,
} from "~/components/elements/Menu";
import { ListButton } from "~/components/SideNav";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import { databaseTimestampToDate } from "~/utils/dates";
import { SENDOUQ_LOOKING_PAGE, tournamentSubsPage } from "~/utils/urls";
@ -128,7 +129,7 @@ function resolveActivityHref(friend: {
}) {
if (!friend.subtitle) return null;
if (friend.subtitle === "SendouQ") {
if (friend.subtitle === SENDOUQ_ACTIVITY_LABEL) {
return { url: SENDOUQ_LOOKING_PAGE, isSendouQ: true };
}

View File

@ -1,3 +1,5 @@
export const FRIEND = {
MAX_PENDING_REQUESTS: 20,
} as const;
export const SENDOUQ_ACTIVITY_LABEL = "SendouQ";

View File

@ -1,5 +1,6 @@
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
import { SENDOUQ_ACTIVITY_LABEL } from "./friends-constants";
export function resolveFriendActivity(
friendId: number,
@ -9,7 +10,7 @@ export function resolveFriendActivity(
if (ownGroup && ownGroup.members.length < FULL_GROUP_SIZE) {
return {
subtitle: "SendouQ",
subtitle: SENDOUQ_ACTIVITY_LABEL,
badge: `${ownGroup.members.length}/${FULL_GROUP_SIZE}`,
};
}

View File

@ -31,7 +31,14 @@ export function SplatoonRotations() {
const [activeFilter, setActiveFilter] =
React.useState<RotationModeFilter>("ALL");
if (data.rotations.length === 0) return null;
const nowUnixLive = useNowUnix();
const allInThePast = data.rotations.every(
(rotation) => rotation.endTime <= nowUnixLive,
);
if (allInThePast) return null;
if (allInThePast || data.rotations.length === 0) return null;
const nowUnix = databaseTimestampNow();
@ -71,10 +78,6 @@ export function SplatoonRotations() {
rotationsByType.set(rotation.type, existing);
}
const handleFilterChange = (filter: RotationModeFilter) => {
setActiveFilter(filter);
};
const sortedEntries = Array.from(rotationsByType.entries()).sort(
(a, b) => TYPE_ORDER.indexOf(a[0]) - TYPE_ORDER.indexOf(b[0]),
);
@ -90,6 +93,7 @@ export function SplatoonRotations() {
current={current}
next={next}
nextAfter={nextAfter}
now={nowUnixLive}
/>
) : null,
)}
@ -107,7 +111,7 @@ export function SplatoonRotations() {
? styles.rotationsModeFilterButtonActive
: null,
)}
onClick={() => handleFilterChange(filter)}
onClick={() => setActiveFilter(filter)}
>
{filter === "ALL" ? t("front:rotations.filter.all") : filter}
</button>
@ -123,7 +127,7 @@ export function SplatoonRotations() {
);
}
function useTimeRemaining(startTimeUnix: number, endTimeUnix: number) {
function useNowUnix() {
const [now, setNow] = React.useState(() => Math.floor(Date.now() / 1000));
React.useEffect(() => {
@ -133,6 +137,15 @@ function useTimeRemaining(startTimeUnix: number, endTimeUnix: number) {
return () => clearInterval(interval);
}, []);
return now;
}
// xxx: can we use date-fns?
function timeRemaining(
now: number,
startTimeUnix: number,
endTimeUnix: number,
) {
const remaining = endTimeUnix - now;
if (remaining <= 0) return null;
@ -145,16 +158,8 @@ function useTimeRemaining(startTimeUnix: number, endTimeUnix: number) {
return { hours, minutes, progress };
}
function useTimeUntil(startTimeUnix: number) {
const [now, setNow] = React.useState(() => Math.floor(Date.now() / 1000));
React.useEffect(() => {
const interval = setInterval(() => {
setNow(Math.floor(Date.now() / 1000));
}, 60_000);
return () => clearInterval(interval);
}, []);
// xxx: can we use date-fns?
function timeUntil(now: number, startTimeUnix: number) {
const diff = startTimeUnix - now;
if (diff <= 0) return null;
@ -168,20 +173,23 @@ function RotationCard({
current,
next,
nextAfter,
now,
}: {
type: string;
current: RotationFromLoader | undefined;
next: RotationFromLoader | undefined;
nextAfter: RotationFromLoader | undefined;
now: number;
}) {
const { t } = useTranslation(["front", "game-misc"]);
const remaining = useTimeRemaining(
const remaining = timeRemaining(
now,
current?.startTime ?? 0,
current?.endTime ?? 0,
);
const displayRotation = current ?? next;
const nextStartsIn = useTimeUntil(next?.startTime ?? 0);
const nextAfterStartsIn = useTimeUntil(nextAfter?.startTime ?? 0);
const nextStartsIn = timeUntil(now, next?.startTime ?? 0);
const nextAfterStartsIn = timeUntil(now, nextAfter?.startTime ?? 0);
const shownNext = current ? next : nextAfter;
const shownNextStartsIn = current ? nextStartsIn : nextAfterStartsIn;

View File

@ -9,6 +9,7 @@ import {
type SidebarStream,
} from "~/features/core/streams/streams.server";
import * as FriendRepository from "~/features/friends/FriendRepository.server";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import { resolveFriendActivity } from "~/features/friends/friends-utils.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
@ -253,7 +254,7 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
activity.badge ?? "",
);
if (activity.subtitle === "SendouQ") {
if (activity.subtitle === SENDOUQ_ACTIVITY_LABEL) {
sendouqFriends.push(sidebarFriend);
} else {
// this is temporary, will be replaced with "SQified tournament team creator"