mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-05 20:56:13 -05:00
Small clean up
This commit is contained in:
parent
268a7e6aaa
commit
bd8a8410a4
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
export const FRIEND = {
|
||||
MAX_PENDING_REQUESTS: 20,
|
||||
} as const;
|
||||
|
||||
export const SENDOUQ_ACTIVITY_LABEL = "SendouQ";
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user