mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-14 23:26:15 -05:00
New widgets
This commit is contained in:
@@ -48,9 +48,10 @@ export async function seedMisc({
|
||||
await seedNotifications(users, tournaments);
|
||||
await seedUserReports(users, sendouq);
|
||||
|
||||
await LiveStreamFactory.replaceAll(
|
||||
users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })),
|
||||
);
|
||||
await LiveStreamFactory.replaceAll([
|
||||
{ userId: users.nzapId, twitch: "nzap_stream" },
|
||||
...users.showcaseIds.slice(0, STREAM_COUNT).map((userId) => ({ userId })),
|
||||
]);
|
||||
await SplatoonRotationFactory.replaceAll();
|
||||
|
||||
return { adminFriendIds: adminFriendIds(users) };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { addDays } from "date-fns";
|
||||
import type { UserMapModePreferences } from "~/db/tables-json";
|
||||
import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants";
|
||||
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
|
||||
@@ -21,6 +22,8 @@ import * as showcaseNames from "../core/showcaseNames";
|
||||
import * as UserFactory from "../factories/UserFactory";
|
||||
|
||||
const SHOWCASE_COUNT = 100;
|
||||
/** Latest finished LUTI the seeded divisions are from. */
|
||||
const LUTI_SEASON = 17;
|
||||
const CROWD_COUNT = 396;
|
||||
|
||||
export type SeededUsers = {
|
||||
@@ -83,6 +86,8 @@ export async function seedUsers(): Promise<SeededUsers> {
|
||||
{
|
||||
patronTier: 2,
|
||||
roles: ["VIDEO_ADDER", "TOURNAMENT_ORGANIZER", "ARTIST"],
|
||||
div: "2",
|
||||
divSeason: LUTI_SEASON,
|
||||
matchProfile: {
|
||||
mapModePreferences: fakePreferences(),
|
||||
vc: "YES",
|
||||
@@ -246,20 +251,30 @@ export function nzapWidgets(): StoredWidget[] {
|
||||
return [
|
||||
{ id: "bio-md", settings: { bio: showcaseNames.maxLengthBio() } },
|
||||
{ id: "teams" },
|
||||
{ id: "organizations" },
|
||||
{ id: "patron-since" },
|
||||
{ id: "live-stream" },
|
||||
{ id: "luti-div" },
|
||||
{
|
||||
id: "countdown",
|
||||
settings: { title: "Next LAN", date: addDays(new Date(), 42) },
|
||||
},
|
||||
{
|
||||
id: "markdown",
|
||||
settings: {
|
||||
content:
|
||||
"## Looking for\n\n- **Scrims** on weekdays\n- A *support* player\n\nDM me on Discord!",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sens",
|
||||
settings: { controller: "s2-pro-con", motionSens: 50, stickSens: 5 },
|
||||
},
|
||||
{ id: "timezone", settings: { timezone: "Europe/Stockholm" } },
|
||||
{ id: "social-links" },
|
||||
{ id: "weapon-pool" },
|
||||
{ id: "map-mode-preferences" },
|
||||
{ id: "badges-owned", settings: { favoriteBadgeIds: [] } },
|
||||
{ id: "trophies-owned" },
|
||||
{ id: "art", settings: { source: "ALL" } },
|
||||
{ id: "x-rank-peaks", settings: { division: "both" } },
|
||||
{ id: "highlighted-results" },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -283,6 +298,7 @@ function showcaseOptions(): Parameters<typeof UserFactory.create>[1] {
|
||||
faker.number.float(1) < 0.6
|
||||
? faker.helpers.arrayElement(LUTI_DIVS)
|
||||
: undefined,
|
||||
divSeason: LUTI_SEASON,
|
||||
card: {
|
||||
shortBio: faker.number.float(1) < 0.6 ? faker.lorem.sentence() : null,
|
||||
bannerPresetImg: fakeBannerPresetImg(),
|
||||
|
||||
@@ -45,6 +45,8 @@ type Options = {
|
||||
ban?: Omit<Parameters<typeof AdminRepository.banUser>[0], "userId">;
|
||||
/** Division the user played their last season in. */
|
||||
div?: NonNullable<Tables["User"]["div"]>;
|
||||
/** LUTI season `div` is from, only applied together with it. */
|
||||
divSeason?: number;
|
||||
/** User card fields, submitted as the user themselves. */
|
||||
card?: Partial<CardArgs>;
|
||||
/** Replaces the user's widgets, i.e. their profile layout, in place of the default one. */
|
||||
@@ -320,6 +322,7 @@ export async function grant(
|
||||
matchProfile,
|
||||
ban,
|
||||
div,
|
||||
divSeason,
|
||||
card,
|
||||
widgets,
|
||||
preferences,
|
||||
@@ -363,7 +366,9 @@ export async function grant(
|
||||
}
|
||||
|
||||
if (div) {
|
||||
await UserRepository.updateManyDivs([{ userId, div }]);
|
||||
await UserRepository.updateManyDivs([
|
||||
{ userId, div, divSeason: divSeason ?? null },
|
||||
]);
|
||||
}
|
||||
|
||||
if (widgets) {
|
||||
|
||||
@@ -999,6 +999,8 @@ export interface User {
|
||||
hiddenCardStats: JSONColumnTypeNullable<Array<HideableUserCardStat>>;
|
||||
/** Div in the latest finished LUTI (e.g. "2" or "X"). Must have been in a team that did not drop and the user played at least one match (got result as well) */
|
||||
div: string | null;
|
||||
/** LUTI season `div` was earned in. */
|
||||
divSeason: number | null;
|
||||
/** Peak XP as indicated by the user. Should have either `takoroka` or `tentatek` key defined but not both. */
|
||||
unverifiedPeakXP: JSONColumnTypeNullable<PeakXP>;
|
||||
/** Division the user card's XP is taken from. `null` when the user has not picked one, showing their highest XP across both. */
|
||||
|
||||
@@ -25,6 +25,21 @@ export function insertTournamentStreamers(
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** The user's ongoing Splatoon 3 Twitch stream, `undefined` when they are not live. */
|
||||
export function findByUserId(userId: number) {
|
||||
return db
|
||||
.selectFrom("LiveStream")
|
||||
.select([
|
||||
"LiveStream.twitch",
|
||||
"LiveStream.viewerCount",
|
||||
"LiveStream.thumbnailUrl",
|
||||
])
|
||||
.where("LiveStream.userId", "=", userId)
|
||||
.where("LiveStream.twitch", "is not", null)
|
||||
.$narrowType<{ twitch: string }>()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
export function findXRankStreams() {
|
||||
return db
|
||||
.selectFrom("LiveStream")
|
||||
|
||||
@@ -23,6 +23,15 @@ export function findSettingsByUserId(userId: number) {
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
export function findMapModePreferencesByUserId(userId: number) {
|
||||
return db
|
||||
.selectFrom("User")
|
||||
.select("User.mapModePreferences")
|
||||
.where("User.id", "=", userId)
|
||||
.executeTakeFirstOrThrow()
|
||||
.then((row) => row.mapModePreferences);
|
||||
}
|
||||
|
||||
/** Match profile weapon pool of one user, with ten-star status. */
|
||||
export function findWeaponPoolByUserId(userId: number) {
|
||||
return db
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
formatFlexTimeDisplay,
|
||||
generateTimeOptions,
|
||||
parseLutiDivFromName,
|
||||
parseLutiSeasonFromName,
|
||||
parseMapPoolInput,
|
||||
postSpan,
|
||||
requestStarts,
|
||||
@@ -23,6 +24,18 @@ describe("parseLutiDivFromName", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLutiSeasonFromName", () => {
|
||||
test.each([
|
||||
["LUTI: Season 15 - Division 2", 15],
|
||||
["LUTI Season 17", 17],
|
||||
["LUTI season 9 Div X", 9],
|
||||
["LUTI Division 2", null],
|
||||
["Seasonal Cup 3", null],
|
||||
])("parses %s as %s", (name, expected) => {
|
||||
expect(parseLutiSeasonFromName(name)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateTimeOptions", () => {
|
||||
test("includes both start and end times", () => {
|
||||
const start = new Date("2025-01-15T14:15:00");
|
||||
|
||||
@@ -70,6 +70,14 @@ export const parseLutiDivFromName = (name: string): LutiDiv | null => {
|
||||
: null;
|
||||
};
|
||||
|
||||
/** The season number of a LUTI name like "LUTI: Season 15 - Division 2", or `null`. */
|
||||
export const parseLutiSeasonFromName = (name: string): number | null => {
|
||||
const match = name.match(/\bseason\s*(\d+)\b/i);
|
||||
if (!match) return null;
|
||||
|
||||
return Number(match[1]);
|
||||
};
|
||||
|
||||
export const serializeLutiDiv = (div: LutiDiv): number => {
|
||||
if (div === "X") return 0;
|
||||
|
||||
|
||||
@@ -712,7 +712,7 @@ export async function findLatestFinalizedLeagueParticipants(args: {
|
||||
"CalendarEvent.id",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.select(["Tournament.id", "Tournament.settings"])
|
||||
.select(["Tournament.id", "Tournament.settings", "CalendarEvent.name"])
|
||||
.where("CalendarEvent.organizationId", "=", args.organizationId)
|
||||
.where("CalendarEvent.name", "like", `${args.namePrefix}%`)
|
||||
.where("Tournament.isFinalized", "=", 1)
|
||||
@@ -742,6 +742,7 @@ export async function findLatestFinalizedLeagueParticipants(args: {
|
||||
|
||||
return {
|
||||
tournamentId: league.id,
|
||||
name: league.name,
|
||||
bracketProgression: league.settings.bracketProgression,
|
||||
participants,
|
||||
};
|
||||
|
||||
@@ -1120,6 +1120,19 @@ export async function findPatronStartedAtByUserId(userId: number) {
|
||||
)?.patronStartedAt;
|
||||
}
|
||||
|
||||
/** Division and season of the last LUTI the user placed in, `null` when they never have. */
|
||||
export async function findDivByUserId(userId: number) {
|
||||
const row = await db
|
||||
.selectFrom("User")
|
||||
.select(["User.div", "User.divSeason"])
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row?.div) return null;
|
||||
|
||||
return { div: row.div, divSeason: row.divSeason };
|
||||
}
|
||||
|
||||
export async function findJoinOrderByUserId(userId: number) {
|
||||
const row = await db
|
||||
.selectFrom("User")
|
||||
@@ -1251,15 +1264,15 @@ export function updateOwnProfile(args: UpdateProfileArgs) {
|
||||
|
||||
/** Bulk-sets each user's latest LUTI division. Used by the `ComputeLutiDivs` routine. */
|
||||
export function updateManyDivs(
|
||||
updates: Array<{ userId: number; div: string }>,
|
||||
updates: Array<{ userId: number; div: string; divSeason: number | null }>,
|
||||
) {
|
||||
if (updates.length === 0) return;
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
for (const { userId, div } of updates) {
|
||||
for (const { userId, div, divSeason } of updates) {
|
||||
await trx
|
||||
.updateTable("User")
|
||||
.set({ div })
|
||||
.set({ div, divSeason })
|
||||
.where("id", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -348,3 +348,82 @@
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
}
|
||||
|
||||
.mapModePreferences {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.mapModePreferenceStages {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
.liveStream {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.liveStreamThumbnail {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.liveStreamInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
/* same look as the sidebar's live badge */
|
||||
.liveBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--selector-size-xs);
|
||||
padding: 0 var(--s-1);
|
||||
border-radius: var(--radius-selector);
|
||||
background-color: var(--color-text-second);
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.liveStreamName {
|
||||
margin-inline-start: auto;
|
||||
color: var(--color-text-high);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.countdownTitle {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--s-2) var(--s-3);
|
||||
}
|
||||
|
||||
.countdownPart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
min-width: 3ch;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Link2 as LinkIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { BuildCard } from "~/components/BuildCard";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { Image, StageImage, WeaponImage } from "~/components/Image";
|
||||
import { Image, ModeImage, StageImage, WeaponImage } from "~/components/Image";
|
||||
import { BskyIcon } from "~/components/icons/Bsky";
|
||||
import { DiscordIcon } from "~/components/icons/Discord";
|
||||
import { TwitchIcon } from "~/components/icons/Twitch";
|
||||
@@ -36,6 +37,7 @@ import type {
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { rawSensToString } from "~/utils/strings";
|
||||
@@ -50,10 +52,12 @@ import {
|
||||
modeImageUrl,
|
||||
navIconUrl,
|
||||
teamPage,
|
||||
twitchUrl,
|
||||
userBuildsPage,
|
||||
userResultsPage,
|
||||
userVodsPage,
|
||||
} from "~/utils/urls";
|
||||
import * as Countdown from "../core/widgets/countdown";
|
||||
import type { LoadedWidget } from "../core/widgets/types";
|
||||
import styles from "./Widget.module.css";
|
||||
|
||||
@@ -279,6 +283,37 @@ export function Widget({
|
||||
return widget.data.length === 0 ? null : (
|
||||
<FriendsWidget friends={widget.data} />
|
||||
);
|
||||
case "luti-div":
|
||||
if (!widget.data) return null;
|
||||
return (
|
||||
<BigValue
|
||||
value={`Div ${widget.data.div}`}
|
||||
footer={
|
||||
widget.data.divSeason
|
||||
? t("user:widget.luti-div.season", {
|
||||
season: widget.data.divSeason,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "map-mode-preferences":
|
||||
return widget.data.length === 0 ? null : (
|
||||
<MapModePreferencesWidget rows={widget.data} />
|
||||
);
|
||||
case "live-stream":
|
||||
if (!widget.data) return null;
|
||||
return <LiveStreamWidget stream={widget.data} />;
|
||||
case "countdown":
|
||||
return (
|
||||
<CountdownWidget title={widget.data.title} date={widget.data.date} />
|
||||
);
|
||||
case "markdown":
|
||||
return widget.data.content ? (
|
||||
<article>
|
||||
<Markdown>{widget.data.content}</Markdown>
|
||||
</article>
|
||||
) : null;
|
||||
default:
|
||||
assertUnreachable(widget);
|
||||
}
|
||||
@@ -976,3 +1011,110 @@ function GameBadgesDisplay({ badgeIds }: { badgeIds: string[] }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapModePreferencesWidget({
|
||||
rows,
|
||||
}: {
|
||||
rows: Extract<LoadedWidget, { id: "map-mode-preferences" }>["data"];
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.mapModePreferences}>
|
||||
{rows.map((row) => (
|
||||
<div key={row.mode} className="stack sm">
|
||||
<Divider>
|
||||
<ModeImage mode={row.mode} size={32} />
|
||||
</Divider>
|
||||
<div className={styles.mapModePreferenceStages}>
|
||||
{row.stages.map((stageId) => (
|
||||
<StageImage
|
||||
key={stageId}
|
||||
stageId={stageId}
|
||||
width={80}
|
||||
className="rounded-sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveStreamWidget({
|
||||
stream,
|
||||
}: {
|
||||
stream: NonNullable<Extract<LoadedWidget, { id: "live-stream" }>["data"]>;
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={twitchUrl(stream.twitch)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={styles.liveStream}
|
||||
>
|
||||
<img
|
||||
src={twitchThumbnailUrlToSrc(stream.thumbnailUrl)}
|
||||
alt=""
|
||||
className={styles.liveStreamThumbnail}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className={styles.liveStreamInfo}>
|
||||
<span className={styles.liveBadge}>
|
||||
{t("user:widget.live-stream.live")}
|
||||
</span>
|
||||
<span>
|
||||
{t("user:widget.live-stream.viewers", {
|
||||
count: stream.viewerCount,
|
||||
})}
|
||||
</span>
|
||||
<span className={styles.liveStreamName}>{stream.twitch}</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const COUNTDOWN_UNITS_FAR = ["days", "hours", "minutes"] as const;
|
||||
const COUNTDOWN_UNITS_NEAR = ["hours", "minutes", "seconds"] as const;
|
||||
|
||||
/** Comes out of the JSON column as an ISO string even though the settings type says `Date`. */
|
||||
function CountdownWidget({
|
||||
title,
|
||||
date,
|
||||
}: {
|
||||
title: string;
|
||||
date: Date | string;
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const now = useAutoRerender("second");
|
||||
const target = new Date(date);
|
||||
const remaining = Countdown.remainingUntil(now, target);
|
||||
|
||||
return (
|
||||
<div className="stack sm items-center">
|
||||
{title ? <div className={styles.countdownTitle}>{title}</div> : null}
|
||||
{remaining ? (
|
||||
<div className={styles.countdown}>
|
||||
{(remaining.days > 0
|
||||
? COUNTDOWN_UNITS_FAR
|
||||
: COUNTDOWN_UNITS_NEAR
|
||||
).map((unit) => (
|
||||
<div key={unit} className={styles.countdownPart}>
|
||||
<div className={styles.widgetValueMain}>{remaining[unit]}</div>
|
||||
<div className={styles.widgetValueFooter}>
|
||||
{t(`user:widget.countdown.${unit}` as const)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<LocaleTime
|
||||
date={target}
|
||||
options={{ day: "numeric", month: "numeric", year: "numeric" }}
|
||||
className={styles.widgetValueMain}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,15 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) {
|
||||
return <FormField name="links" />;
|
||||
case "tier-list":
|
||||
return <FormField name="searchParams" />;
|
||||
case "countdown":
|
||||
return (
|
||||
<div className="stack md">
|
||||
<FormField name="title" />
|
||||
<FormField name="date" />
|
||||
</div>
|
||||
);
|
||||
case "markdown":
|
||||
return <FormField name="content" />;
|
||||
case "badges-owned":
|
||||
return <FavoriteBadgesField />;
|
||||
case "game-badges":
|
||||
|
||||
38
app/features/user-page/core/widgets/countdown.test.ts
Normal file
38
app/features/user-page/core/widgets/countdown.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import * as Countdown from "./countdown";
|
||||
|
||||
const NOW = new Date("2026-09-05T12:00:00Z");
|
||||
|
||||
describe("Countdown.remainingUntil", () => {
|
||||
test.each([
|
||||
{
|
||||
why: "one full day",
|
||||
target: "2026-09-06T12:00:00Z",
|
||||
expected: { days: 1, hours: 0, minutes: 0, seconds: 0 },
|
||||
},
|
||||
{
|
||||
why: "mixed units",
|
||||
target: "2026-09-07T14:30:15Z",
|
||||
expected: { days: 2, hours: 2, minutes: 30, seconds: 15 },
|
||||
},
|
||||
{
|
||||
why: "under a minute",
|
||||
target: "2026-09-05T12:00:45Z",
|
||||
expected: { days: 0, hours: 0, minutes: 0, seconds: 45 },
|
||||
},
|
||||
{
|
||||
why: "more than a year stays in days",
|
||||
target: "2027-09-05T12:00:00Z",
|
||||
expected: { days: 365, hours: 0, minutes: 0, seconds: 0 },
|
||||
},
|
||||
])("splits the remaining time ($why)", ({ target, expected }) => {
|
||||
expect(Countdown.remainingUntil(NOW, new Date(target))).toEqual(expected);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ why: "same instant", target: "2026-09-05T12:00:00Z" },
|
||||
{ why: "in the past", target: "2026-09-01T00:00:00Z" },
|
||||
])("returns null once the target has passed ($why)", ({ target }) => {
|
||||
expect(Countdown.remainingUntil(NOW, new Date(target))).toBeNull();
|
||||
});
|
||||
});
|
||||
25
app/features/user-page/core/widgets/countdown.ts
Normal file
25
app/features/user-page/core/widgets/countdown.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { differenceInSeconds } from "date-fns";
|
||||
|
||||
const SECONDS_IN_MINUTE = 60;
|
||||
const SECONDS_IN_HOUR = 60 * SECONDS_IN_MINUTE;
|
||||
const SECONDS_IN_DAY = 24 * SECONDS_IN_HOUR;
|
||||
|
||||
export interface CountdownParts {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
/** Time left from `now` until `target` split into whole days, hours, minutes and seconds. `null` once the target has passed. */
|
||||
export function remainingUntil(now: Date, target: Date): CountdownParts | null {
|
||||
const totalSeconds = differenceInSeconds(target, now);
|
||||
if (totalSeconds <= 0) return null;
|
||||
|
||||
return {
|
||||
days: Math.floor(totalSeconds / SECONDS_IN_DAY),
|
||||
hours: Math.floor((totalSeconds % SECONDS_IN_DAY) / SECONDS_IN_HOUR),
|
||||
minutes: Math.floor((totalSeconds % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE),
|
||||
seconds: totalSeconds % SECONDS_IN_MINUTE,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import * as FriendRepository from "~/features/friends/FriendRepository.server";
|
||||
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
|
||||
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
|
||||
import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
|
||||
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
|
||||
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
|
||||
import { ordinalToSp } from "~/features/mmr/mmr-utils";
|
||||
import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
|
||||
@@ -15,6 +17,7 @@ import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import * as VodRepository from "~/features/vods/VodRepository.server";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { weaponCategories } from "~/modules/in-game-lists/weapon-ids";
|
||||
import type { ExtractWidgetSettings } from "./types";
|
||||
import { cachedUserSQLeaderboardTopData } from "./utils.server";
|
||||
@@ -314,6 +317,31 @@ export const WIDGET_LOADERS = {
|
||||
friends: async (userId: number) => {
|
||||
return FriendRepository.findFriendsByUserId(userId);
|
||||
},
|
||||
"luti-div": async (userId: number) => {
|
||||
return UserRepository.findDivByUserId(userId);
|
||||
},
|
||||
"map-mode-preferences": async (userId: number) => {
|
||||
const preferences =
|
||||
await MatchProfileRepository.findMapModePreferencesByUserId(userId);
|
||||
if (!preferences) return [];
|
||||
|
||||
return modesShort.flatMap((mode) => {
|
||||
const preference = preferences.modes.find(
|
||||
(m) => m.mode === mode,
|
||||
)?.preference;
|
||||
if (preference === "AVOID") return [];
|
||||
|
||||
const stages = (
|
||||
preferences.pool.find((p) => p.mode === mode)?.stages ?? []
|
||||
).filter((stageId) => !BANNED_MAPS[mode].includes(stageId));
|
||||
if (stages.length === 0) return [];
|
||||
|
||||
return { mode, stages };
|
||||
});
|
||||
},
|
||||
"live-stream": async (userId: number) => {
|
||||
return LiveStreamRepository.findByUserId(userId);
|
||||
},
|
||||
};
|
||||
|
||||
async function getTop500WeaponsByCategory(
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
badgesOwnedSchema,
|
||||
bioMdSchema,
|
||||
bioSchema,
|
||||
countdownSchema,
|
||||
favoriteStageSchema,
|
||||
gameBadgesSchema,
|
||||
gameBadgesSmallSchema,
|
||||
linksSchema,
|
||||
markdownSchema,
|
||||
peakXpUnverifiedSchema,
|
||||
peakXpWeaponSchema,
|
||||
sensSchema,
|
||||
@@ -78,6 +80,22 @@ export const ALL_WIDGETS = {
|
||||
schema: tierListSchema,
|
||||
defaultSettings: { searchParams: "" },
|
||||
}),
|
||||
defineWidget({ id: "luti-div", slot: "side" }),
|
||||
defineWidget({ id: "map-mode-preferences", slot: "main" }),
|
||||
defineWidget({ id: "live-stream", slot: "side", supporterOnly: true }),
|
||||
defineWidget({
|
||||
id: "countdown",
|
||||
slot: "side",
|
||||
schema: countdownSchema,
|
||||
defaultSettings: { title: "", date: new Date() },
|
||||
}),
|
||||
defineWidget({
|
||||
id: "markdown",
|
||||
slot: "side",
|
||||
supporterOnly: true,
|
||||
schema: markdownSchema,
|
||||
defaultSettings: { content: "" },
|
||||
}),
|
||||
],
|
||||
trophies: [defineWidget({ id: "trophies-owned", slot: "main" })],
|
||||
badges: [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { addYears } from "date-fns";
|
||||
import * as v from "valibot";
|
||||
import { ART_SOURCES } from "~/features/art/art-types";
|
||||
import { BADGE } from "~/features/badges/badges-constants";
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
array,
|
||||
badges,
|
||||
customField,
|
||||
datetime,
|
||||
numberField,
|
||||
select,
|
||||
selectDynamic,
|
||||
@@ -154,6 +156,27 @@ export const gameBadgesSmallSchema = v.object({
|
||||
),
|
||||
});
|
||||
|
||||
const COUNTDOWN_MAX_YEARS_AHEAD = 10;
|
||||
|
||||
export const countdownSchema = v.object({
|
||||
title: textField({
|
||||
label: "labels.title",
|
||||
maxLength: USER.COUNTDOWN_TITLE_MAX_LENGTH,
|
||||
}),
|
||||
date: datetime({
|
||||
label: "labels.date",
|
||||
max: () => addYears(new Date(), COUNTDOWN_MAX_YEARS_AHEAD),
|
||||
}),
|
||||
});
|
||||
|
||||
export const markdownSchema = v.object({
|
||||
content: textArea({
|
||||
label: "labels.text",
|
||||
bottomText: "bottomTexts.bioMarkdown",
|
||||
maxLength: USER.MARKDOWN_WIDGET_MAX_LENGTH,
|
||||
}),
|
||||
});
|
||||
|
||||
const WIDGET_FORM_SCHEMAS: Record<string, FormObjectSchema> = {
|
||||
bio: bioSchema,
|
||||
"bio-md": bioMdSchema,
|
||||
@@ -169,6 +192,8 @@ const WIDGET_FORM_SCHEMAS: Record<string, FormObjectSchema> = {
|
||||
"badges-owned": badgesOwnedSchema,
|
||||
"game-badges": gameBadgesSchema,
|
||||
"game-badges-small": gameBadgesSmallSchema,
|
||||
countdown: countdownSchema,
|
||||
markdown: markdownSchema,
|
||||
};
|
||||
|
||||
export function getWidgetFormSchema(widgetId: string) {
|
||||
|
||||
@@ -14,6 +14,8 @@ export const USER = {
|
||||
MAX_SIDE_WIDGETS_SUPPORTER: 7,
|
||||
GAME_BADGES_MAX: 8,
|
||||
GAME_BADGES_SMALL_MAX: 4,
|
||||
COUNTDOWN_TITLE_MAX_LENGTH: 50,
|
||||
MARKDOWN_WIDGET_MAX_LENGTH: 2000,
|
||||
};
|
||||
|
||||
export const SPL2_JOIN_ORDER_CUTOFF = 13_589;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { parseLutiDivFromName } from "../features/scrims/scrims-utils";
|
||||
import {
|
||||
parseLutiDivFromName,
|
||||
parseLutiSeasonFromName,
|
||||
} from "../features/scrims/scrims-utils";
|
||||
import * as TournamentRepository from "../features/tournament/TournamentRepository.server";
|
||||
import { LUTI_ORGANIZATION_ID } from "../features/tournament-organization/tournament-organization-constants";
|
||||
import * as UserRepository from "../features/user-page/UserRepository.server";
|
||||
@@ -22,6 +25,14 @@ export const ComputeLutiDivsRoutine = new Routine({
|
||||
});
|
||||
if (!league) return;
|
||||
|
||||
const divSeason = parseLutiSeasonFromName(league.name);
|
||||
if (!divSeason) {
|
||||
logger.warn(
|
||||
`ComputeLutiDivs: could not parse season from league name "${league.name}", skipping the run`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const divByBracketIdx = new Map<number, string | null>();
|
||||
const divOfBracket = (bracketIdx: number) => {
|
||||
if (!divByBracketIdx.has(bracketIdx)) {
|
||||
@@ -38,12 +49,12 @@ export const ComputeLutiDivsRoutine = new Routine({
|
||||
return divByBracketIdx.get(bracketIdx)!;
|
||||
};
|
||||
|
||||
const updates: Array<{ userId: number; div: string }> = [];
|
||||
const updates: Parameters<typeof UserRepository.updateManyDivs>[0] = [];
|
||||
for (const participant of league.participants) {
|
||||
const div = divOfBracket(participant.startingBracketIdx ?? 0);
|
||||
if (!div) continue;
|
||||
|
||||
updates.push({ userId: participant.userId, div });
|
||||
updates.push({ userId: participant.userId, div, divSeason });
|
||||
}
|
||||
|
||||
await UserRepository.updateManyDivs(updates);
|
||||
|
||||
10
changelog/2026-09-05-new-profile-widgets.md
Normal file
10
changelog/2026-09-05-new-profile-widgets.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: feature
|
||||
---
|
||||
New profile widgets
|
||||
|
||||
- LUTI Division: show your division from the latest LUTI season
|
||||
- Map & Mode Preferences: show the modes and your map pool from your match profile
|
||||
- Countdown: count down to a date & event of your choosing
|
||||
- Live Stream (Supporter): show your Twitch stream while you are live streaming Splatoon 3
|
||||
- Text Block (Supporter): a side widget for anything you want to write with markdown formatting
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "",
|
||||
"options.controller.handheld": "",
|
||||
"labels.tierListUrl": "",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "",
|
||||
"labels.comment": "",
|
||||
"errors.plusAlreadySuggested": "",
|
||||
|
||||
@@ -47,6 +47,19 @@
|
||||
"widget.game-badges-small": "",
|
||||
"widget.friends": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.live-stream.viewers_one": "",
|
||||
"widget.live-stream.viewers_other": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
@@ -120,6 +133,11 @@
|
||||
"widgets.description.game-badges-small": "",
|
||||
"widgets.description.friends": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "",
|
||||
"options.controller.handheld": "",
|
||||
"labels.tierListUrl": "",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "",
|
||||
"labels.comment": "",
|
||||
"errors.plusAlreadySuggested": "",
|
||||
|
||||
@@ -47,6 +47,19 @@
|
||||
"widget.game-badges-small": "",
|
||||
"widget.friends": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.live-stream.viewers_one": "",
|
||||
"widget.live-stream.viewers_other": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
@@ -120,6 +133,11 @@
|
||||
"widgets.description.game-badges-small": "",
|
||||
"widgets.description.friends": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "Joy-Con Grip",
|
||||
"options.controller.handheld": "Handheld",
|
||||
"labels.tierListUrl": "Tier List URL",
|
||||
"labels.title": "Title",
|
||||
"labels.plusTier": "Tier",
|
||||
"labels.comment": "Comment",
|
||||
"errors.plusAlreadySuggested": "This user has already been suggested",
|
||||
|
||||
@@ -47,6 +47,19 @@
|
||||
"widget.game-badges-small": "Game Badges",
|
||||
"widget.friends": "Friends",
|
||||
"widget.tier-list": "Featured Tier List",
|
||||
"widget.luti-div": "LUTI Division",
|
||||
"widget.luti-div.season": "Season {{season}}",
|
||||
"widget.map-mode-preferences": "Map & Mode Preferences",
|
||||
"widget.live-stream": "Live Stream",
|
||||
"widget.live-stream.live": "Live",
|
||||
"widget.live-stream.viewers_one": "{{count}} viewer",
|
||||
"widget.live-stream.viewers_other": "{{count}} viewers",
|
||||
"widget.countdown": "Countdown",
|
||||
"widget.countdown.days": "days",
|
||||
"widget.countdown.hours": "hours",
|
||||
"widget.countdown.minutes": "minutes",
|
||||
"widget.countdown.seconds": "seconds",
|
||||
"widget.markdown": "Text Block",
|
||||
"widget.link.all": "All",
|
||||
"widgets.edit": "Edit Widgets",
|
||||
"widgets.editProfile": "Edit Profile",
|
||||
@@ -120,6 +133,11 @@
|
||||
"widgets.description.game-badges-small": "Display up to {{max}} in-game badges on your profile",
|
||||
"widgets.description.friends": "Display your friends list",
|
||||
"widgets.description.tier-list": "Display a tier list you have made",
|
||||
"widgets.description.luti-div": "Show your division from the most recently finished LUTI season",
|
||||
"widgets.description.map-mode-preferences": "Show the modes and map pool from your match profile",
|
||||
"widgets.description.live-stream": "Show your Twitch stream while you are live streaming Splatoon 3",
|
||||
"widgets.description.countdown": "Count down to a date and event of your choosing",
|
||||
"widgets.description.markdown": "Write anything you want with markdown formatting",
|
||||
"widgets.forms.bio": "Bio",
|
||||
"widgets.forms.bio.markdownSupport": "Supports Markdown",
|
||||
"widgets.forms.division": "Division",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "Soporte para Joy-Con",
|
||||
"options.controller.handheld": "Modo portátil",
|
||||
"labels.tierListUrl": "URL de Tier List",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "Tier",
|
||||
"labels.comment": "Comentario",
|
||||
"errors.plusAlreadySuggested": "Este usuario ya ha sido sugerido",
|
||||
|
||||
@@ -47,6 +47,20 @@
|
||||
"widget.game-badges-small": "Chapas del juego",
|
||||
"widget.friends": "Amigos",
|
||||
"widget.tier-list": "Tier List destacada",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.live-stream.viewers_one": "",
|
||||
"widget.live-stream.viewers_many": "",
|
||||
"widget.live-stream.viewers_other": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "Todos",
|
||||
"widgets.edit": "Editar widgets",
|
||||
"widgets.editProfile": "Editar perfil",
|
||||
@@ -120,6 +134,11 @@
|
||||
"widgets.description.game-badges-small": "Muestra hasta {{max}} chapas del juego en tu perfil",
|
||||
"widgets.description.friends": "Muestra tu lista de amigos",
|
||||
"widgets.description.tier-list": "Muestra una Tier List que hayas creado",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "Biografía",
|
||||
"widgets.forms.bio.markdownSupport": "Compatible con Markdown",
|
||||
"widgets.forms.division": "División",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "Soporte para Joy-Con",
|
||||
"options.controller.handheld": "Modo portátil",
|
||||
"labels.tierListUrl": "URL de Tier List",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "Tier",
|
||||
"labels.comment": "Comentario",
|
||||
"errors.plusAlreadySuggested": "Este usuario ya ha sido sugerido",
|
||||
|
||||
@@ -47,6 +47,20 @@
|
||||
"widget.game-badges-small": "Insignias del juego",
|
||||
"widget.friends": "Amigos",
|
||||
"widget.tier-list": "Tier List destacada",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.live-stream.viewers_one": "",
|
||||
"widget.live-stream.viewers_many": "",
|
||||
"widget.live-stream.viewers_other": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "Todos",
|
||||
"widgets.edit": "Editar widgets",
|
||||
"widgets.editProfile": "Editar perfil",
|
||||
@@ -120,6 +134,11 @@
|
||||
"widgets.description.game-badges-small": "Muestra hasta {{max}} insignias del juego en tu perfil",
|
||||
"widgets.description.friends": "Muestra tu lista de amigos",
|
||||
"widgets.description.tier-list": "Muestra una Tier List que hayas creado",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "Biografía",
|
||||
"widgets.forms.bio.markdownSupport": "Compatible con Markdown",
|
||||
"widgets.forms.division": "División",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "",
|
||||
"options.controller.handheld": "",
|
||||
"labels.tierListUrl": "",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "",
|
||||
"labels.comment": "",
|
||||
"errors.plusAlreadySuggested": "",
|
||||
|
||||
@@ -47,6 +47,20 @@
|
||||
"widget.game-badges-small": "",
|
||||
"widget.friends": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.live-stream.viewers_one": "",
|
||||
"widget.live-stream.viewers_many": "",
|
||||
"widget.live-stream.viewers_other": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
@@ -120,6 +134,11 @@
|
||||
"widgets.description.game-badges-small": "",
|
||||
"widgets.description.friends": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "",
|
||||
"options.controller.handheld": "",
|
||||
"labels.tierListUrl": "",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "",
|
||||
"labels.comment": "Commentaire",
|
||||
"errors.plusAlreadySuggested": "",
|
||||
|
||||
@@ -47,6 +47,20 @@
|
||||
"widget.game-badges-small": "",
|
||||
"widget.friends": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.live-stream.viewers_one": "",
|
||||
"widget.live-stream.viewers_many": "",
|
||||
"widget.live-stream.viewers_other": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
@@ -120,6 +134,11 @@
|
||||
"widgets.description.game-badges-small": "",
|
||||
"widgets.description.friends": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "",
|
||||
"options.controller.handheld": "",
|
||||
"labels.tierListUrl": "",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "",
|
||||
"labels.comment": "",
|
||||
"errors.plusAlreadySuggested": "",
|
||||
|
||||
@@ -47,6 +47,20 @@
|
||||
"widget.game-badges-small": "",
|
||||
"widget.friends": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.live-stream.viewers_one": "",
|
||||
"widget.live-stream.viewers_two": "",
|
||||
"widget.live-stream.viewers_other": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
@@ -120,6 +134,11 @@
|
||||
"widgets.description.game-badges-small": "",
|
||||
"widgets.description.friends": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "",
|
||||
"options.controller.handheld": "",
|
||||
"labels.tierListUrl": "",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "",
|
||||
"labels.comment": "Commento",
|
||||
"errors.plusAlreadySuggested": "",
|
||||
|
||||
@@ -47,6 +47,20 @@
|
||||
"widget.game-badges-small": "",
|
||||
"widget.friends": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.live-stream.viewers_one": "",
|
||||
"widget.live-stream.viewers_many": "",
|
||||
"widget.live-stream.viewers_other": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
@@ -120,6 +134,11 @@
|
||||
"widgets.description.game-badges-small": "",
|
||||
"widgets.description.friends": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "",
|
||||
"options.controller.handheld": "",
|
||||
"labels.tierListUrl": "",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "",
|
||||
"labels.comment": "コメント",
|
||||
"errors.plusAlreadySuggested": "",
|
||||
|
||||
@@ -47,6 +47,17 @@
|
||||
"widget.game-badges-small": "",
|
||||
"widget.friends": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
@@ -120,6 +131,11 @@
|
||||
"widgets.description.game-badges-small": "",
|
||||
"widgets.description.friends": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "",
|
||||
"options.controller.handheld": "",
|
||||
"labels.tierListUrl": "",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "",
|
||||
"labels.comment": "",
|
||||
"errors.plusAlreadySuggested": "",
|
||||
|
||||
@@ -47,6 +47,17 @@
|
||||
"widget.game-badges-small": "",
|
||||
"widget.friends": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
@@ -120,6 +131,11 @@
|
||||
"widgets.description.game-badges-small": "",
|
||||
"widgets.description.friends": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "",
|
||||
"options.controller.handheld": "",
|
||||
"labels.tierListUrl": "",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "",
|
||||
"labels.comment": "",
|
||||
"errors.plusAlreadySuggested": "",
|
||||
|
||||
@@ -47,6 +47,19 @@
|
||||
"widget.game-badges-small": "",
|
||||
"widget.friends": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.live-stream.viewers_one": "",
|
||||
"widget.live-stream.viewers_other": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
@@ -120,6 +133,11 @@
|
||||
"widgets.description.game-badges-small": "",
|
||||
"widgets.description.friends": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "",
|
||||
"options.controller.handheld": "",
|
||||
"labels.tierListUrl": "",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "",
|
||||
"labels.comment": "",
|
||||
"errors.plusAlreadySuggested": "",
|
||||
|
||||
@@ -47,6 +47,21 @@
|
||||
"widget.game-badges-small": "",
|
||||
"widget.friends": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.live-stream.viewers_one": "",
|
||||
"widget.live-stream.viewers_few": "",
|
||||
"widget.live-stream.viewers_many": "",
|
||||
"widget.live-stream.viewers_other": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
@@ -120,6 +135,11 @@
|
||||
"widgets.description.game-badges-small": "",
|
||||
"widgets.description.friends": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "",
|
||||
"options.controller.handheld": "",
|
||||
"labels.tierListUrl": "",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "",
|
||||
"labels.comment": "Comentário",
|
||||
"errors.plusAlreadySuggested": "",
|
||||
|
||||
@@ -47,6 +47,20 @@
|
||||
"widget.game-badges-small": "",
|
||||
"widget.friends": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.live-stream.viewers_one": "",
|
||||
"widget.live-stream.viewers_many": "",
|
||||
"widget.live-stream.viewers_other": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
@@ -120,6 +134,11 @@
|
||||
"widgets.description.game-badges-small": "",
|
||||
"widgets.description.friends": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "",
|
||||
"options.controller.handheld": "",
|
||||
"labels.tierListUrl": "",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "",
|
||||
"labels.comment": "Комментарий",
|
||||
"errors.plusAlreadySuggested": "",
|
||||
|
||||
@@ -47,6 +47,21 @@
|
||||
"widget.game-badges-small": "",
|
||||
"widget.friends": "",
|
||||
"widget.tier-list": "",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.live-stream.viewers_one": "",
|
||||
"widget.live-stream.viewers_few": "",
|
||||
"widget.live-stream.viewers_many": "",
|
||||
"widget.live-stream.viewers_other": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "",
|
||||
"widgets.edit": "",
|
||||
"widgets.editProfile": "",
|
||||
@@ -120,6 +135,11 @@
|
||||
"widgets.description.game-badges-small": "",
|
||||
"widgets.description.friends": "",
|
||||
"widgets.description.tier-list": "",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "",
|
||||
"widgets.forms.bio.markdownSupport": "",
|
||||
"widgets.forms.division": "",
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"options.controller.grip": "Joy-Con 握把",
|
||||
"options.controller.handheld": "手提模式",
|
||||
"labels.tierListUrl": "自制强度榜 URL",
|
||||
"labels.title": "",
|
||||
"labels.plusTier": "级别",
|
||||
"labels.comment": "评论",
|
||||
"errors.plusAlreadySuggested": "此用户已被推荐过",
|
||||
|
||||
@@ -47,6 +47,17 @@
|
||||
"widget.game-badges-small": "游戏内徽章",
|
||||
"widget.friends": "好友",
|
||||
"widget.tier-list": "推荐自制强度榜",
|
||||
"widget.luti-div": "",
|
||||
"widget.luti-div.season": "",
|
||||
"widget.map-mode-preferences": "",
|
||||
"widget.live-stream": "",
|
||||
"widget.live-stream.live": "",
|
||||
"widget.countdown": "",
|
||||
"widget.countdown.days": "",
|
||||
"widget.countdown.hours": "",
|
||||
"widget.countdown.minutes": "",
|
||||
"widget.countdown.seconds": "",
|
||||
"widget.markdown": "",
|
||||
"widget.link.all": "全部",
|
||||
"widgets.edit": "编辑小组件",
|
||||
"widgets.editProfile": "编辑个人资料",
|
||||
@@ -120,6 +131,11 @@
|
||||
"widgets.description.game-badges-small": "在个人资料中最多展示 {{max}} 个游戏内徽章",
|
||||
"widgets.description.friends": "展示您的好友列表",
|
||||
"widgets.description.tier-list": "展示您制作的自制强度榜",
|
||||
"widgets.description.luti-div": "",
|
||||
"widgets.description.map-mode-preferences": "",
|
||||
"widgets.description.live-stream": "",
|
||||
"widgets.description.countdown": "",
|
||||
"widgets.description.markdown": "",
|
||||
"widgets.forms.bio": "个人简介",
|
||||
"widgets.forms.bio.markdownSupport": "支持 Markdown 语法",
|
||||
"widgets.forms.division": "组别",
|
||||
|
||||
16
migrations/20260905192242-user-div-season.ts
Normal file
16
migrations/20260905192242-user-div-season.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
|
||||
/**
|
||||
* LUTI season the user's `div` was earned in, so the profile can say which season it is from.
|
||||
* Filled by the div routine from now on; every division stored so far came from season 17.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable("User")
|
||||
.addColumn("divSeason", "integer")
|
||||
.execute();
|
||||
|
||||
await sql`update "User" set "divSeason" = 17 where "div" is not null`.execute(
|
||||
db,
|
||||
);
|
||||
}
|
||||
@@ -433,6 +433,9 @@ export function buildCases(fx: Fixtures): {
|
||||
LFGRepository.findByAuthorUserId(authorId),
|
||||
);
|
||||
|
||||
add("LiveStreamRepository.findByUserId", fx.heavyUser, (user) =>
|
||||
LiveStreamRepository.findByUserId(user.id),
|
||||
);
|
||||
addStatic("LiveStreamRepository.findXRankStreams", () =>
|
||||
LiveStreamRepository.findXRankStreams(),
|
||||
);
|
||||
@@ -440,6 +443,11 @@ export function buildCases(fx: Fixtures): {
|
||||
add("MatchProfileRepository.findSettingsByUserId", fx.heavyUser, (user) =>
|
||||
MatchProfileRepository.findSettingsByUserId(user.id),
|
||||
);
|
||||
add(
|
||||
"MatchProfileRepository.findMapModePreferencesByUserId",
|
||||
fx.heavyUser,
|
||||
(user) => MatchProfileRepository.findMapModePreferencesByUserId(user.id),
|
||||
);
|
||||
add("MatchProfileRepository.findWeaponPoolByUserId", fx.heavyUser, (user) =>
|
||||
MatchProfileRepository.findWeaponPoolByUserId(user.id),
|
||||
);
|
||||
@@ -1385,6 +1393,9 @@ export function buildCases(fx: Fixtures): {
|
||||
add("UserRepository.findPatronStartedAtByUserId", fx.heavyUser, (user) =>
|
||||
UserRepository.findPatronStartedAtByUserId(user.id),
|
||||
);
|
||||
add("UserRepository.findDivByUserId", fx.heavyUser, (user) =>
|
||||
UserRepository.findDivByUserId(user.id),
|
||||
);
|
||||
add("UserRepository.findJoinOrderByUserId", fx.heavyUser, (user) =>
|
||||
UserRepository.findJoinOrderByUserId(user.id),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user