-
- sendou.ink
-
- {breadcrumbs.flatMap((breadcrumb) => {
- return [
-
- »
- ,
-
,
- ];
- })}
+
+ const sideNavFooterContent = (
+
+
+
+ );
+
+ const sideNavChildren = (
+ <>
+
}
+ action={
+ user ? (
+
+ {t("common:actions.viewAll")}
+
+
+ ) : null
+ }
+ >
+ {t("front:sideNav.myCalendar")}
+
+ {events.length > 0 ? (
+ events.map((event) => (
+
+ {event.scrimStatus === "booked"
+ ? t("front:sideNav.scrimVs", { opponent: event.name })
+ : event.scrimStatus === "looking"
+ ? t("front:sideNav.lookingForScrim")
+ : event.name}
+
+ ))
+ ) : (
+
{t("front:sideNav.noEvents")}
+ )}
+
+
}
+ action={
+ user ? (
+
+ {t("common:actions.viewAll")}
+
+
+ ) : null
+ }
+ >
+ {t("front:sideNav.friends")}
+
+ {friends.length > 0 ? (
+ friends.map((friend) =>
)
+ ) : (
+
+ {user
+ ? t("front:sideNav.friends.noFriends")
+ : t("front:sideNav.friends.notLoggedIn")}
-
}>{t("front:sideNav.streams")}
+ {streams.length === 0 ? (
+
+ {t("front:sideNav.noStreams")}
+
+ ) : null}
+
+ >
+ );
+
+ return (
+ <>
+
}
+ topCentered={isFrontPage}
+ >
+ {sideNavChildren}
+
+
+
+
+
+
+
+
+ 0}
+ testId="sidenav-modal-trigger"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setSideNavCollapsed(!sideNavCollapsed)}
+ className={styles.sideNavCollapseButton}
+ showNotificationDot={sideNavCollapsed && unseenIds.length > 0}
+ testId="sidenav-collapse-button"
+ />
+
+ setChatSidebarOpen(true)
+ : undefined
+ }
+ onChatModalToggle={
+ data?.user
+ ? () => setChatSidebarModalOpen((prev) => !prev)
+ : undefined
+ }
+ chatUnreadCount={chatContext?.totalUnreadCount}
+ />
+
+ {showLeaderboard ? (
+
+ ) : null}
+ {children}
+
+
+ {chatSidebarOpen ? (
+
setNavDialogOpen(true)}
- />
-
- {showLeaderboard ? : null}
- {children}
-
+ >
+ setChatSidebarOpen(false)} />
+
+ ) : null}
+ >
+ );
+}
+
+function SiteTitle() {
+ const location = useLocation();
+ const { breadcrumbs, currentPageText } = useBreadcrumbData();
+
+ const isFrontPage = location.pathname === "/";
+ const hasBreadcrumbs = breadcrumbs.length > 0;
+
+ return (
+
+
+
+
+
+
+
+
+ {hasBreadcrumbs ? (
+ <>
+ {breadcrumbs.map((crumb) => (
+
+ /
+
+
+ ))}
+
+ {currentPageText ? (
+
{currentPageText}
+ ) : null}
+ >
+ ) : null}
+
+
+ );
+}
+
+function SiteLogoContent() {
+ return (
+ <>
+
S
+
ink
+ >
+ );
+}
+
+function SideNavCollapseButton({
+ onToggle,
+ className,
+ showNotificationDot,
+ testId,
+}: {
+ onToggle?: () => void;
+ className?: string;
+ showNotificationDot?: boolean;
+ testId?: string;
+}) {
+ return (
+
+ }
+ onPress={onToggle}
+ />
+ {showNotificationDot ? : null}
);
}
-function BreadcrumbLink({ data }: { data: Breadcrumb }) {
- if (data.type === "IMAGE") {
- const imageIsWithExtension = data.imgPath.includes(".");
+function PageIcon({ crumb }: { crumb: Breadcrumb }) {
+ if (crumb.type !== "IMAGE") {
+ return null;
+ }
+ const isExternal = crumb.imgPath.includes(".");
+ const iconClass = clsx(styles.pageIcon, "rounded");
+
+ return isExternal ? (
+

+ ) : (
+
+ );
+}
+
+function SideNavUserPanel() {
+ const { t } = useTranslation();
+ const location = useLocation();
+ const user = useUser();
+ const { notifications, unseenIds } = useNotifications();
+
+ if (user) {
return (
-
- {imageIsWithExtension ? (
-

- ) : (
-
- )}
-
- {data.text}
-
-
+ <>
+
+
+
+ {user.username}
+
+
+
+ {notifications ? (
+
+ {unseenIds.length > 0 ? (
+
+ ) : null}
+
+
+
+ }
+ popoverClassName={clsx(
+ notificationPopoverStyles.popoverContainer,
+ {
+ [notificationPopoverStyles.noNotificationsContainer]:
+ notifications.length === 0,
+ },
+ )}
+ >
+
+
+
+ ) : null}
+
+
+
+
+ >
);
}
return (
-
- {data.text}
-
+ <>
+
+ }>
+ {t("header.login.discord")}
+
+
+
+
+
+
+
+ >
);
}
-
-function MyRampUnit() {
- return
;
-}
diff --git a/app/components/layout/nav-items.ts b/app/components/layout/nav-items.ts
index e217b9add..5a7949362 100644
--- a/app/components/layout/nav-items.ts
+++ b/app/components/layout/nav-items.ts
@@ -71,11 +71,6 @@ export const navItems = [
url: "plus/suggestions",
prefetch: false,
},
- {
- name: "u",
- url: "u",
- prefetch: false,
- },
{
name: "xsearch",
url: "xsearch",
@@ -96,11 +91,6 @@ export const navItems = [
url: "art",
prefetch: false,
},
- {
- name: "t",
- url: "t",
- prefetch: false,
- },
{
name: "tier-list-maker",
url: "tier-list-maker",
diff --git a/app/components/ramp/Ramp.tsx b/app/components/ramp/Ramp.tsx
deleted file mode 100644
index 5737449c9..000000000
--- a/app/components/ramp/Ramp.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { useEffect, useState } from "react";
-import { useLocation } from "react-router";
-import { logger } from "../../utils/logger";
-
-declare global {
- interface Window {
- ramp: any;
- }
-}
-
-const PUBLISHER_ID = import.meta.env.VITE_PLAYWIRE_PUBLISHER_ID;
-const WEBSITE_ID = import.meta.env.VITE_PLAYWIRE_WEBSITE_ID;
-
-export const Ramp = () => {
- const [rampComponentLoaded, setRampComponentLoaded] = useState(false);
- const location = useLocation();
-
- useEffect(() => {
- if (!PUBLISHER_ID || !WEBSITE_ID) {
- logger.info("RAMP: Missing Publisher Id or Website Id");
- return;
- }
-
- if (!rampComponentLoaded) {
- logger.info("RAMP: Loading");
- setRampComponentLoaded(true);
- window.ramp = window.ramp || {};
- window.ramp.que = window.ramp.que || [];
- window.ramp.passiveMode = true;
-
- // Load the Ramp configuration script
- const configScript = document.createElement("script");
- configScript.src = `https://cdn.intergient.com/${PUBLISHER_ID}/${WEBSITE_ID}/ramp.js`;
- document.body.appendChild(configScript);
-
- configScript.onload = window.ramp.que.push(() => {
- window.ramp.spaNewPage;
- });
- }
-
- // Cleanup function to handle component unmount and updating page state
- return () => {
- window.ramp.que.push(() => {
- window.ramp.spaNewPage(location.pathname);
- });
- };
- }, [rampComponentLoaded, location.pathname]);
-
- return null;
-};
diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts
index b689cf192..993f64346 100644
--- a/app/db/seed/index.ts
+++ b/app/db/seed/index.ts
@@ -37,8 +37,8 @@ import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.s
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/sendouq-settings/q-settings-constants";
-import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
+import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import * as VodRepository from "~/features/vods/VodRepository.server";
@@ -56,7 +56,6 @@ import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import { stagesObj as s, stageIds } from "~/modules/in-game-lists/stage-ids";
import type {
AbilityType,
- MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
@@ -70,6 +69,7 @@ import {
} from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
+import { randomTeamName } from "~/utils/team-name";
import { mySlugify } from "~/utils/urls";
import {
getArtFilename,
@@ -219,7 +219,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
calendarEventWithToToolsTeamsDepths,
calendarEventWithToToolsLUTI,
calendarEventWithToToolsTeamsLUTI,
- tournamentSubs,
+ variation === "NO_TOURNAMENT_TEAMS" ? undefined : tournamentLfgGroups,
adminBuilds,
manySplattershotBuilds,
detailedTeam(variation),
@@ -237,7 +237,9 @@ const basicSeeds = (variation?: SeedVariation | null) => [
variation === "NO_SCRIMS" ? undefined : scrimPostRequests,
associations,
notifications,
+ () => friendships(variation),
liveStreams,
+ splatoonRotations,
];
export async function seed(variation?: SeedVariation | null) {
@@ -277,6 +279,7 @@ function wipeDB() {
"MapPoolMap",
"TournamentMatchGameResult",
"TournamentTeamCheckIn",
+ "TournamentLFGLike",
"TournamentTeam",
"TournamentStage",
"TournamentResult",
@@ -296,6 +299,8 @@ function wipeDB() {
"Notification",
"BanLog",
"ModNote",
+ "Friendship",
+ "FriendRequest",
"User",
"PlusSuggestion",
"PlusVote",
@@ -304,6 +309,7 @@ function wipeDB() {
"TournamentOrganization",
"SeedingSkill",
"LiveStream",
+ "SplatoonRotation",
];
for (const table of tablesToDelete) {
@@ -891,9 +897,7 @@ function calendarEvents() {
)
.run({
id,
- name: `${R.capitalize(faker.word.adjective())} ${R.capitalize(
- faker.word.noun(),
- )}`,
+ name: randomTeamName(),
description: faker.lorem.paragraph(),
discordInviteCode: faker.lorem.word(),
bracketUrl: faker.internet.url(),
@@ -1009,7 +1013,7 @@ async function calendarEventResults() {
.fill(null)
.map((_, i) => ({
placement: i + 1,
- teamName: R.capitalize(faker.word.noun()),
+ teamName: randomTeamName(),
players: new Array(
faker.helpers.arrayElement([1, 2, 3, 4, 4, 4, 4, 4, 5, 6]),
)
@@ -1371,12 +1375,7 @@ function calendarEventWithToToolsToSetMapPool() {
}
}
-const validTournamentTeamName = () => {
- while (true) {
- const name = faker.music.songName();
- if (name.length <= TOURNAMENT.TEAM_NAME_MAX_LENGTH) return name;
- }
-};
+const validTournamentTeamName = () => randomTeamName();
const availableStages: StageId[] = [1, 2, 3, 4, 6, 7, 8, 10, 11];
const availablePairs = rankedModesShort
@@ -1485,12 +1484,14 @@ function calendarEventWithToToolsTeams(
"tournamentTeamId",
"userId",
"isOwner",
- "createdAt"
+ "createdAt",
+ "role"
) values (
$tournamentTeamId,
$userId,
$isOwner,
- $createdAt
+ $createdAt,
+ $role
)
`,
)
@@ -1499,6 +1500,7 @@ function calendarEventWithToToolsTeams(
userId,
isOwner: i === 0 ? 1 : 0,
createdAt: dateToDatabaseTimestamp(yesterday),
+ role: i === 0 ? "OWNER" : "REGULAR",
});
}
@@ -1561,73 +1563,113 @@ function calendarEventWithToToolsTeams(
}
}
-function tournamentSubs() {
- for (let id = 100; id < 120; id++) {
- const includedWeaponIds: MainWeaponId[] = [];
+async function tournamentLfgGroups() {
+ const availableUsers = userIdsInAscendingOrderById().slice(300);
- sql
- .prepare(
- /* sql */ `
- insert into "TournamentSub" (
- "userId",
- "tournamentId",
- "canVc",
- "bestWeapons",
- "okWeapons",
- "message",
- "visibility"
- ) values (
- @userId,
- @tournamentId,
- @canVc,
- @bestWeapons,
- @okWeapons,
- @message,
- @visibility
- )
- `,
- )
- .run({
- userId: id,
- tournamentId: 1,
- canVc: Number(faker.number.float(1) > 0.5),
- bestWeapons: nullFilledArray(
- faker.helpers.arrayElement([1, 1, 1, 2, 2, 3, 4, 5]),
- )
- // biome-ignore lint/suspicious/useIterableCallbackReturn: Biome 2.3.1 upgrade
- .map(() => {
- while (true) {
- const weaponId = R.sample(mainWeaponIds, 1)[0]!;
- if (!includedWeaponIds.includes(weaponId)) {
- includedWeaponIds.push(weaponId);
- return weaponId;
- }
- }
- })
- .join(","),
- okWeapons:
- faker.number.float(1) > 0.5
- ? null
- : nullFilledArray(
- faker.helpers.arrayElement([1, 1, 1, 2, 2, 3, 4, 5]),
- )
- // biome-ignore lint/suspicious/useIterableCallbackReturn: Biome 2.3.1 upgrade
- .map(() => {
- while (true) {
- const weaponId = R.sample(mainWeaponIds, 1)[0]!;
- if (!includedWeaponIds.includes(weaponId)) {
- includedWeaponIds.push(weaponId);
- return weaponId;
- }
- }
- })
- .join(","),
- message: faker.number.float(1) > 0.5 ? null : faker.lorem.paragraph(),
- visibility: id < 105 ? "+1" : id < 110 ? "+2" : id < 115 ? "+2" : "ALL",
- });
+ const MAX_GROUP_SIZE = 6;
+
+ // Add admin's friends to tournament LFG so sidebar shows tournament friends
+ for (const friendId of SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG) {
+ await TournamentLFGRepository.createPlaceholderTeam({
+ tournamentId: 1,
+ userId: friendId,
+ });
}
- return null;
+ const tournaments = [1, 2, 3];
+
+ let userIndex = 0;
+ for (const tournamentId of tournaments) {
+ const users = availableUsers.slice(userIndex, userIndex + 8);
+ userIndex += 8;
+
+ // Group 1: solo placeholder, has note, isStayAsSub=1
+ const { id: team1Id } = await TournamentLFGRepository.createPlaceholderTeam(
+ {
+ tournamentId,
+ userId: users[0],
+ isStayAsSub: true,
+ },
+ );
+ await TournamentLFGRepository.updateTeamNote({
+ teamId: team1Id,
+ value: "Looking for a team, can play any role",
+ });
+
+ // Group 2: solo placeholder
+ const { id: team2Id } = await TournamentLFGRepository.createPlaceholderTeam(
+ {
+ tournamentId,
+ userId: users[1],
+ },
+ );
+
+ // Group 3: solo placeholder
+ const { id: team3Id } = await TournamentLFGRepository.createPlaceholderTeam(
+ {
+ tournamentId,
+ userId: users[2],
+ },
+ );
+
+ // Group 4: solo placeholder
+ const { id: team4Id } = await TournamentLFGRepository.createPlaceholderTeam(
+ {
+ tournamentId,
+ userId: users[3],
+ },
+ );
+
+ // Group 5: 2-member group (merged from two placeholders)
+ const { id: mergeTarget1 } =
+ await TournamentLFGRepository.createPlaceholderTeam({
+ tournamentId,
+ userId: users[4],
+ });
+ const { id: mergeSource1 } =
+ await TournamentLFGRepository.createPlaceholderTeam({
+ tournamentId,
+ userId: users[5],
+ });
+ await TournamentLFGRepository.mergeTeams({
+ survivingTeamId: mergeTarget1,
+ otherTeamId: mergeSource1,
+ maxGroupSize: MAX_GROUP_SIZE,
+ });
+
+ // Group 6: 2-member group (merged from two placeholders)
+ const { id: mergeTarget2 } =
+ await TournamentLFGRepository.createPlaceholderTeam({
+ tournamentId,
+ userId: users[6],
+ });
+ const { id: mergeSource2 } =
+ await TournamentLFGRepository.createPlaceholderTeam({
+ tournamentId,
+ userId: users[7],
+ });
+ await TournamentLFGRepository.mergeTeams({
+ survivingTeamId: mergeTarget2,
+ otherTeamId: mergeSource2,
+ maxGroupSize: MAX_GROUP_SIZE,
+ });
+
+ // Team 1 -> Team 2 (one-way like)
+ await TournamentLFGRepository.addLike({
+ likerTeamId: team1Id,
+ targetTeamId: team2Id,
+ });
+ // Team 2 -> Team 1 (mutual — tests invitation UI)
+ await TournamentLFGRepository.addLike({
+ likerTeamId: team2Id,
+ targetTeamId: team1Id,
+ });
+ // Team 3 -> Team 4 (one-way like)
+ await TournamentLFGRepository.addLike({
+ likerTeamId: team3Id,
+ targetTeamId: team4Id,
+ });
+ }
}
const randomAbility = (legalTypes: AbilityType[]) => {
@@ -1821,12 +1863,7 @@ function otherTeams() {
);
for (let i = 3; i < 50; i++) {
- const teamName =
- i === 3
- ? "Team Olive"
- : `${R.capitalize(faker.word.adjective())} ${R.capitalize(
- faker.word.noun(),
- )}`;
+ const teamName = i === 3 ? "Team Olive" : randomTeamName();
const teamCustomUrl = mySlugify(teamName);
sql
@@ -2692,8 +2729,7 @@ async function notifications() {
{
type: "TO_CHECK_IN_OPENED",
meta: { tournamentId: 1, tournamentName: "PICNIC #2" },
- pictureUrl:
- "http://localhost:5173/static-assets/img/tournament-logos/pn.png",
+ pictureUrl: "/static-assets/img/tournament-logos/pn.png",
},
];
@@ -2795,6 +2831,62 @@ async function organization() {
.run();
}
+const SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS = [150, 151, 152, 153];
+const SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG = [100, 101];
+const SENDOU_FRIEND_IDS_OTHER = [102, 103];
+
+async function friendships(variation?: SeedVariation | null) {
+ const allFriendIds = [
+ ...SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS,
+ ...SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG,
+ ...SENDOU_FRIEND_IDS_OTHER,
+ ];
+
+ for (const friendId of allFriendIds) {
+ const userOneId = Math.min(ADMIN_ID, friendId);
+ const userTwoId = Math.max(ADMIN_ID, friendId);
+
+ sql
+ .prepare(
+ /* sql */ `
+ insert into "Friendship" ("userOneId", "userTwoId")
+ values (@userOneId, @userTwoId)
+ `,
+ )
+ .run({ userOneId, userTwoId });
+ }
+
+ if (variation === "NO_SQ_GROUPS") return;
+
+ for (const friendId of SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS) {
+ const group = await SQGroupRepository.createGroup({
+ status: "ACTIVE",
+ userId: friendId,
+ });
+
+ const additionalMemberCount = faker.helpers.arrayElement([0, 1, 2]);
+ const additionalMembers = [200, 201, 202, 203, 204, 205].slice(
+ 0,
+ additionalMemberCount,
+ );
+
+ for (const memberId of additionalMembers) {
+ sql
+ .prepare(
+ /* sql */ `
+ insert into "GroupMember" ("groupId", "userId", "role")
+ values (@groupId, @userId, @role)
+ `,
+ )
+ .run({
+ groupId: group.id,
+ userId: memberId + (friendId - 150) * 10,
+ role: "REGULAR",
+ });
+ }
+ }
+}
+
function liveStreams() {
const userIds = userIdsInAscendingOrderById();
@@ -2861,3 +2953,75 @@ function liveStreams() {
});
}
}
+
+function splatoonRotations() {
+ const nowUnix = Math.floor(Date.now() / 1000);
+ const TWO_HOURS = 2 * 60 * 60;
+
+ const currentStart = nowUnix - (nowUnix % TWO_HOURS);
+ const currentEnd = currentStart + TWO_HOURS;
+ const nextStart = currentEnd;
+ const nextEnd = nextStart + TWO_HOURS;
+
+ const rotationData = [
+ {
+ type: "SERIES",
+ mode: "SZ",
+ stageId1: 0,
+ stageId2: 3,
+ startTime: currentStart,
+ endTime: currentEnd,
+ },
+ {
+ type: "SERIES",
+ mode: "TC",
+ stageId1: 5,
+ stageId2: 8,
+ startTime: nextStart,
+ endTime: nextEnd,
+ },
+ {
+ type: "OPEN",
+ mode: "RM",
+ stageId1: 1,
+ stageId2: 4,
+ startTime: currentStart,
+ endTime: currentEnd,
+ },
+ {
+ type: "OPEN",
+ mode: "CB",
+ stageId1: 6,
+ stageId2: 9,
+ startTime: nextStart,
+ endTime: nextEnd,
+ },
+ {
+ type: "X",
+ mode: "CB",
+ stageId1: 2,
+ stageId2: 7,
+ startTime: currentStart,
+ endTime: currentEnd,
+ },
+ {
+ type: "X",
+ mode: "SZ",
+ stageId1: 10,
+ stageId2: 11,
+ startTime: nextStart,
+ endTime: nextEnd,
+ },
+ ];
+
+ for (const rotation of rotationData) {
+ sql
+ .prepare(
+ `
+ insert into "SplatoonRotation" ("type", "mode", "stageId1", "stageId2", "startTime", "endTime")
+ values ($type, $mode, $stageId1, $stageId2, $startTime, $endTime)
+ `,
+ )
+ .run(rotation);
+ }
+}
diff --git a/app/db/tables.ts b/app/db/tables.ts
index 7f2a9f8c7..84d369295 100644
--- a/app/db/tables.ts
+++ b/app/db/tables.ts
@@ -36,13 +36,51 @@ export type MemberRole = (typeof TEAM_MEMBER_ROLES)[number];
/** In SQLite booleans are presented as 0 (false) and 1 (true) */
export type DBBoolean = number;
+export const CUSTOM_THEME_VARS = [
+ "--_base-h",
+ "--_base-c-0",
+ "--_base-c-1",
+ "--_base-c-2",
+ "--_base-c-3",
+ "--_base-c-4",
+ "--_base-c-5",
+ "--_base-c-6",
+ "--_base-c-7",
+ "--_acc-h",
+ "--_acc-c-0",
+ "--_acc-c-1",
+ "--_acc-c-2",
+ "--_acc-c-3",
+ "--_acc-c-4",
+ "--_acc-c-5",
+ "--_second-h",
+ "--_second-c-0",
+ "--_second-c-1",
+ "--_second-c-2",
+ "--_second-c-3",
+ "--_second-c-4",
+ "--_second-c-5",
+ "--_chat-h",
+ "--_radius-box",
+ "--_radius-field",
+ "--_radius-selector",
+ "--_border-width",
+ "--_size-field",
+ "--_size-selector",
+ "--_size-spacing",
+] as const;
+export type CustomThemeVar = (typeof CUSTOM_THEME_VARS)[number];
+export type CustomTheme = Omit
, "--_chat-h"> & {
+ "--_chat-h": number | null;
+};
+
export interface Team {
avatarImgId: number | null;
bannerImgId: number | null;
bio: string | null;
createdAt: Generated;
- css: JSONColumnTypeNullable>;
customUrl: string;
+ customTheme: JSONColumnTypeNullable;
deletedAt: number | null;
id: GeneratedAlways;
inviteCode: string;
@@ -520,6 +558,13 @@ export interface PreparedMaps {
eliminationTeamCount?: number;
}
+export interface SavedCalendarEvent {
+ id: GeneratedAlways;
+ userId: number;
+ calendarEventId: number;
+ createdAt: Generated;
+}
+
export interface TournamentBadgeOwner {
badgeId: number;
userId: number;
@@ -695,6 +740,12 @@ export interface TournamentSub {
visibility: "+1" | "+2" | "+3" | "ALL";
}
+export interface TournamentLFGLike {
+ likerTeamId: number;
+ targetTeamId: number;
+ createdAt: Generated;
+}
+
export interface TournamentStaff {
tournamentId: number;
userId: number;
@@ -715,6 +766,10 @@ export interface TournamentTeam {
tournamentId: number;
teamId: number | null;
avatarImgId: number | null;
+ isLooking: Generated;
+ isPlaceholder: Generated;
+ lfgNote: string | null;
+ chatCode: Generated;
}
export interface TournamentTeamCheckIn {
@@ -732,6 +787,10 @@ export interface TournamentTeamMember {
inGameName: string | null;
tournamentTeamId: number;
userId: number;
+ role: Generated<"OWNER" | "MANAGER" | "REGULAR">;
+ isStayAsSub: Generated;
+ // denormalized from TournamentTeam.isLooking
+ isLooking: Generated;
}
export interface TournamentOrganization {
@@ -797,6 +856,22 @@ export interface TrustRelationship {
lastUsedAt: number;
}
+/** Mutual friendship between two users. Invariant: userOneId < userTwoId. */
+export interface Friendship {
+ id: GeneratedAlways;
+ userOneId: number;
+ userTwoId: number;
+ createdAt: Generated;
+}
+
+/** Pending friend request from one user to another. */
+export interface FriendRequest {
+ id: GeneratedAlways;
+ senderId: number;
+ receiverId: number;
+ createdAt: Generated;
+}
+
export interface UnvalidatedUserSubmittedImage {
id: GeneratedAlways;
submitterUserId: number;
@@ -892,7 +967,7 @@ export interface User {
commissionsOpenedAt: number | null;
commissionText: string | null;
country: string | null;
- css: JSONColumnTypeNullable>;
+ customTheme: JSONColumnTypeNullable;
customUrl: string | null;
discordAvatar: string | null;
discordId: string;
@@ -1154,6 +1229,19 @@ export interface NotificationUserSubscription {
subscription: JSONColumnType;
}
+export const SPLATOON_ROTATION_TYPES = ["SERIES", "OPEN", "X"] as const;
+export type SplatoonRotationType = (typeof SPLATOON_ROTATION_TYPES)[number];
+
+export interface SplatoonRotation {
+ id: GeneratedAlways;
+ type: SplatoonRotationType;
+ mode: string;
+ stageId1: number;
+ stageId2: number;
+ startTime: number;
+ endTime: number;
+}
+
export type Tables = { [P in keyof DB]: Selectable };
export type TablesInsertable = { [P in keyof DB]: Insertable };
export type TablesUpdatable = { [P in keyof DB]: Updateable };
@@ -1208,6 +1296,7 @@ export interface DB {
Tournament: Tournament;
TournamentStaff: TournamentStaff;
TournamentGroup: TournamentGroup;
+ TournamentLFGLike: TournamentLFGLike;
TournamentMatch: TournamentMatch;
TournamentMatchPickBanEvent: TournamentMatchPickBanEvent;
TournamentMatchGameResult: TournamentMatchGameResult;
@@ -1226,6 +1315,8 @@ export interface DB {
TournamentBracketProgressionOverride: TournamentBracketProgressionOverride;
TournamentOrganizationBannedUser: TournamentOrganizationBannedUser;
TrustRelationship: TrustRelationship;
+ Friendship: Friendship;
+ FriendRequest: FriendRequest;
UnvalidatedUserSubmittedImage: UnvalidatedUserSubmittedImage;
UnvalidatedVideo: UnvalidatedVideo;
User: User;
@@ -1247,4 +1338,6 @@ export interface DB {
Notification: Notification;
NotificationUser: NotificationUser;
NotificationUserSubscription: NotificationUserSubscription;
+ SavedCalendarEvent: SavedCalendarEvent;
+ SplatoonRotation: SplatoonRotation;
}
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
index 563a1553a..9f2502159 100644
--- a/app/entry.client.tsx
+++ b/app/entry.client.tsx
@@ -1,3 +1,4 @@
+import "@formatjs/intl-durationformat/polyfill.js";
import i18next from "i18next";
import { hydrateRoot } from "react-dom/client";
import { I18nextProvider } from "react-i18next";
diff --git a/app/entry.server.tsx b/app/entry.server.tsx
index 33ebd1aee..4a4fda0fd 100644
--- a/app/entry.server.tsx
+++ b/app/entry.server.tsx
@@ -1,3 +1,4 @@
+import "@formatjs/intl-durationformat/polyfill.js";
import { PassThrough } from "node:stream";
import { createReadableStreamFromReadable } from "@react-router/node";
import { createInstance } from "i18next";
@@ -13,6 +14,7 @@ import {
daily,
everyHourAt00,
everyHourAt30,
+ everyTwoHours,
everyTwoMinutes,
} from "./routines/list.server";
import { logger } from "./utils/logger";
@@ -110,6 +112,12 @@ 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();
diff --git a/app/features/admin/admin-constants.ts b/app/features/admin/admin-constants.ts
index 8bca72137..0ddc89efe 100644
--- a/app/features/admin/admin-constants.ts
+++ b/app/features/admin/admin-constants.ts
@@ -3,6 +3,9 @@ export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274;
// Panda Scep Acing Baja Michi
export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094];
+// hfcRed
+export const DEV_IDS = [27883];
+
export const STAFF_DISCORD_IDS = [
"138757634500067328",
"184478601171828737",
diff --git a/app/features/admin/loaders/admin.server.ts b/app/features/admin/loaders/admin.server.ts
index 65a7315d7..5b8c6dd22 100644
--- a/app/features/admin/loaders/admin.server.ts
+++ b/app/features/admin/loaders/admin.server.ts
@@ -1,14 +1,25 @@
import type { LoaderFunctionArgs } from "react-router";
-import { isImpersonating } from "~/features/auth/core/user.server";
+import {
+ getRealUserId,
+ isImpersonating,
+ requireUser,
+} from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
-import { requireRole } from "~/modules/permissions/guards.server";
+import { isAdmin, isDev, isStaff } from "~/modules/permissions/utils";
import { parseSafeSearchParams } from "~/utils/remix.server";
import { adminActionSearchParamsSchema } from "../admin-schemas";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls";
export const loader = async ({ request }: LoaderFunctionArgs) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
- requireRole("STAFF");
+ const user = requireUser();
+ const realUserId = await getRealUserId(request);
+ const userToCheck =
+ realUserId && realUserId !== user.id ? { id: realUserId } : user;
+
+ if (!isAdmin(userToCheck) && !isStaff(userToCheck) && !isDev(userToCheck)) {
+ throw new Response("Forbidden", { status: 403 });
+ }
}
const parsedSearchParams = parseSafeSearchParams({
diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx
index 3652fdcb9..1f2098532 100644
--- a/app/features/admin/routes/admin.tsx
+++ b/app/features/admin/routes/admin.tsx
@@ -1,3 +1,4 @@
+import { Search } from "lucide-react";
import * as React from "react";
import type { MetaFunction } from "react-router";
import {
@@ -21,7 +22,6 @@ import {
import { UserSearch } from "~/components/elements/UserSearch";
import { FormMessage } from "~/components/FormMessage";
import { Input } from "~/components/Input";
-import { SearchIcon } from "~/components/icons/Search";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { SEED_VARIATIONS } from "~/features/api-private/constants";
@@ -47,6 +47,17 @@ export const meta: MetaFunction = (args) => {
};
export default function AdminPage() {
+ const isStaff = useHasRole("STAFF");
+
+ // is dev user or is someone impersonating another user (allow them to stop)
+ if (!isStaff) {
+ return (
+
+
+
+ );
+ }
+
return (
@@ -84,7 +95,7 @@ function FriendCodeLookUp() {
/>
}
+ icon={}
onPress={() => setSearchParams({ friendCode })}
>
Search
@@ -109,12 +120,15 @@ function FriendCodeLookUp() {
function AdminActions() {
const isStaff = useHasRole("STAFF");
const isAdmin = useHasRole("ADMIN");
+ const isDev = useHasRole("DEV");
return (
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ?
: null}
{DANGEROUS_CAN_ACCESS_DEV_CONTROLS ?
: null}
- {DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin ?
: null}
+ {DANGEROUS_CAN_ACCESS_DEV_CONTROLS || isAdmin || isDev ? (
+
+ ) : null}
{isStaff ?
: null}
{isStaff ?
: null}
@@ -172,16 +186,20 @@ function MigrateUser() {
Migrate user data
-
setOldUserId(newUser?.id)}
- />
- setNewUserId(newUser?.id)}
- />
+
+ setOldUserId(newUser?.id)}
+ />
+
+
+ setNewUserId(newUser?.id)}
+ />
+
Link player
-
-
+
+
+
+
@@ -302,8 +322,10 @@ function UpdateFriendCode() {
Update friend code
-
-
+
+
+
+
Force patron
-
+
+
+
-
+
-
+
@@ -370,14 +394,16 @@ function BanUser() {
Ban user
-
+
+
+
-
+
-
+
diff --git a/app/features/admin/routes/generate-images.tsx b/app/features/admin/routes/generate-images.tsx
index f3119747b..f0c6c1b22 100644
--- a/app/features/admin/routes/generate-images.tsx
+++ b/app/features/admin/routes/generate-images.tsx
@@ -106,11 +106,11 @@ function InfoSquare({
style={{
width: "12rem",
height: "12rem",
- borderRadius: "var(--rounded)",
+ borderRadius: "var(--radius-box)",
marginTop: "1rem",
display: "grid",
placeItems: "center",
- borderColor: "var(--border)",
+ borderColor: "var(--color-border)",
borderWidth: "2px",
borderStyle: "solid",
}}
diff --git a/app/features/api-private/routes/seed.ts b/app/features/api-private/routes/seed.ts
index 7336dd0d8..a70d6b5e4 100644
--- a/app/features/api-private/routes/seed.ts
+++ b/app/features/api-private/routes/seed.ts
@@ -127,14 +127,22 @@ function restoreFromPreSeeded(sourcePath: string) {
for (const { name } of tables) {
sql.exec(`DELETE FROM main."${name}"`);
- // Get non-generated columns for this table (table_xinfo includes hidden column info)
- const columns = sql
+ // Get non-generated columns from main database
+ const mainColumns = sql
.prepare(`PRAGMA main.table_xinfo("${name}")`)
.all() as Array<{ name: string; hidden: number }>;
+ // Get columns from source database
+ const sourceColumns = sql
+ .prepare(`PRAGMA source.table_info("${name}")`)
+ .all() as Array<{ name: string }>;
+
+ const sourceColumnNames = new Set(sourceColumns.map((c) => c.name));
+
// hidden = 2 or 3 means virtual/stored generated column
- const nonGeneratedCols = columns
- .filter((c) => c.hidden === 0)
+ // Only include columns that exist in both databases
+ const nonGeneratedCols = mainColumns
+ .filter((c) => c.hidden === 0 && sourceColumnNames.has(c.name))
.map((c) => c.name);
if (nonGeneratedCols.length > 0) {
diff --git a/app/features/api/routes/api.tsx b/app/features/api/routes/api.tsx
index 92be74bdc..458b630a2 100644
--- a/app/features/api/routes/api.tsx
+++ b/app/features/api/routes/api.tsx
@@ -1,3 +1,4 @@
+import { Eye, RefreshCcw } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useLoaderData } from "react-router";
@@ -5,8 +6,6 @@ import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover";
import { SendouButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import { FormWithConfirm } from "~/components/FormWithConfirm";
-import { EyeIcon } from "~/components/icons/Eye";
-import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { metaTags } from "~/utils/remix";
@@ -95,7 +94,7 @@ function TokenSection({
}>
+
}>
{t("common:api.revealButton")}
}
@@ -107,11 +106,7 @@ function TokenSection({
submitButtonText={t("common:api.regenerate.confirm")}
fields={[["_action", generateAction]]}
>
-
}
- className="mr-auto"
- >
+
}>
{t("common:api.regenerate.button")}
diff --git a/app/features/art/components/ArtGrid.module.css b/app/features/art/components/ArtGrid.module.css
new file mode 100644
index 000000000..8a0578f54
--- /dev/null
+++ b/app/features/art/components/ArtGrid.module.css
@@ -0,0 +1,44 @@
+.thumbnail {
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ scale: 1.025;
+ }
+}
+
+.dialogTag {
+ background-color: #fff;
+ border-radius: var(--radius-box);
+ color: #000;
+ font-size: var(--font-2xs);
+ padding-inline: var(--s-1);
+ margin-block: var(--s-1) var(--s-0-5);
+}
+
+.dialogTagUser {
+ background-color: var(--color-accent);
+}
+
+.dialogDescription {
+ font-size: var(--font-sm);
+ text-align: center;
+ color: #fff;
+}
+
+.dialogImg {
+ max-width: 100%;
+ max-height: 75vh;
+ width: 100%;
+ height: auto;
+ object-fit: contain;
+ display: block;
+}
+
+.tagsContainer {
+ display: flex;
+ gap: var(--s-0-5) var(--s-2);
+ justify-content: center;
+ flex-wrap: wrap;
+ margin-block-start: var(--s-0-5);
+}
diff --git a/app/features/art/components/ArtGrid.tsx b/app/features/art/components/ArtGrid.tsx
index a65e9a334..9d7ebb46d 100644
--- a/app/features/art/components/ArtGrid.tsx
+++ b/app/features/art/components/ArtGrid.tsx
@@ -1,4 +1,5 @@
import clsx from "clsx";
+import { SquarePen, Trash, Unlink, X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@@ -6,10 +7,6 @@ import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { FormWithConfirm } from "~/components/FormWithConfirm";
-import { CrossIcon } from "~/components/icons/Cross";
-import { EditIcon } from "~/components/icons/Edit";
-import { TrashIcon } from "~/components/icons/Trash";
-import { UnlinkIcon } from "~/components/icons/Unlink";
import { Pagination } from "~/components/Pagination";
import { useIsMounted } from "~/hooks/useIsMounted";
import { usePagination } from "~/hooks/usePagination";
@@ -21,6 +18,7 @@ import { ResponsiveMasonry } from "../../../modules/responsive-masonry/component
import { ART_PER_PAGE } from "../art-constants";
import type { ListedArt } from "../art-types";
import { previewUrl } from "../art-utils";
+import styles from "./ArtGrid.module.css";
export function ArtGrid({
arts,
@@ -107,18 +105,18 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
alt=""
src={art.url}
loading="lazy"
- className="art__dialog__img"
+ className={styles.dialogImg}
onLoad={() => setImageLoaded(true)}
/>
{art.tags || art.linkedUsers ? (
{art.linkedUsers?.map((user) => (
{user.username}
@@ -127,7 +125,7 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
#{tag.name}
@@ -136,7 +134,7 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
) : null}
{art.description ? (
@@ -147,7 +145,7 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
variant="destructive"
className="mx-auto mt-6"
onPress={close}
- icon={}
+ icon={}
>
Close
@@ -180,7 +178,7 @@ function ImagePreview({
loading="lazy"
onClick={onClick}
onLoad={() => setImageLoaded(true)}
- className={enablePreview ? "art__thumbnail" : undefined}
+ className={enablePreview ? styles.thumbnail : undefined}
/>
);
@@ -197,7 +195,7 @@ function ImagePreview({
to={newArtPage(art.id)}
size="small"
variant="outlined"
- icon={}
+ icon={}
>
{t("common:actions.edit")}
@@ -208,11 +206,7 @@ function ImagePreview({
["_action", "DELETE_ART"],
]}
>
- }
- variant="destructive"
- size="small"
- />
+ } variant="destructive" size="small" />
@@ -266,7 +260,7 @@ function ImagePreview({
submitButtonText={t("common:actions.remove")}
>
}
+ icon={
}
variant="destructive"
size="small"
/>
diff --git a/app/features/art/routes/art.new.tsx b/app/features/art/routes/art.new.tsx
index 59d647653..68bc66fc2 100644
--- a/app/features/art/routes/art.new.tsx
+++ b/app/features/art/routes/art.new.tsx
@@ -1,4 +1,5 @@
import Compressor from "compressorjs";
+import { X } from "lucide-react";
import { nanoid } from "nanoid";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -9,7 +10,6 @@ import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import { UserSearch } from "~/components/elements/UserSearch";
import { FormMessage } from "~/components/FormMessage";
-import { CrossIcon } from "~/components/icons/Cross";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { useHasRole } from "~/modules/permissions/hooks";
@@ -119,7 +119,6 @@ function ImageUpload({
{t.name}{" "}
}
+ icon={
}
size="small"
variant="minimal-destructive"
className="art__delete-tag-button"
@@ -354,7 +353,7 @@ function LinkedUsers() {
setUsers(users.filter((u) => u.inputId !== inputId));
}
}}
- icon={
}
+ icon={
}
/>
) : null}
diff --git a/app/features/art/routes/art.tsx b/app/features/art/routes/art.tsx
index 6256a0576..79a1810cf 100644
--- a/app/features/art/routes/art.tsx
+++ b/app/features/art/routes/art.tsx
@@ -1,9 +1,9 @@
import clsx from "clsx";
+import { X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction, ShouldRevalidateFunction } from "react-router";
import { useLoaderData, useSearchParams } from "react-router";
-import { AddNewButton } from "~/components/AddNewButton";
import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import {
@@ -12,11 +12,10 @@ import {
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
-import { CrossIcon } from "~/components/icons/Cross";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import type { SendouRouteHandle } from "~/utils/remix.server";
-import { artPage, navIconUrl, newArtPage } from "~/utils/urls";
+import { artPage, navIconUrl } from "~/utils/urls";
import { metaTags, type SerializeFrom } from "../../../utils/remix";
import { FILTERED_TAG_KEY_SEARCH_PARAM_KEY } from "../art-constants";
import { ArtGrid } from "../components/ArtGrid";
@@ -103,27 +102,21 @@ export default function ArtPage() {
{t("art:openCommissionsOnly")}
-
-
- {
- setSearchParams((prev) => {
- prev.set(
- FILTERED_TAG_KEY_SEARCH_PARAM_KEY,
- tagName as string,
- );
- return prev;
- });
- }}
- />
-
-
+
+ {
+ setSearchParams((prev) => {
+ prev.set(FILTERED_TAG_KEY_SEARCH_PARAM_KEY, tagName as string);
+ return prev;
+ });
+ }}
+ />
{filteredTag ? (
@@ -132,7 +125,7 @@ export default function ArtPage() {
}
+ icon={
}
onPress={() => {
setSearchParams((prev) => {
prev.delete(FILTERED_TAG_KEY_SEARCH_PARAM_KEY);
diff --git a/app/features/articles/routes/a.$slug.tsx b/app/features/articles/routes/a.$slug.tsx
index 456010b8c..3fd8ead70 100644
--- a/app/features/articles/routes/a.$slug.tsx
+++ b/app/features/articles/routes/a.$slug.tsx
@@ -7,7 +7,6 @@ import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
ARTICLES_MAIN_PAGE,
- articlePage,
articlePreviewUrl,
navIconUrl,
} from "~/utils/urls";
@@ -28,11 +27,6 @@ export const handle: SendouRouteHandle = {
href: ARTICLES_MAIN_PAGE,
type: "IMAGE",
},
- {
- text: data.title,
- href: articlePage(data.slug),
- type: "TEXT",
- },
];
},
};
@@ -64,12 +58,38 @@ export default function ArticlePage() {
-
{data.content}
+
+ {contentWithoutLeadingTitle(data.content, data.title)}
+
);
}
+function normalizeText(text: string) {
+ return text
+ .replace(/\*+/g, "")
+ .replace(/…/g, "...")
+ .replace(/\\!/g, "!")
+ .trim();
+}
+
+function contentWithoutLeadingTitle(content: string, title: string) {
+ const trimmed = content.trimStart();
+ const firstLineEnd = trimmed.indexOf("\n");
+ const firstLine =
+ firstLineEnd === -1 ? trimmed : trimmed.slice(0, firstLineEnd);
+
+ if (
+ firstLine.startsWith("# ") &&
+ normalizeText(firstLine.slice(2)) === normalizeText(title)
+ ) {
+ return trimmed.slice(firstLine.length).trimStart();
+ }
+
+ return content;
+}
+
function Author() {
const data = useLoaderData
();
diff --git a/app/features/articles/routes/a.module.css b/app/features/articles/routes/a.module.css
new file mode 100644
index 000000000..899727371
--- /dev/null
+++ b/app/features/articles/routes/a.module.css
@@ -0,0 +1,12 @@
+.list {
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+ gap: var(--s-6);
+ list-style: none;
+}
+
+.title {
+ color: var(--color-text-accent);
+ font-size: var(--font-md);
+}
diff --git a/app/features/articles/routes/a.tsx b/app/features/articles/routes/a.tsx
index 7ca8905d6..2c83ba08c 100644
--- a/app/features/articles/routes/a.tsx
+++ b/app/features/articles/routes/a.tsx
@@ -5,8 +5,8 @@ import { Main } from "~/components/Main";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { ARTICLES_MAIN_PAGE, articlePage, navIconUrl } from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
-
import { loader } from "../loaders/a.server";
+import styles from "./a.module.css";
export { loader };
export const handle: SendouRouteHandle = {
@@ -33,13 +33,10 @@ export default function ArticlesMainPage() {
return (
-
+
{data.articles.map((article) => (
-
-
+
{article.title}
diff --git a/app/features/associations/AssociationRepository.server.ts b/app/features/associations/AssociationRepository.server.ts
index 5dd0b5095..b9d121195 100644
--- a/app/features/associations/AssociationRepository.server.ts
+++ b/app/features/associations/AssociationRepository.server.ts
@@ -3,6 +3,7 @@ import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants";
import { ASSOCIATION } from "~/features/associations/associations-constants";
+import * as FriendRepository from "~/features/friends/FriendRepository.server";
import { LimitReachedError } from "~/utils/errors";
import { shortNanoid } from "~/utils/id";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
@@ -28,6 +29,7 @@ export async function findByMemberUserId(
return {
actual: await findBy({ type: "user", userId }, options),
virtual: await virtualAssociationsByUserId(userId),
+ friendIds: await FriendRepository.findFriendIds(userId),
};
}
@@ -94,6 +96,10 @@ async function findBy(
}));
}
+const DEFAULT_VIRTUAL_ASSOCIATIONS: Array
= [
+ "FRIENDS",
+];
+
async function virtualAssociationsByUserId(
userId: number,
): Promise> {
@@ -103,14 +109,16 @@ async function virtualAssociationsByUserId(
.select(["PlusTier.tier as plusTier"])
.where("userId", "=", userId)
.executeTakeFirst()) ?? {};
- if (!plusTier) return [];
+ if (!plusTier) return [...DEFAULT_VIRTUAL_ASSOCIATIONS];
- if (plusTier === 1) return ["+1", "+2", "+3"] as const;
- if (plusTier === 2) return ["+2", "+3"] as const;
- if (plusTier === 3) return ["+3"] as const;
+ if (plusTier === 1)
+ return [...DEFAULT_VIRTUAL_ASSOCIATIONS, "+1", "+2", "+3"] as const;
+ if (plusTier === 2)
+ return [...DEFAULT_VIRTUAL_ASSOCIATIONS, "+2", "+3"] as const;
+ if (plusTier === 3) return [...DEFAULT_VIRTUAL_ASSOCIATIONS, "+3"] as const;
logger.error("Invalid plusTier", { plusTier });
- return [];
+ return [...DEFAULT_VIRTUAL_ASSOCIATIONS];
}
type InsertArgs = Omit & {
diff --git a/app/features/associations/associations-constants.ts b/app/features/associations/associations-constants.ts
index bf3bc6dad..363b32a49 100644
--- a/app/features/associations/associations-constants.ts
+++ b/app/features/associations/associations-constants.ts
@@ -1,5 +1,5 @@
export const ASSOCIATION = {
- VIRTUAL_IDENTIFIERS: ["+1", "+2", "+3"] as const,
+ VIRTUAL_IDENTIFIERS: ["+1", "+2", "+3", "FRIENDS"] as const,
MAX_COUNT_REGULAR_USER: 3,
MAX_COUNT_SUPPORTER: 6,
MAX_ASSOCIATION_MEMBER_COUNT: 300,
diff --git a/app/features/associations/core/Association.test.ts b/app/features/associations/core/Association.test.ts
index 288b259f1..3919056a3 100644
--- a/app/features/associations/core/Association.test.ts
+++ b/app/features/associations/core/Association.test.ts
@@ -103,6 +103,63 @@ describe("isVisible", () => {
expect(Association.isVisible(args)).toBe(true);
});
+ it("should return true if viewer is a friend of the content owner", () => {
+ const args: Association.IsVisibleArgs = {
+ visibility: { forAssociation: "FRIENDS" },
+ time: new Date(),
+ associations: {
+ actual: [],
+ virtual: [],
+ friendIds: [42],
+ },
+ contentOwnerUserId: 42,
+ };
+ expect(Association.isVisible(args)).toBe(true);
+ });
+
+ it("should return false if viewer is not a friend of the content owner", () => {
+ const args: Association.IsVisibleArgs = {
+ visibility: { forAssociation: "FRIENDS" },
+ time: new Date(),
+ associations: {
+ actual: [],
+ virtual: [],
+ friendIds: [99],
+ },
+ contentOwnerUserId: 42,
+ };
+ expect(Association.isVisible(args)).toBe(false);
+ });
+
+ it("should return false for FRIENDS visibility when not logged in", () => {
+ const args: Association.IsVisibleArgs = {
+ visibility: { forAssociation: "FRIENDS" },
+ time: new Date(),
+ associations: null,
+ };
+ expect(Association.isVisible(args)).toBe(false);
+ });
+
+ it("should return true when FRIENDS visibility becomes public via notFoundInstructions", () => {
+ const visibleAt = add(new Date(), { days: 1 });
+
+ const args: Association.IsVisibleArgs = {
+ visibility: {
+ forAssociation: "FRIENDS",
+ notFoundInstructions: [
+ { at: dateToDatabaseTimestamp(visibleAt), forAssociation: null },
+ ],
+ },
+ time: add(new Date(), { days: 2 }),
+ associations: {
+ actual: [],
+ virtual: [],
+ friendIds: [],
+ },
+ };
+ expect(Association.isVisible(args)).toBe(true);
+ });
+
it("should return true if has become public (no associations)", () => {
const visibleAt = add(new Date(), { days: 1 });
diff --git a/app/features/associations/core/Association.ts b/app/features/associations/core/Association.ts
index cf684e13d..2d2ef3bac 100644
--- a/app/features/associations/core/Association.ts
+++ b/app/features/associations/core/Association.ts
@@ -8,7 +8,9 @@ export interface IsVisibleArgs {
associations: {
virtual: Array;
actual: Array<{ id: number }>;
+ friendIds?: Array;
} | null;
+ contentOwnerUserId?: number;
}
export function isVisible(args: IsVisibleArgs) {
@@ -29,6 +31,14 @@ export function isVisible(args: IsVisibleArgs) {
if (isPublic) return true;
+ if (
+ currentVisibility.includes("FRIENDS") &&
+ args.contentOwnerUserId &&
+ args.associations?.friendIds?.includes(args.contentOwnerUserId)
+ ) {
+ return true;
+ }
+
return (
args.associations?.actual.some((association) =>
currentVisibility.includes(association.id),
diff --git a/app/features/associations/routes/associations.tsx b/app/features/associations/routes/associations.tsx
index ef95deb26..cdefe5f01 100644
--- a/app/features/associations/routes/associations.tsx
+++ b/app/features/associations/routes/associations.tsx
@@ -1,14 +1,11 @@
+import { Check, Clipboard, Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, Outlet, useFetcher, useLoaderData } from "react-router";
import { useCopyToClipboard } from "react-use";
-import { AddNewButton } from "~/components/AddNewButton";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
-import { CheckmarkIcon } from "~/components/icons/Checkmark";
-import { ClipboardIcon } from "~/components/icons/Clipboard";
-import { TrashIcon } from "~/components/icons/Trash";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
@@ -20,7 +17,7 @@ import {
import { useUser } from "~/features/auth/core/user";
import { useHasPermission } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
-import { associationsPage, newAssociationsPage, userPage } from "~/utils/urls";
+import { associationsPage, userPage } from "~/utils/urls";
export { loader, action };
export const handle: SendouRouteHandle = {
@@ -34,9 +31,6 @@ export default function AssociationsPage() {
@@ -97,7 +91,7 @@ function Association({
return (
-
+
{association.name}
{canManage ? (
}
+ shape="square"
+ icon={}
className="small-text"
variant="minimal-destructive"
type="submit"
@@ -197,9 +192,10 @@ function AssociationInviteCodeActions({
copyToClipboard(inviteLink)}
- icon={copySuccess ? : }
+ icon={copySuccess ? : }
aria-label="Copy to clipboard"
/>
@@ -208,7 +204,6 @@ function AssociationInviteCodeActions({
@@ -233,7 +228,7 @@ function AssociationMember({
const { t } = useTranslation(["common", "scrims"]);
return (
-
+
}
+ shape="square"
+ icon={
}
className="small-text"
variant="minimal-destructive"
+ size="small"
type="submit"
/>
diff --git a/app/features/auth/core/routes.server.ts b/app/features/auth/core/routes.server.ts
index d0af10012..69f57bcfa 100644
--- a/app/features/auth/core/routes.server.ts
+++ b/app/features/auth/core/routes.server.ts
@@ -3,8 +3,9 @@ import type { ActionFunction, LoaderFunction } from "react-router";
import { redirect } from "react-router";
import { z } from "zod";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
+import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
-import { requireRole } from "~/modules/permissions/guards.server";
+import { isAdmin, isStaff } from "~/modules/permissions/utils";
import { logger } from "~/utils/logger";
import {
canAccessLohiEndpoint,
@@ -74,19 +75,36 @@ export const logInAction: ActionFunction = async ({ request }) => {
export const impersonateAction: ActionFunction = async ({ request }) => {
if (!DANGEROUS_CAN_ACCESS_DEV_CONTROLS) {
- requireRole("ADMIN");
+ const user = requireUser();
+ if (!user.roles.includes("ADMIN") && !user.roles.includes("DEV")) {
+ throw new Response("Forbidden", { status: 403 });
+ }
+
+ if (user.roles.includes("DEV") && !user.roles.includes("ADMIN")) {
+ const url = new URL(request.url);
+ const targetId = Number(url.searchParams.get("id"));
+ if (isAdmin({ id: targetId }) || isStaff({ id: targetId })) {
+ throw new Response("Forbidden", { status: 403 });
+ }
+ }
}
const session = await authSessionStorage.getSession(
request.headers.get("Cookie"),
);
+ const realUserId = session.get(SESSION_KEY);
+
const url = new URL(request.url);
const rawId = url.searchParams.get("id");
const userId = Number(url.searchParams.get("id"));
if (!rawId || Number.isNaN(userId)) throw new Response(null, { status: 400 });
+ logger.info(
+ `Impersonation: user ${realUserId} started impersonating user ${userId}`,
+ );
+
session.set(IMPERSONATED_SESSION_KEY, userId);
throw redirect(ADMIN_PAGE, {
@@ -99,6 +117,13 @@ export const stopImpersonatingAction: ActionFunction = async ({ request }) => {
request.headers.get("Cookie"),
);
+ const realUserId = session.get(SESSION_KEY);
+ const impersonatedUserId = session.get(IMPERSONATED_SESSION_KEY);
+
+ logger.info(
+ `Impersonation: user ${realUserId} stopped impersonating user ${impersonatedUserId}`,
+ );
+
session.unset(IMPERSONATED_SESSION_KEY);
throw redirect(ADMIN_PAGE, {
diff --git a/app/features/auth/core/user.server.ts b/app/features/auth/core/user.server.ts
index b00e9140a..1e9a171c0 100644
--- a/app/features/auth/core/user.server.ts
+++ b/app/features/auth/core/user.server.ts
@@ -1,4 +1,4 @@
-import { IMPERSONATED_SESSION_KEY } from "./authenticator.server";
+import { IMPERSONATED_SESSION_KEY, SESSION_KEY } from "./authenticator.server";
import { authSessionStorage } from "./session.server";
import { type AuthenticatedUser, getUserContext } from "./user-context.server";
@@ -24,3 +24,13 @@ export async function isImpersonating(request: Request) {
return Boolean(session.get(IMPERSONATED_SESSION_KEY));
}
+
+export async function getRealUserId(
+ request: Request,
+): Promise
{
+ const session = await authSessionStorage.getSession(
+ request.headers.get("Cookie"),
+ );
+
+ return session.get(SESSION_KEY) as number | undefined;
+}
diff --git a/app/features/badges/badges.module.css b/app/features/badges/badges.module.css
new file mode 100644
index 000000000..e2d850e82
--- /dev/null
+++ b/app/features/badges/badges.module.css
@@ -0,0 +1,108 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ border-radius: var(--radius-box);
+ background-color: var(--color-bg-badge);
+ color: var(--color-text-high);
+ gap: var(--s-6);
+ padding-block: var(--s-2);
+ padding-inline: var(--s-3);
+}
+
+.smallBadges {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: var(--s-2);
+ margin-block-start: var(--s-1);
+}
+
+.generalInfoTexts {
+ display: flex;
+ justify-content: space-between;
+ color: var(--color-text-high);
+ font-size: var(--font-xs);
+ padding-inline: var(--s-1);
+}
+
+.explanation {
+ color: var(--color-text-accent);
+ font-weight: var(--weight-semi);
+ text-align: center;
+}
+
+.managers {
+ color: var(--color-text-high);
+ font-size: var(--font-2xs);
+ text-align: center;
+}
+
+.ownersContainer {
+ height: 8rem;
+ overflow-y: auto;
+}
+
+.owners {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ padding: 0;
+ font-size: var(--font-sm);
+ gap: var(--s-1-5);
+}
+
+.owners > li {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ line-height: 1.1;
+ list-style: none;
+}
+
+.count {
+ color: var(--color-accent-high);
+ font-size: var(--font-xs);
+}
+
+.editUsersList > li {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ list-style: none;
+}
+
+.editUsersList {
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+ font-size: var(--font-xs);
+ font-weight: var(--weight-semi);
+ gap: var(--s-2);
+ padding-block-end: var(--s-3);
+}
+
+.editNumberInput {
+ max-width: 5rem;
+}
+
+.editSmallHeader {
+ font-size: var(--font-md);
+}
+
+.editDifferences {
+ padding: 0;
+ font-size: var(--font-xs);
+ list-style: none;
+}
+
+.editDifferences > li::before {
+ content: "-";
+ padding-inline-end: 5px;
+}
+
+.searchInput {
+ height: 40px !important;
+ margin: 0 auto;
+ font-size: var(--font-lg);
+}
diff --git a/app/features/badges/components/BadgeDisplay.module.css b/app/features/badges/components/BadgeDisplay.module.css
index 90dc0e98b..d9e3fd3dc 100644
--- a/app/features/badges/components/BadgeDisplay.module.css
+++ b/app/features/badges/components/BadgeDisplay.module.css
@@ -5,8 +5,8 @@
min-height: 12rem;
align-items: center;
padding: var(--s-2);
- border-radius: var(--rounded);
- background-color: var(--bg-badge);
+ border-radius: var(--radius-box);
+ background-color: var(--color-bg-badge);
margin-inline: auto;
}
@@ -20,8 +20,8 @@
}
.badgeExplanation {
- color: var(--text-lighter);
- font-size: var(--fonts-xs);
+ color: var(--color-text-high);
+ font-size: var(--font-xs);
display: flex;
align-items: center;
justify-content: center;
@@ -40,15 +40,15 @@
margin-top: -8px;
margin-right: auto;
margin-left: auto;
- color: var(--theme-vibrant);
- font-size: var(--fonts-xxxs);
- font-weight: var(--bold);
+ color: var(--color-accent-high);
+ font-size: var(--font-2xs);
+ font-weight: var(--weight-bold);
}
.pagination {
display: flex;
flex-wrap: wrap;
- gap: var(--s-2-5);
+ gap: var(--s-3);
justify-content: center;
align-items: center;
max-width: 20rem;
@@ -57,17 +57,18 @@
}
.paginationButton {
- background-color: var(--bg-darker);
+ background-color: var(--color-bg);
border-radius: 100%;
padding: var(--s-1);
height: 24px;
width: 24px;
- border: 2px solid var(--border);
- font-size: var(--fonts-xs);
- color: var(--text-lighter);
+ border: var(--border-style);
+ font-size: var(--font-xs);
+ color: var(--color-text-high);
}
.paginationButtonActive {
- color: var(--theme);
- background-color: var(--bg-lightest);
+ color: var(--color-text-accent);
+ background-color: var(--color-bg-high);
+ border-color: var(--color-border-high);
}
diff --git a/app/features/badges/components/BadgeDisplay.tsx b/app/features/badges/components/BadgeDisplay.tsx
index 1a461bf65..3d9b5cc7c 100644
--- a/app/features/badges/components/BadgeDisplay.tsx
+++ b/app/features/badges/components/BadgeDisplay.tsx
@@ -1,9 +1,9 @@
import clsx from "clsx";
+import { Trash } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "~/components/Badge";
import { SendouButton } from "~/components/elements/Button";
-import { TrashIcon } from "~/components/icons/Trash";
import type { Tables } from "~/db/tables";
import { BADGE } from "~/features/badges/badges-constants";
import { usePagination } from "~/hooks/usePagination";
@@ -96,7 +96,7 @@ export function BadgeDisplay({
{badgeExplanationText(t, bigBadge)}
{onChange ? (
}
+ icon={}
variant="minimal-destructive"
onPress={() =>
onChange(
diff --git a/app/features/badges/routes/badges.$id.edit.tsx b/app/features/badges/routes/badges.$id.edit.tsx
index 3a96cc66e..c7aea85de 100644
--- a/app/features/badges/routes/badges.$id.edit.tsx
+++ b/app/features/badges/routes/badges.$id.edit.tsx
@@ -1,13 +1,14 @@
+import { Trash } from "lucide-react";
import * as React from "react";
import { Form, useMatches, useOutletContext } from "react-router";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { UserSearch } from "~/components/elements/UserSearch";
-import { TrashIcon } from "~/components/icons/Trash";
import type { Tables } from "~/db/tables";
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
import { action } from "../actions/badges.$id.edit.server";
+import styles from "../badges.module.css";
import type { BadgeDetailsLoaderData } from "../loaders/badges.$id.server";
import type { BadgeDetailsContext } from "./badges.$id";
export { action };
@@ -24,7 +25,6 @@ export default function EditBadgePage() {