mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Merge branch 'main' of https://github.com/sendou-ink/sendou.ink
This commit is contained in:
commit
a8e36f8518
|
|
@ -173,7 +173,6 @@
|
|||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
padding-inline: var(--s-4);
|
||||
padding-block-start: env(safe-area-inset-top);
|
||||
background-color: var(--color-bg-high);
|
||||
border-bottom: 1.5px solid var(--color-border);
|
||||
border-top: 1.5px solid var(--color-border);
|
||||
|
|
@ -181,8 +180,9 @@
|
|||
color: var(--color-text-high);
|
||||
min-height: var(--layout-nav-height);
|
||||
|
||||
&:has(+ nav) {
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
padding-block-start: env(safe-area-inset-top);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -582,8 +582,7 @@ function ChatPanel({
|
|||
>
|
||||
<Modal
|
||||
className={clsx(
|
||||
styles.panel,
|
||||
"scrollbar",
|
||||
styles.menuOverlay,
|
||||
skipAnimation && styles.noAnimation,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-height: var(--visual-viewport-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
|
|
@ -18,6 +19,7 @@ import { Avatar } from "~/components/Avatar";
|
|||
import { Image } from "~/components/Image";
|
||||
import { Input } from "~/components/Input";
|
||||
import type { SearchLoaderData } from "~/features/search/routes/search";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
mySlugify,
|
||||
navIconUrl,
|
||||
|
|
@ -83,6 +85,7 @@ export function GlobalSearch() {
|
|||
|
||||
const searchParamOpen = searchParams.get("search") === "open";
|
||||
const searchParamType = searchParams.get("type");
|
||||
const searchParamWeapon = searchParams.get("weapon");
|
||||
const initialSearchType =
|
||||
searchParamType && SEARCH_TYPES.includes(searchParamType as SearchType)
|
||||
? (searchParamType as SearchType)
|
||||
|
|
@ -91,10 +94,10 @@ export function GlobalSearch() {
|
|||
const [isOpen, setIsOpen] = React.useState(searchParamOpen);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (searchParamOpen && !isOpen) {
|
||||
if (searchParamOpen) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [searchParamOpen, isOpen]);
|
||||
}, [searchParamOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsMac(/Mac|iPhone|iPad|iPod/.test(navigator.userAgent));
|
||||
|
|
@ -115,10 +118,11 @@ export function GlobalSearch() {
|
|||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
if (!open && (searchParamOpen || searchParamType)) {
|
||||
if (!open && (searchParamOpen || searchParamType || searchParamWeapon)) {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete("search");
|
||||
newParams.delete("type");
|
||||
newParams.delete("weapon");
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
};
|
||||
|
|
@ -138,8 +142,9 @@ export function GlobalSearch() {
|
|||
<Modal className={styles.modal}>
|
||||
<Dialog className={styles.dialog} aria-label={t("common:search")}>
|
||||
<GlobalSearchContent
|
||||
onClose={() => handleOpenChange(false)}
|
||||
onClose={() => setIsOpen(false)}
|
||||
initialSearchType={initialSearchType}
|
||||
initialWeaponId={searchParamWeapon}
|
||||
/>
|
||||
</Dialog>
|
||||
</Modal>
|
||||
|
|
@ -148,12 +153,26 @@ export function GlobalSearch() {
|
|||
);
|
||||
}
|
||||
|
||||
function resolveInitialWeapon(
|
||||
weaponIdStr: string | null,
|
||||
t: TFunction<["common", "weapons"]>,
|
||||
): SelectedWeapon | null {
|
||||
if (!weaponIdStr) return null;
|
||||
const id = Number(weaponIdStr) as MainWeaponId;
|
||||
if (Number.isNaN(id)) return null;
|
||||
const name = t(`weapons:MAIN_${id}`);
|
||||
if (!name || name === `MAIN_${id}`) return null;
|
||||
return { id, name, slug: mySlugify(name) };
|
||||
}
|
||||
|
||||
function GlobalSearchContent({
|
||||
onClose,
|
||||
initialSearchType,
|
||||
initialWeaponId,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
initialSearchType: SearchType | null;
|
||||
initialWeaponId: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation(["common", "weapons"]);
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -162,7 +181,9 @@ function GlobalSearchContent({
|
|||
initialSearchType ?? getInitialSearchType(),
|
||||
);
|
||||
const [selectedWeapon, setSelectedWeapon] =
|
||||
React.useState<SelectedWeapon | null>(null);
|
||||
React.useState<SelectedWeapon | null>(
|
||||
resolveInitialWeapon(initialWeaponId, t),
|
||||
);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listBoxRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
daily,
|
||||
everyHourAt00,
|
||||
everyHourAt30,
|
||||
everyTwoHours,
|
||||
everyTwoMinutes,
|
||||
} from "./routines/list.server";
|
||||
import { logger } from "./utils/logger";
|
||||
|
|
@ -112,12 +111,6 @@ if (!global.appStartSignal && process.env.NODE_ENV === "production") {
|
|||
}
|
||||
});
|
||||
|
||||
cron.schedule("5 */2 * * *", async () => {
|
||||
for (const routine of everyTwoHours) {
|
||||
await routine.run();
|
||||
}
|
||||
});
|
||||
|
||||
cron.schedule("*/2 * * * *", async () => {
|
||||
for (const routine of everyTwoMinutes) {
|
||||
await routine.run();
|
||||
|
|
|
|||
|
|
@ -303,6 +303,54 @@ describe("deleteById", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("deleteOrphanTags", () => {
|
||||
beforeEach(async () => {
|
||||
imageCounter = 0;
|
||||
await dbInsertUsers(1);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("deletes tags with no associated art", async () => {
|
||||
const art = await ArtRepository.insert({
|
||||
authorId: 1,
|
||||
url: "https://example.com/image-1.png",
|
||||
validatedAt: Date.now(),
|
||||
description: null,
|
||||
linkedUsers: [],
|
||||
tags: [{ name: "Orphan1" }, { name: "Orphan2" }],
|
||||
});
|
||||
|
||||
await ArtRepository.deleteById(art.id);
|
||||
|
||||
const deletedCount = await ArtRepository.deleteOrphanTags();
|
||||
expect(deletedCount).toBe(2);
|
||||
|
||||
const tags = await ArtRepository.findAllTags();
|
||||
expect(tags).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("does not delete tags that are still linked to art", async () => {
|
||||
await ArtRepository.insert({
|
||||
authorId: 1,
|
||||
url: "https://example.com/image-1.png",
|
||||
validatedAt: Date.now(),
|
||||
description: null,
|
||||
linkedUsers: [],
|
||||
tags: [{ name: "InUse" }],
|
||||
});
|
||||
|
||||
const deletedCount = await ArtRepository.deleteOrphanTags();
|
||||
expect(deletedCount).toBe(0);
|
||||
|
||||
const tags = await ArtRepository.findAllTags();
|
||||
expect(tags).toHaveLength(1);
|
||||
expect(tags[0].name).toBe("InUse");
|
||||
});
|
||||
});
|
||||
|
||||
describe("insert", () => {
|
||||
beforeEach(async () => {
|
||||
imageCounter = 0;
|
||||
|
|
|
|||
|
|
@ -171,6 +171,15 @@ export async function findAllTags() {
|
|||
return db.selectFrom("ArtTag").select(["id", "name"]).execute();
|
||||
}
|
||||
|
||||
export async function deleteOrphanTags() {
|
||||
const result = await db
|
||||
.deleteFrom("ArtTag")
|
||||
.where("id", "not in", db.selectFrom("TaggedArt").select("TaggedArt.tagId"))
|
||||
.executeTakeFirst();
|
||||
|
||||
return Number(result.numDeletedRows);
|
||||
}
|
||||
|
||||
export async function findArtsByUserId(
|
||||
userId: number,
|
||||
{ includeAuthored = true, includeTagged = true } = {},
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||
? cachedArts.allTags.find((t) => t.name === filteredTagName)
|
||||
: null;
|
||||
|
||||
if (!filteredTag) return cachedArts;
|
||||
if (!filteredTag) {
|
||||
return filteredTagName ? { ...cachedArts, showcaseArts: [] } : cachedArts;
|
||||
}
|
||||
|
||||
return {
|
||||
...cachedArts,
|
||||
|
|
|
|||
|
|
@ -160,7 +160,13 @@ export default function ArtPage() {
|
|||
<ArtGrid arts={recentlyUploadedArts} showUploadDate />
|
||||
</SendouTabPanel>
|
||||
<SendouTabPanel id={TABS.SHOWCASE}>
|
||||
<ArtGrid arts={showcaseArts} />
|
||||
{filteredTag && showcaseArts.length === 0 ? (
|
||||
<div className="no-results mt-4">
|
||||
{t("art:noArtForTag", { tag: filteredTag })}
|
||||
</div>
|
||||
) : (
|
||||
<ArtGrid arts={showcaseArts} />
|
||||
)}
|
||||
</SendouTabPanel>
|
||||
</SendouTabs>
|
||||
</Main>
|
||||
|
|
|
|||
|
|
@ -556,55 +556,71 @@ function useChatRouteSync({
|
|||
React.SetStateAction<Record<string, ChatMessage[]>>
|
||||
>;
|
||||
}) {
|
||||
const chatCode = useCurrentRouteChatCode();
|
||||
const rawChatCode = useCurrentRouteChatCode();
|
||||
const chatCodesKey = rawChatCode
|
||||
? Array.isArray(rawChatCode)
|
||||
? rawChatCode.join(",")
|
||||
: rawChatCode
|
||||
: "";
|
||||
const { pathname } = useLocation();
|
||||
const layoutSize = useLayoutSize();
|
||||
const subscribedRoomRef = React.useRef<string | null>(null);
|
||||
const previousRouteChatCodeRef = React.useRef<string | null>(null);
|
||||
const subscribedRoomRef = React.useRef<string[]>([]);
|
||||
const previousRouteChatCodeRef = React.useRef<string[]>([]);
|
||||
const previousPathnameRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
const previousSubscribed = subscribedRoomRef.current;
|
||||
const chatCodes = chatCodesKey ? chatCodesKey.split(",") : [];
|
||||
|
||||
// Clean up previous non-participant subscription if chatCode changed
|
||||
if (previousSubscribed && previousSubscribed !== chatCode) {
|
||||
unsubscribe(previousSubscribed);
|
||||
setRooms((prev) => prev.filter((r) => r.chatCode !== previousSubscribed));
|
||||
// Clean up subscriptions for rooms no longer in chatCodes
|
||||
const previousSubscribed = subscribedRoomRef.current;
|
||||
const removedRooms = previousSubscribed.filter(
|
||||
(code) => !chatCodes.includes(code),
|
||||
);
|
||||
for (const code of removedRooms) {
|
||||
unsubscribe(code);
|
||||
setRooms((prev) => prev.filter((r) => r.chatCode !== code));
|
||||
setMessagesByRoom((prev) => {
|
||||
const { [previousSubscribed]: _, ...rest } = prev;
|
||||
const { [code]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
if (activeRoom === previousSubscribed) {
|
||||
if (activeRoom === code) {
|
||||
setActiveRoom(null);
|
||||
setChatOpen(false);
|
||||
}
|
||||
subscribedRoomRef.current = null;
|
||||
}
|
||||
if (removedRooms.length > 0) {
|
||||
subscribedRoomRef.current = previousSubscribed.filter((code) =>
|
||||
chatCodes.includes(code),
|
||||
);
|
||||
}
|
||||
|
||||
if (chatCode) {
|
||||
const room = rooms.find((r) => r.chatCode === chatCode);
|
||||
const isParticipant = room?.participantUserIds.includes(userId);
|
||||
if (chatCodes.length > 0) {
|
||||
for (const code of chatCodes) {
|
||||
const alreadyInRooms = rooms.some((r) => r.chatCode === code);
|
||||
|
||||
if (!isParticipant && subscribedRoomRef.current !== chatCode) {
|
||||
logger.debug("Subscribing to non-participant room:", chatCode);
|
||||
subscribe(chatCode);
|
||||
subscribedRoomRef.current = chatCode;
|
||||
if (!alreadyInRooms && !subscribedRoomRef.current.includes(code)) {
|
||||
logger.debug("Subscribing to non-participant room:", code);
|
||||
subscribe(code);
|
||||
subscribedRoomRef.current = [...subscribedRoomRef.current, code];
|
||||
}
|
||||
}
|
||||
|
||||
const previousCodes = previousRouteChatCodeRef.current;
|
||||
const routeChatCodeChanged =
|
||||
previousRouteChatCodeRef.current !== chatCode;
|
||||
previousRouteChatCodeRef.current = chatCode;
|
||||
chatCodes.length !== previousCodes.length ||
|
||||
chatCodes.some((code, i) => previousCodes[i] !== code);
|
||||
previousRouteChatCodeRef.current = chatCodes;
|
||||
|
||||
if (routeChatCodeChanged) {
|
||||
setActiveRoom(chatCode);
|
||||
setActiveRoom(chatCodes[0]);
|
||||
if (layoutSize === "desktop") {
|
||||
setChatOpen(true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
previousRouteChatCodeRef.current = null;
|
||||
previousRouteChatCodeRef.current = [];
|
||||
|
||||
const pathnameChanged = previousPathnameRef.current !== pathname;
|
||||
previousPathnameRef.current = pathname;
|
||||
|
|
@ -624,7 +640,7 @@ function useChatRouteSync({
|
|||
}
|
||||
}, [
|
||||
isLoading,
|
||||
chatCode,
|
||||
chatCodesKey,
|
||||
pathname,
|
||||
rooms,
|
||||
userId,
|
||||
|
|
@ -639,11 +655,13 @@ function useChatRouteSync({
|
|||
]);
|
||||
}
|
||||
|
||||
function useCurrentRouteChatCode() {
|
||||
function useCurrentRouteChatCode(): string | string[] | null {
|
||||
const matches = useMatches();
|
||||
|
||||
for (const match of matches) {
|
||||
const matchData = match.data as { chatCode?: string } | undefined;
|
||||
const matchData = match.data as
|
||||
| { chatCode?: string | string[] }
|
||||
| undefined;
|
||||
if (matchData?.chatCode) {
|
||||
return matchData.chatCode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import cachified from "@epic-web/cachified";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as Changelog from "~/features/front-page/core/Changelog.server";
|
||||
import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server";
|
||||
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
|
||||
import * as SplatoonRotationRepository from "~/features/splatoon-rotations/SplatoonRotationRepository.server";
|
||||
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
|
|
@ -13,26 +15,35 @@ import * as ShowcaseTournaments from "../core/ShowcaseTournaments.server";
|
|||
export type FrontPageLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async () => {
|
||||
const [tournaments, changelog, leaderboards, rotations] = await Promise.all([
|
||||
ShowcaseTournaments.categorizedTournamentsByUserId(null),
|
||||
cachified({
|
||||
key: "front-changelog",
|
||||
cache,
|
||||
ttl: ttl(IN_MILLISECONDS.ONE_HOUR),
|
||||
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
|
||||
async getFreshValue() {
|
||||
return Changelog.get();
|
||||
},
|
||||
}),
|
||||
cachedLeaderboards(),
|
||||
SplatoonRotationRepository.findAll(),
|
||||
]);
|
||||
const user = getUser();
|
||||
|
||||
const [tournaments, changelog, leaderboards, rotations, weaponPool] =
|
||||
await Promise.all([
|
||||
ShowcaseTournaments.categorizedTournamentsByUserId(null),
|
||||
cachified({
|
||||
key: "front-changelog",
|
||||
cache,
|
||||
ttl: ttl(IN_MILLISECONDS.ONE_HOUR),
|
||||
staleWhileRevalidate: ttl(IN_MILLISECONDS.TWO_HOURS),
|
||||
async getFreshValue() {
|
||||
return Changelog.get();
|
||||
},
|
||||
}),
|
||||
cachedLeaderboards(),
|
||||
SplatoonRotationRepository.findAll(),
|
||||
user
|
||||
? QSettingsRepository.settingsByUserId(user.id).then(
|
||||
(s) => s.qWeaponPool ?? null,
|
||||
)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return {
|
||||
tournaments,
|
||||
changelog,
|
||||
leaderboards,
|
||||
rotations,
|
||||
weaponPool,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next";
|
|||
import { Link, useLoaderData } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { Image } from "~/components/Image";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
|
||||
import { BSKYLikeIcon } from "~/components/icons/BSKYLike";
|
||||
import { BSKYReplyIcon } from "~/components/icons/BSKYReply";
|
||||
import { BSKYRepostIcon } from "~/components/icons/BSKYRepost";
|
||||
import { ExternalIcon } from "~/components/icons/External";
|
||||
import { navItems } from "~/components/layout/nav-items";
|
||||
import { Main } from "~/components/Main";
|
||||
import { TournamentCard } from "~/features/calendar/components/TournamentCard";
|
||||
import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations";
|
||||
|
|
@ -41,6 +42,7 @@ export default function FrontPage() {
|
|||
<SeasonBanner />
|
||||
<SplatoonRotations />
|
||||
<ResultHighlights />
|
||||
<DiscoverFeatures />
|
||||
<ChangelogList />
|
||||
</Main>
|
||||
);
|
||||
|
|
@ -227,6 +229,61 @@ function Leaderboard({
|
|||
);
|
||||
}
|
||||
|
||||
const DISCOVER_EXCLUDED_ITEMS = new Set(["settings", "luti"]);
|
||||
|
||||
function DiscoverFeatures() {
|
||||
const { t } = useTranslation(["front", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const filteredNavItems = navItems.filter(
|
||||
(item) => !DISCOVER_EXCLUDED_ITEMS.has(item.name),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<Divider smallText className="text-uppercase text-xs font-bold">
|
||||
{t("front:discover.header")}
|
||||
</Divider>
|
||||
{data.weaponPool && data.weaponPool.length > 0 ? (
|
||||
<div className={styles.weaponPills}>
|
||||
{data.weaponPool.map((weapon) => (
|
||||
<Link
|
||||
key={weapon.weaponSplId}
|
||||
to={`?search=open&type=weapons&weapon=${weapon.weaponSplId}`}
|
||||
className={styles.weaponPill}
|
||||
>
|
||||
<WeaponImage
|
||||
weaponSplId={weapon.weaponSplId}
|
||||
variant="badge"
|
||||
size={32}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<nav className={styles.discoverGrid}>
|
||||
{filteredNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={`/${item.url}`}
|
||||
className={styles.discoverGridItem}
|
||||
>
|
||||
<div className={styles.discoverGridItemImage}>
|
||||
<Image
|
||||
path={navIconUrl(item.name)}
|
||||
height={32}
|
||||
width={32}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<span>{t(`common:pages.${item.name}` as any)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangelogList() {
|
||||
const { t } = useTranslation(["front"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
|
|
@ -45,15 +45,30 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
})
|
||||
: null,
|
||||
rawReportedWeapons,
|
||||
chatCode:
|
||||
(user?.roles.includes("STAFF") ||
|
||||
(user && matchUsers.includes(user.id))) &&
|
||||
chatAccessible({
|
||||
isStaff: user?.roles.includes("STAFF") ?? false,
|
||||
chatCode: (() => {
|
||||
const isStaff = user?.roles.includes("STAFF") ?? false;
|
||||
const isParticipant = user && matchUsers.includes(user.id);
|
||||
|
||||
if (!(isStaff || isParticipant)) return null;
|
||||
|
||||
const accessible = chatAccessible({
|
||||
isStaff,
|
||||
expiresAfterDays: 1,
|
||||
comparedTo: databaseTimestampToDate(matchUnmapped.createdAt),
|
||||
})
|
||||
? match.chatCode
|
||||
: null,
|
||||
});
|
||||
if (!accessible) return null;
|
||||
|
||||
if (!isParticipant) return match.chatCode ?? null;
|
||||
|
||||
const codes = [
|
||||
match.chatCode,
|
||||
match.groupAlpha.chatCode,
|
||||
match.groupBravo.chatCode,
|
||||
].filter((c): c is string => Boolean(c));
|
||||
|
||||
if (codes.length === 0) return null;
|
||||
if (codes.length === 1) return codes[0];
|
||||
return codes;
|
||||
})(),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
matchMapList,
|
||||
} from "~/features/sendouq-match/core/match.server";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { navIconUrl, SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
|
||||
|
|
@ -177,6 +178,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
});
|
||||
|
||||
await refreshSendouQInstance();
|
||||
refreshStreamsCache();
|
||||
|
||||
if (createdMatch.chatCode) {
|
||||
ChatSystemMessage.setMetadata({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { assertUnreachable } from "~/utils/types";
|
|||
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
|
||||
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
|
||||
import { preparingSchema } from "../q-schemas.server";
|
||||
import { setGroupChatMetadata } from "../q-utils.server";
|
||||
|
||||
export type SendouQPreparingAction = typeof action;
|
||||
|
||||
|
|
@ -58,6 +59,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const updatedGroup = SendouQ.findOwnGroup(user.id);
|
||||
if (updatedGroup?.chatCode) {
|
||||
setGroupChatMetadata({
|
||||
chatCode: updatedGroup.chatCode,
|
||||
members: updatedGroup.members,
|
||||
});
|
||||
}
|
||||
|
||||
notify({
|
||||
userIds: [data.id],
|
||||
notification: {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
tournamentId,
|
||||
userId: user.id,
|
||||
});
|
||||
if (!ownTournamentTeam)
|
||||
if (!ownTournamentTeam) {
|
||||
return {
|
||||
mapPool: null,
|
||||
friendPlayers: null,
|
||||
|
|
@ -31,6 +31,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
tournamentId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mapPool: findMapPoolByTeamId(ownTournamentTeam.id),
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const stm = sql.prepare(/*sql*/ `
|
|||
and "TournamentTeamMember"."isOwner" = 1
|
||||
where
|
||||
"TournamentTeam"."tournamentId" = @tournamentId
|
||||
and "TournamentTeam"."isPlaceholder" = 0
|
||||
and "TournamentTeamMember"."userId" = @userId
|
||||
`);
|
||||
|
||||
|
|
|
|||
|
|
@ -368,7 +368,7 @@ export default function App() {
|
|||
|
||||
const scrollbarWidth =
|
||||
window.innerWidth - document.documentElement.clientWidth;
|
||||
|
||||
|
||||
htmlStyle.overflow = "initial";
|
||||
bodyStyle.overflow = "hidden";
|
||||
bodyStyle.paddingRight = `${scrollbarWidth}px`;
|
||||
|
|
|
|||
11
app/routines/deleteOrphanArtTags.ts
Normal file
11
app/routines/deleteOrphanArtTags.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import * as ArtRepository from "../features/art/ArtRepository.server";
|
||||
import { logger } from "../utils/logger";
|
||||
import { Routine } from "./routine.server";
|
||||
|
||||
export const DeleteOrphanArtTagsRoutine = new Routine({
|
||||
name: "DeleteOrphanArtTags",
|
||||
func: async () => {
|
||||
const deletedCount = await ArtRepository.deleteOrphanTags();
|
||||
logger.info(`Deleted ${deletedCount} orphan art tags`);
|
||||
},
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions";
|
||||
import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
|
||||
import { DeleteOrphanArtTagsRoutine } from "./deleteOrphanArtTags";
|
||||
import { NotifyCheckInStartRoutine } from "./notifyCheckInStart";
|
||||
import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting";
|
||||
import { NotifyScrimStartingSoonRoutine } from "./notifyScrimStartingSoon";
|
||||
|
|
@ -15,6 +16,7 @@ export const everyHourAt00 = [
|
|||
NotifyPlusServerVotingRoutine,
|
||||
NotifyCheckInStartRoutine,
|
||||
NotifyScrimStartingSoonRoutine,
|
||||
SyncSplatoonRotationsRoutine,
|
||||
];
|
||||
|
||||
/** List of Routines that should occur hourly at XX:30 */
|
||||
|
|
@ -27,10 +29,8 @@ export const everyHourAt30 = [
|
|||
export const daily = [
|
||||
DeleteOldNotificationsRoutine,
|
||||
CloseExpiredCommissionsRoutine,
|
||||
DeleteOrphanArtTagsRoutine,
|
||||
];
|
||||
|
||||
/** List of Routines that should occur every 2 hours */
|
||||
export const everyTwoHours = [SyncSplatoonRotationsRoutine];
|
||||
|
||||
/** List of Routines that should occur every 2 minutes */
|
||||
export const everyTwoMinutes = [SyncLiveStreamsRoutine];
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ export const SyncSplatoonRotationsRoutine = new Routine({
|
|||
});
|
||||
|
||||
async function syncSplatoonRotations() {
|
||||
if (import.meta.env.VITE_PROD_MODE !== "true") return;
|
||||
|
||||
const rotations = await fetchRotations();
|
||||
await SplatoonRotationRepository.replaceAll(rotations);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,3 +165,62 @@
|
|||
gap: var(--s-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.weaponPills {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.weaponPill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-bg-higher);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--s-1);
|
||||
transition: background-color 0.15s ease-out;
|
||||
}
|
||||
|
||||
.weaponPill:hover {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.discoverGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-2);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.discoverGrid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.discoverGridItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.discoverGridItem:hover .discoverGridItemImage {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
.discoverGridItemImage {
|
||||
width: var(--field-size-lg);
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: var(--radius-field);
|
||||
background-color: var(--color-bg-higher);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,5 +29,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "Maks antal etiketter opnået",
|
||||
"delete.title": "",
|
||||
"unlink.title": ""
|
||||
"unlink.title": "",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,5 +29,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "",
|
||||
"delete.title": "",
|
||||
"unlink.title": ""
|
||||
"unlink.title": "",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,5 +29,6 @@
|
|||
"forms.tags.placeholder": "Select a tag",
|
||||
"forms.tags.maxReached": "Max tags reached",
|
||||
"delete.title": "Are you sure you want to delete the art?",
|
||||
"unlink.title": "Are you sure you want to remove this art from your profile (only {{username}} can add it back)?"
|
||||
"unlink.title": "Are you sure you want to remove this art from your profile (only {{username}} can add it back)?",
|
||||
"noArtForTag": "No uploaded art found for #{{tag}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "Now",
|
||||
"rotations.nextLabel": "Next",
|
||||
"rotations.credit": "Data from splatoon3.ink",
|
||||
"rotations.filter.all": "All"
|
||||
"rotations.filter.all": "All",
|
||||
"discover.header": "Discover all the features"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,5 +30,6 @@
|
|||
"forms.tags.placeholder": "Selecciona una etiqueta",
|
||||
"forms.tags.maxReached": "Limite máximo de etiquetas",
|
||||
"delete.title": "¿Seguro que quieres eliminar este arte?",
|
||||
"unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)"
|
||||
"unlink.title": "¿Seguro que quieres desvincular este arte de tu perfil? (solo {{username}} puede volver a añadirlo)",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,5 +30,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "Limite máximo de etiquetas",
|
||||
"delete.title": "",
|
||||
"unlink.title": ""
|
||||
"unlink.title": "",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,5 +30,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "Limite de tags atteinte",
|
||||
"delete.title": "",
|
||||
"unlink.title": ""
|
||||
"unlink.title": "",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,5 +30,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "Limite de tags atteinte",
|
||||
"delete.title": "Êtes vous sûre de vouloir supprimer cette œuvre?",
|
||||
"unlink.title": "Êtes vous sûre de vouloir supprimer cette œuvre de votre profil? (Seulement {{username}} pourra le remettre)"
|
||||
"unlink.title": "Êtes vous sûre de vouloir supprimer cette œuvre de votre profil? (Seulement {{username}} pourra le remettre)",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,5 +30,6 @@
|
|||
"forms.tags.placeholder": "הגעת למקסימום תגים",
|
||||
"forms.tags.maxReached": "הגעת לכמות תגים מקסימלית",
|
||||
"delete.title": "האם אתה בטוח שאתה רוצה למחוק את הציור?",
|
||||
"unlink.title": "האם אתה בטוח שאתה רוצה להסיר את הציור הזה מהפרופיל שלך (רק {{username}} יוכל להוסיף אותו חזרה)?"
|
||||
"unlink.title": "האם אתה בטוח שאתה רוצה להסיר את הציור הזה מהפרופיל שלך (רק {{username}} יוכל להוסיף אותו חזרה)?",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,5 +30,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "Numero massimo di tag raggiunto.",
|
||||
"delete.title": "",
|
||||
"unlink.title": ""
|
||||
"unlink.title": "",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "タグの最大数に到達しました",
|
||||
"delete.title": "",
|
||||
"unlink.title": ""
|
||||
"unlink.title": "",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "",
|
||||
"delete.title": "",
|
||||
"unlink.title": ""
|
||||
"unlink.title": "",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,5 +29,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "",
|
||||
"delete.title": "",
|
||||
"unlink.title": ""
|
||||
"unlink.title": "",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,5 +31,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "",
|
||||
"delete.title": "",
|
||||
"unlink.title": ""
|
||||
"unlink.title": "",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,5 +30,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "Quantidade máxima de marcações atingida",
|
||||
"delete.title": "",
|
||||
"unlink.title": ""
|
||||
"unlink.title": "",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,5 +31,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "Максимальное кол-во тегов достигнуто",
|
||||
"delete.title": "Вы точно хотите удалить этот арт?",
|
||||
"unlink.title": "Вы точно хотите удалить этот арт с вашего профиля (только {{username}} может добавить его обратно)?"
|
||||
"unlink.title": "Вы точно хотите удалить этот арт с вашего профиля (только {{username}} может добавить его обратно)?",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,5 +27,6 @@
|
|||
"forms.tags.placeholder": "",
|
||||
"forms.tags.maxReached": "已达到最大标签数",
|
||||
"delete.title": "",
|
||||
"unlink.title": ""
|
||||
"unlink.title": "",
|
||||
"noArtForTag": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,5 +38,6 @@
|
|||
"rotations.current": "",
|
||||
"rotations.nextLabel": "",
|
||||
"rotations.credit": "",
|
||||
"rotations.filter.all": ""
|
||||
"rotations.filter.all": "",
|
||||
"discover.header": ""
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user