Docker setup for development (#2460)
Some checks are pending
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle 2025-07-20 16:58:21 +03:00 committed by GitHub
parent cfb103800d
commit baa4b43855
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 122 additions and 93 deletions

View File

@ -10,12 +10,12 @@ DISCORD_CLIENT_SECRET=
PATREON_ACCESS_TOKEN=
// Image upload
STORAGE_END_POINT=
STORAGE_ACCESS_KEY=
STORAGE_SECRET=
STORAGE_REGION=
STORAGE_BUCKET=
STORAGE_URL=
STORAGE_END_POINT=http://127.0.0.1:9000
STORAGE_ACCESS_KEY=minio-user
STORAGE_SECRET=minio-password
STORAGE_REGION=us-east-1
STORAGE_BUCKET=sendou
STORAGE_URL=http://127.0.0.1:9000
// Twitch integration for fetching streams
TWITCH_CLIENT_ID=
@ -23,6 +23,7 @@ TWITCH_CLIENT_SECRET=
SKALOP_SYSTEM_MESSAGE_URL=http://localhost:5900/system
SKALOP_TOKEN=secret
REDIS_URL=redis://redis:6379
VITE_SITE_DOMAIN=http://localhost:5173
VITE_SKALOP_WS_URL=ws://localhost:5900

View File

@ -90,6 +90,16 @@ You should then be able to access the application by visiting http://localhost:5
Use the admin panel at http://localhost:5173/admin to log in (impersonate) as the admin user "Sendou" or as a regular user "N-ZAP" as well as re-seed the database if needed.
#### Docker
Optionally, if you want to develop image upload, real-time features or chat, you can use Docker to spin up the Skalop service and Minio for image hosting. You will need [Docker](https://www.docker.com/) up and running and then run the following command:
```
docker compose up -d
```
Minio admin UI to manage uploaded photos should be up and running at http://localhost:9001
## Contributing
- **Developers**: Read [CONTRIBUTING.md](./CONTRIBUTING.md)

View File

@ -1616,25 +1616,12 @@ const detailedTeam = (seedVariation?: SeedVariation | null) => () => {
sql
.prepare(
/* sql */ `
insert into "UnvalidatedUserSubmittedImage" ("validatedAt", "url", "submitterUserId")
values
(1672587342, 'AiGSM5T-cxm6BFGT7N_lA-1673297699133.webp', ${ADMIN_ID}),
(1672587342, 'jTbWd95klxU2MzGFIdi1c-1673297932788.webp', ${ADMIN_ID})
`,
)
.run();
sql
.prepare(
/* sql */ `
insert into "AllTeam" ("name", "customUrl", "inviteCode", "bio", "avatarImgId", "bannerImgId")
insert into "AllTeam" ("name", "customUrl", "inviteCode", "bio")
values (
'Alliance Rogue',
'alliance-rogue',
'${shortNanoid()}',
'${faker.lorem.paragraph()}',
1,
2
'${faker.lorem.paragraph()}'
)
`,
)

View File

@ -4,7 +4,7 @@ import { cors } from "remix-utils/cors";
import { z } from "zod/v4";
import { db } from "~/db/sql";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { userSubmittedImage } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,

View File

@ -9,7 +9,7 @@ import i18next from "~/modules/i18n/i18next.server";
import { nullifyingAvg } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import { parseParams } from "~/utils/remix.server";
import { userSubmittedImage } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,

View File

@ -6,7 +6,7 @@ import { db } from "~/db/sql";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { databaseTimestampToDate } from "~/utils/dates";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { userSubmittedImage } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,

View File

@ -15,13 +15,8 @@ import { useIsMounted } from "~/hooks/useIsMounted";
import { usePagination } from "~/hooks/usePagination";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { databaseTimestampToDate } from "~/utils/dates";
import {
artPage,
conditionalUserSubmittedImage,
newArtPage,
userArtPage,
userPage,
} from "~/utils/urls";
import { artPage, newArtPage, userArtPage, userPage } from "~/utils/urls";
import { conditionalUserSubmittedImage } from "~/utils/urls-img";
import { ResponsiveMasonry } from "../../../modules/responsive-masonry/components/ResponsiveMasonry";
import { ART_PER_PAGE } from "../art-constants";
import type { ListedArt } from "../art-types";

View File

@ -17,11 +17,8 @@ import { useHasRole } from "~/modules/permissions/hooks";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
artPage,
conditionalUserSubmittedImage,
navIconUrl,
} from "~/utils/urls";
import { artPage, navIconUrl } from "~/utils/urls";
import { conditionalUserSubmittedImage } from "~/utils/urls-img";
import { metaTitle } from "../../../utils/remix";
import { action } from "../actions/art.new.server";
import { ART } from "../art-constants";

View File

@ -11,7 +11,8 @@ import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { databaseTimestampToDate } from "~/utils/dates";
import { navIconUrl, userSubmittedImage } from "~/utils/urls";
import { navIconUrl } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import type { CalendarEvent, ShowcaseCalendarEvent } from "../calendar-types";
import { Tags } from "./Tags";
import styles from "./TournamentCard.module.css";

View File

@ -32,7 +32,8 @@ import {
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { pathnameFromPotentialURL } from "~/utils/strings";
import { CREATING_TOURNAMENT_DOC_LINK, userSubmittedImage } from "~/utils/urls";
import { CREATING_TOURNAMENT_DOC_LINK } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import {
CALENDAR_EVENT,
REG_CLOSES_AT_OPTIONS,

View File

@ -7,12 +7,8 @@ import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderbo
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import {
discordAvatarUrl,
teamPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { discordAvatarUrl, teamPage, userPage } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import * as ShowcaseTournaments from "../core/ShowcaseTournaments.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {

View File

@ -5,7 +5,7 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
import { TrashIcon } from "~/components/icons/Trash";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { userSubmittedImage } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { action } from "../actions/upload.admin.server";
import { loader } from "../loaders/upload.admin.server";

View File

@ -19,8 +19,8 @@ import {
topSearchPlayerPage,
userPage,
userSeasonsPage,
userSubmittedImage,
} from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { InfoPopover } from "../../../components/InfoPopover";
import { TopTenPlayer } from "../components/TopTenPlayer";
import {

View File

@ -17,12 +17,8 @@ import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import {
lfgNewPostPage,
navIconUrl,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { lfgNewPostPage, navIconUrl, userPage } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { hourDifferenceBetweenTimezones } from "../core/timezone";
import type { LFGLoaderData, TiersMap } from "../routes/lfg";

View File

@ -13,6 +13,7 @@ import { cancelScrimSchema } from "~/features/scrims/scrims-schemas";
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
import { useHasPermission } from "~/modules/permissions/hooks";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { userSubmittedImage } from "~/utils/urls-img";
import { Avatar } from "../../../components/Avatar";
import { Main } from "../../../components/Main";
import { databaseTimestampToDate } from "../../../utils/dates";
@ -22,7 +23,6 @@ import {
scrimsPage,
teamPage,
userPage,
userSubmittedImage,
} from "../../../utils/urls";
import { ConnectedChat } from "../../chat/components/Chat";
import { action } from "../actions/scrims.$id.server";

View File

@ -33,8 +33,8 @@ import {
scrimPage,
scrimsPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import {
SendouTab,
SendouTabList,

View File

@ -66,8 +66,8 @@ import {
sendouQMatchPage,
specialWeaponImageUrl,
teamPage,
userSubmittedImage,
} from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { action } from "../actions/q.match.$id.server";
import { matchEndedAtIndex } from "../core/match";
import { loader } from "../loaders/q.match.$id.server";

View File

@ -9,12 +9,8 @@ import { Table } from "~/components/Table";
import type { TeamResultsLoaderData } from "~/features/team/loaders/t.$customUrl.results.server";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { databaseTimestampToDate } from "~/utils/dates";
import {
tournamentLogoUrl,
tournamentTeamPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { tournamentLogoUrl, tournamentTeamPage, userPage } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import styles from "./TeamResultsTable.module.css";

View File

@ -7,13 +7,8 @@ import { BskyIcon } from "~/components/icons/Bsky";
import { Main } from "~/components/Main";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
bskyUrl,
navIconUrl,
TEAM_SEARCH_PAGE,
teamPage,
userSubmittedImage,
} from "~/utils/urls";
import { bskyUrl, navIconUrl, TEAM_SEARCH_PAGE, teamPage } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { loader } from "../loaders/t.$customUrl.server";
export { loader };

View File

@ -22,8 +22,8 @@ import {
navIconUrl,
TEAM_SEARCH_PAGE,
teamPage,
userSubmittedImage,
} from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { action } from "../actions/t.server";
import { loader } from "../loaders/t.server";
import { TEAM, TEAMS_PER_PAGE } from "../team-constants";

View File

@ -24,7 +24,7 @@ import {
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { assertUnreachable } from "~/utils/types";
import { userSubmittedImage } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import {
fillWithNullTillPowerOfTwo,
groupNumberToLetters,

View File

@ -4,7 +4,8 @@ import { db } from "~/db/sql";
import type { Tables, TablesInsertable } from "~/db/tables";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { mySlugify, userSubmittedImage } from "~/utils/urls";
import { mySlugify } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { HACKY_resolvePicture } from "../tournament/tournament-utils";
import { TOURNAMENT_SERIES_EVENTS_PER_PAGE } from "./tournament-organization-constants";

View File

@ -32,8 +32,8 @@ import {
tournamentOrganizationPage,
tournamentPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { action } from "../actions/org.$slug.server";
import { EventCalendar } from "../components/EventCalendar";
import { SocialLinksList } from "../components/SocialLinksList";

View File

@ -16,7 +16,7 @@ import { nullFilledArray, nullifyingAvg } from "~/utils/arrays";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server";
import type { Unwrapped } from "~/utils/types";
import { userSubmittedImage } from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { HACKY_resolvePicture } from "./tournament-utils";
export type FindById = NonNullable<Unwrapped<typeof findById>>;

View File

@ -54,8 +54,8 @@ import {
tournamentSubsPage,
userEditProfilePage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { AlertIcon } from "../../../components/icons/Alert";
import { action } from "../actions/to.$id.register.server";
import type { TournamentRegisterPageLoader } from "../loaders/to.$id.register.server";

View File

@ -18,8 +18,8 @@ import {
tournamentMatchPage,
tournamentTeamPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { TeamWithRoster } from "../components/TeamWithRoster";
import * as Standings from "../core/Standings";
import type { PlayedSet } from "../core/sets.server";

View File

@ -20,8 +20,8 @@ import {
tournamentOrganizationPage,
tournamentPage,
tournamentRegisterPage,
userSubmittedImage,
} from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { metaTags } from "../../../utils/remix";
import { loader, type TournamentLoaderData } from "../loaders/to.$id.server";

View File

@ -14,8 +14,8 @@ import {
tournamentLogoUrl,
tournamentTeamPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import type { UserResultsLoaderData } from "../loaders/u.$identifier.results.server";
import { ParticipationPill } from "./ParticipationPill";

View File

@ -224,7 +224,7 @@ function WeaponsSelector() {
return newWeapons;
})
}
initialValue={weapon ?? undefined}
value={weapon ?? null}
testId={`weapon-${i}`}
/>
{i === weapons.length - 1 && (

View File

@ -23,8 +23,8 @@ import {
navIconUrl,
teamPage,
topSearchPlayerPage,
userSubmittedImage,
} from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import { loader } from "../loaders/u.$identifier.index.server";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
export { loader };

View File

@ -56,8 +56,8 @@ import {
TIERS_PAGE,
tournamentTeamPage,
userSeasonsPage,
userSubmittedImage,
} from "~/utils/urls";
import { userSubmittedImage } from "~/utils/urls-img";
import {
loader,
type UserSeasonsPageLoaderData,

View File

@ -320,7 +320,7 @@ function MatchesFieldset({
key={i}
isRequired
testId={`player-${i}-weapon`}
initialValue={value[i]}
value={value[i] ?? null}
onChange={(weaponId) => {
const weapons = [...value];
weapons[i] = weaponId;
@ -343,7 +343,7 @@ function MatchesFieldset({
key={i}
isRequired
testId={`player-${adjustedI}-weapon`}
initialValue={value[adjustedI]}
value={value[adjustedI] ?? null}
onChange={(weaponId) => {
const weapons = [...value];
weapons[adjustedI] = weaponId;
@ -361,7 +361,7 @@ function MatchesFieldset({
label={t("vods:forms.title.weapon")}
isRequired
testId={`match-${idx}-weapon`}
initialValue={value[0]}
value={value[0] ?? null}
onChange={(weaponId) => onChange([weaponId])}
/>
)}

15
app/utils/urls-img.ts Normal file
View File

@ -0,0 +1,15 @@
// TODO: separating this file from urls.ts is a temporary solution. The reason is that import.meta.env cannot currently be used in files that are consumed by plain Node.js
const USER_SUBMITTED_IMAGE_ROOT =
process.env.NODE_ENV === "development" &&
import.meta.env.VITE_PROD_MODE !== "true"
? "http://127.0.0.1:9000/sendou"
: "https://sendou.nyc3.cdn.digitaloceanspaces.com";
// TODO: move development images to minio and deprecate this hack
// images with https are not hosted on spaces, this is used for local development
export const conditionalUserSubmittedImage = (fileName: string) =>
fileName.includes("https") ? fileName : userSubmittedImage(fileName);
export const userSubmittedImage = (fileName: string) =>
`${USER_SUBMITTED_IMAGE_ROOT}/${fileName}`;

View File

@ -53,14 +53,6 @@ export const BADGES_DOC_LINK =
export const CREATING_TOURNAMENT_DOC_LINK =
"https://github.com/sendou-ink/sendou.ink/blob/rewrite/docs/tournament-creation.md";
const USER_SUBMITTED_IMAGE_ROOT =
"https://sendou.nyc3.cdn.digitaloceanspaces.com";
export const userSubmittedImage = (fileName: string) =>
`${USER_SUBMITTED_IMAGE_ROOT}/${fileName}`;
// images with https are not hosted on spaces, this is used for local development
export const conditionalUserSubmittedImage = (fileName: string) =>
fileName.includes("https") ? fileName : userSubmittedImage(fileName);
export const PLUS_SERVER_DISCORD_URL = "https://discord.gg/FW4dKrY";
export const SENDOU_INK_DISCORD_URL = "https://discord.gg/sendou";
export const SENDOU_INK_PATREON_URL = "https://patreon.com/sendou";

41
compose.yaml Normal file
View File

@ -0,0 +1,41 @@
services:
minio:
image: "minio/minio:latest"
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minio-user
MINIO_ROOT_PASSWORD: minio-password
entrypoint: >
/bin/sh -c '
isAlive() { curl -sf http://127.0.0.1:9000/minio/health/live; } # check if Minio is alive
minio $$0 "$$@" --quiet & echo $$! > /tmp/minio.pid # start Minio in the background
while ! isAlive; do sleep 0.1; done # wait until Minio is alive
mc alias set minio http://127.0.0.1:9000 minio-user minio-password # setup Minio client
mc mb minio/sendou || true # create a test bucket
mc anonymous set public minio/sendou # make the test bucket public
kill -s INT $$(cat /tmp/minio.pid) && rm /tmp/minio.pid # stop Minio
while isAlive; do sleep 0.1; done # wait until Minio is stopped
exec minio $$0 "$$@" # start Minio in the foreground
'
volumes:
- ~/minio/data:/data
command: server /data --console-address ":9001"
skalop:
image: ghcr.io/sendou-ink/skalop:latest
ports:
- "5900:5900"
environment:
- REDIS_URL=${REDIS_URL}
- SKALOP_TOKEN=${SKALOP_TOKEN}
- SESSION_SECRET=${SESSION_SECRET}
- PORT=5900
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:alpine
restart: unless-stopped

View File

@ -8,8 +8,13 @@ async function main() {
// Step 1: Create .env if it doesn't exist
if (!fs.existsSync(".env")) {
logger.info("📄 .env not found. Creating from .env.example...");
fs.copyFileSync(".env.example", ".env");
logger.info(".env created with defaults values");
const envContent = fs.readFileSync(".env.example", "utf-8");
const filteredEnv = envContent
.split("\n")
.filter((line) => !line.trim().startsWith("//")) // remove comments to prevent issues with Docker
.join("\n");
fs.writeFileSync(".env", filteredEnv);
logger.info(".env created with default values");
}
const dbEmpty = !(await db.selectFrom("User").selectAll().executeTakeFirst());