diff --git a/app/db/tables.ts b/app/db/tables.ts index 6d213dcace..2b95db447d 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -69,10 +69,12 @@ export interface ArtUserMetadata { } export interface Badge { + id: GeneratedAlways; code: string; displayName: string; hue: number | null; - id: GeneratedAlways; + /** Who made the badge? If null, a legacy badge. */ + authorId: number | null; } export interface BadgeManager { diff --git a/app/features/badges/BadgeRepository.server.ts b/app/features/badges/BadgeRepository.server.ts index 4f4bb03677..b4170cffc4 100644 --- a/app/features/badges/BadgeRepository.server.ts +++ b/app/features/badges/BadgeRepository.server.ts @@ -1,4 +1,4 @@ -import { jsonArrayFrom } from "kysely/helpers/sqlite"; +import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; import type { Unwrapped } from "~/utils/types"; @@ -17,6 +17,12 @@ export async function all() { .whereRef("BadgeManager.badgeId", "=", "Badge.id") .select(["userId"]), ).as("managers"), + jsonObjectFrom( + eb + .selectFrom("User") + .select(COMMON_USER_FIELDS) + .whereRef("User.id", "=", "Badge.authorId"), + ).as("author"), ]) .execute(); diff --git a/app/features/badges/components/BadgeDisplay.tsx b/app/features/badges/components/BadgeDisplay.tsx index 05a4943ba0..1b80673620 100644 --- a/app/features/badges/components/BadgeDisplay.tsx +++ b/app/features/badges/components/BadgeDisplay.tsx @@ -9,7 +9,7 @@ import type { Unpacked } from "~/utils/types"; import { badgeExplanationText } from "../badges-utils"; interface BadgeDisplayProps { - badges: Array; + badges: Array & { count?: number }>; onBadgeRemove?: (badgeId: number) => void; } diff --git a/app/features/badges/homemade.ts b/app/features/badges/homemade.ts new file mode 100644 index 0000000000..9e0961b877 --- /dev/null +++ b/app/features/badges/homemade.ts @@ -0,0 +1,17 @@ +interface BadgeInfo { + // The name of the badge as it shows on the web page: "Awarded for winning {displayName}" + displayName: string; + // The file name of the badge: fileName.png, fileName.avif & fileName.gif + fileName: string; + // The Discord ID of the person who made the badge (not the person who commissioned it) + authorDiscordId: string; +} + +export const homemadeBadges: BadgeInfo[] = [ + // EXAMPLE + // { + // displayName: "Example Badge", + // fileName: "example", + // authorDiscordId: "123456789012345678", + // }, +]; diff --git a/app/features/badges/routes/badges.$id.tsx b/app/features/badges/routes/badges.$id.tsx index 81aa76ef36..01b80d892c 100644 --- a/app/features/badges/routes/badges.$id.tsx +++ b/app/features/badges/routes/badges.$id.tsx @@ -50,14 +50,15 @@ export default function BadgeDetailsPage() {
{badgeExplanationText(t, badge)}
-
+
{t("managedBy", { - users: data.managers.map((m) => m.username).join(", "), + users: data.managers.map((m) => m.username).join(", ") || "???", + })}{" "} + ( + {t("madeBy", { + user: badge.author?.username ?? "borzoic", })} + )
{isMod(user) || canEditBadgeOwners({ user, managers: data.managers }) ? ( diff --git a/app/features/badges/routes/badges.tsx b/app/features/badges/routes/badges.tsx index b623953160..6b31383e97 100644 --- a/app/features/badges/routes/badges.tsx +++ b/app/features/badges/routes/badges.tsx @@ -1,7 +1,6 @@ import type { SerializeFrom } from "@remix-run/node"; -import { NavLink, Outlet, useLoaderData } from "@remix-run/react"; +import { Link, NavLink, Outlet, useLoaderData } from "@remix-run/react"; import * as React from "react"; -import { Trans } from "react-i18next"; import { useTranslation } from "react-i18next"; import { Badge } from "~/components/Badge"; import { Divider } from "~/components/Divider"; @@ -10,7 +9,7 @@ import { Main } from "~/components/Main"; import { SearchIcon } from "~/components/icons/Search"; import { useUser } from "~/features/auth/core/user"; import type { SendouRouteHandle } from "~/utils/remix"; -import { BADGES_PAGE, BORZOIC_TWITTER, navIconUrl } from "~/utils/urls"; +import { BADGES_PAGE, FAQ_PAGE, navIconUrl } from "~/utils/urls"; import * as BadgeRepository from "../BadgeRepository.server"; import "~/styles/badges.css"; @@ -98,16 +97,8 @@ export default function BadgesPageLayout() {

- - Badges by{" "} - - borzoic - - -

- {/*

{t("forYourEvent")} -

*/} +

); diff --git a/docs/badges.md b/docs/badges.md new file mode 100644 index 0000000000..0537af3936 --- /dev/null +++ b/docs/badges.md @@ -0,0 +1,62 @@ +# Badge guide + +## What are badges? + +Badges are a virtual prize that users can win from tournaments and then display on their profile. + +Current list of them can be seen here https://sendou.ink/badges + +## Rules + +Any badge to be added has to follow these rules. Badges that do not follow the rules will not be added to the site or can be removed at any time: + +**Quality and consistency** has to be matching those already on the site. + +**Uniqueness** i.e. the added badge has to be different enough from those already on the site. + +**Variations** for a maximum of 3 per unique design (e.g. 1st, 2nd & 3rd place variations). For further additions it has be a completely different and unique design and not just recolor or small design changes. + +**No AI** every badge has to be made by a human. + +**Made for sendou.ink/approved usage** badges have to be commissioned for this exact purpose or otherwise agreed with the person who made them so that they fully understand what they are used for. + +**As tournament prizes** the badge are meant to be given out as tournament prizes (awarded for reaching certain placement). If you have any other idea in mind then you need to check that with Sendou separately. + +## Adding a new badge + +1. First badge needs to be made +- 3D artists can use [picoCAD](https://johanpeitz.itch.io/picocad) +- Others can use the "[badges" Discord channel](https://discord.gg/sendou) to inquire about a commission +- Read rules from above carefully at this point and ask if you do not understand something +2. Create needed files +- .gif file, black solid background. Create via [picoCAD Web Viewer](https://lucatronica.github.io/picocad-web-viewer/) +- .png file. TODO: info on how to ceate +- .avif file. Create via e.g. [Squoosh](https://squoosh.app/) from the .png file +- All files should be squares. 512x512 is a good size for example +3. Make a pull request to the project +- You can request someone to help you on the ["development" Discord channel](https://discord.gg/sendou) +- In the PR add the 3 needed files to public/static-assets/badges folder: + +![alt text](img/badges-1.png) + +- Also update app/features/badges/homemade.ts file (read the comments to understand each value): + +![alt text](img/badges-2.png) + +4. Wait for Sendou to look into the pull request +- Sometimes this can take a while if Sendou is busy +- Changes might be requested + +5. Wait for the site to be updated +- After the pull request is merged it does not automatically go to the site yet +- Normally the site updates a couple times a week, but this can vary + +6. Request permissions +- After you see your badge on the /badges page you can request manager permissions to it on from the staff on the helpdesk channel + +7. Give out the badges to tournament winners +- Badge can be given out via the /badges page + +## Updating a badge + +Make a new pull request making the changes you need. The `fileName` should always remain the same. diff --git a/docs/img/badges-1.png b/docs/img/badges-1.png new file mode 100644 index 0000000000..1151d6c6bd Binary files /dev/null and b/docs/img/badges-1.png differ diff --git a/docs/img/badges-2.png b/docs/img/badges-2.png new file mode 100644 index 0000000000..11816c8ce7 Binary files /dev/null and b/docs/img/badges-2.png differ diff --git a/locales/da/badges.json b/locales/da/badges.json index 34aecf0a3a..7f3f8b72d9 100644 --- a/locales/da/badges.json +++ b/locales/da/badges.json @@ -5,7 +5,6 @@ "tournament_one": "Tildeldt for at vinde {{tournament}}", "tournament_other": "Tildeldt for at vinde {{tournament}} (×{{count}})", "forYourEvent": "Mærke til dit arrangement?", - "madeBy": "Mærker er lavet af <2>borzoic", "managedBy": "Administreres af {{users}}", "own.divider": "Administrerede mærker", "other.divider": "Andre mærker" diff --git a/locales/de/badges.json b/locales/de/badges.json index 5d379bf7e3..cc4440eb90 100644 --- a/locales/de/badges.json +++ b/locales/de/badges.json @@ -4,6 +4,5 @@ "tournament_one": "Verliehen für den Sieg von {{tournament}}", "tournament_other": "Verliehen für den Sieg von {{tournament}} (×{{count}})", "forYourEvent": "Abzeichen für dein Event?", - "madeBy": "Abzeichen von <2>borzoic", "managedBy": "Verwaltet durch {{users}}" } diff --git a/locales/en/badges.json b/locales/en/badges.json index 4e77fae1a4..b0f27131b6 100644 --- a/locales/en/badges.json +++ b/locales/en/badges.json @@ -5,8 +5,8 @@ "tournament_one": "Awarded for winning {{tournament}}", "tournament_other": "Awarded for winning {{tournament}} (×{{count}})", "forYourEvent": "Badge for your event?", - "madeBy": "Badges by <2>borzoic", "managedBy": "Managed by {{users}}", + "madeBy": "made by {{user}}", "own.divider": "Managed badges", "other.divider": "Other badges" } diff --git a/locales/es-ES/badges.json b/locales/es-ES/badges.json index 48e78d141f..3a5df56cb7 100644 --- a/locales/es-ES/badges.json +++ b/locales/es-ES/badges.json @@ -5,7 +5,6 @@ "tournament_one": "Recibido por ganar {{tournament}}", "tournament_other": "Recibido por ganar {{tournament}} (×{{count}})", "forYourEvent": "¿Insignia para tu evento?", - "madeBy": "Insignia por <2>borzoic", "managedBy": "Administrado por {{users}}", "own.divider": "Insignias administradas", "other.divider": "Otras insignias" diff --git a/locales/es-US/badges.json b/locales/es-US/badges.json index 4ad92fbb34..55635f6a54 100644 --- a/locales/es-US/badges.json +++ b/locales/es-US/badges.json @@ -5,7 +5,6 @@ "tournament_one": "Recibido por ganar {{tournament}}", "tournament_other": "Recibido por ganar {{tournament}} (×{{count}})", "forYourEvent": "¿Insignia para tu evento?", - "madeBy": "Insignia por <2>borzoic", "managedBy": "Administrado por {{users}}", "own.divider": "Insignias administradas", "other.divider": "Otras insignias" diff --git a/locales/fr-CA/badges.json b/locales/fr-CA/badges.json index d5dc48c8c0..0678c3e7b6 100644 --- a/locales/fr-CA/badges.json +++ b/locales/fr-CA/badges.json @@ -5,6 +5,5 @@ "tournament_one": "Attribué pour avoir gagné {{tournament}}", "tournament_other": "Attribué pour avoir gagné {{tournament}} (×{{count}})", "forYourEvent": "Un badge pour votre événement ?", - "madeBy": "Badges créés par <2>borzoic", "managedBy": "Géré par {{users}}" } diff --git a/locales/fr-EU/badges.json b/locales/fr-EU/badges.json index d5dc48c8c0..0678c3e7b6 100644 --- a/locales/fr-EU/badges.json +++ b/locales/fr-EU/badges.json @@ -5,6 +5,5 @@ "tournament_one": "Attribué pour avoir gagné {{tournament}}", "tournament_other": "Attribué pour avoir gagné {{tournament}} (×{{count}})", "forYourEvent": "Un badge pour votre événement ?", - "madeBy": "Badges créés par <2>borzoic", "managedBy": "Géré par {{users}}" } diff --git a/locales/he/badges.json b/locales/he/badges.json index b67b5b23c6..81d97c399f 100644 --- a/locales/he/badges.json +++ b/locales/he/badges.json @@ -5,6 +5,5 @@ "tournament_one": "מוענק עבור ניצחון {{tournament}}", "tournament_other": "מוענק עבור ניצחון {{tournament}} (×{{count}})", "forYourEvent": "תג לאירוע שלכם?", - "madeBy": "תגים מאת <2>borzoic", "managedBy": "מנוהל על ידי {{users}}" } diff --git a/locales/it/badges.json b/locales/it/badges.json index ba9bc2758b..527c76eea8 100644 --- a/locales/it/badges.json +++ b/locales/it/badges.json @@ -4,6 +4,5 @@ "tournament_one": "Premio per aver vinto {{tournament}}", "tournament_other": "Premio per aver vinto {{tournament}} (×{{count}})", "forYourEvent": "Vuoi creare una medaglia per il tuo evento?", - "madeBy": "Medaglie fatte da <2>borzoic", "managedBy": "Gestito da {{users}}" } diff --git a/locales/ja/badges.json b/locales/ja/badges.json index d850fb21cf..0618ca434f 100644 --- a/locales/ja/badges.json +++ b/locales/ja/badges.json @@ -5,7 +5,6 @@ "tournament_one": "{{tournament}} の勝者", "tournament_other": "{{tournament}} の勝者 (×{{count}})", "forYourEvent": "イベント専用のバッジ?", - "madeBy": "<2>borzoic によって作成されたバッジ", "managedBy": "{{users}} によって管理されています", "own.divider": "管理しているバッジ", "other.divider": "他のバッジ" diff --git a/locales/ko/badges.json b/locales/ko/badges.json index 0d3a4f131f..c313f15974 100644 --- a/locales/ko/badges.json +++ b/locales/ko/badges.json @@ -5,6 +5,5 @@ "tournament_one": "{{tournament}} 우승 기념", "tournament_other": "{{tournament}} (×{{count}}) 우승 기념", "forYourEvent": "이벤트에 배지를 원하나요?", - "madeBy": "<2>borzoic 제작", "managedBy": "{{users}}가 관리" } diff --git a/locales/nl/badges.json b/locales/nl/badges.json index 07c0cb1d2f..96996c9938 100644 --- a/locales/nl/badges.json +++ b/locales/nl/badges.json @@ -4,6 +4,5 @@ "tournament_one": "Uitgereikt voor het winnen van {{tournament}}", "tournament_other": "Uitgereikt voor het winnen van {{tournament}} (×{{count}})", "forYourEvent": "Ook een badge voor jouw evenement?", - "madeBy": "Badges gemaakt door <2>borzoic", "managedBy": "Beheerd door {{users}}" } diff --git a/locales/pl/badges.json b/locales/pl/badges.json index 5260c07dd9..9fa4e7b425 100644 --- a/locales/pl/badges.json +++ b/locales/pl/badges.json @@ -4,6 +4,5 @@ "tournament_one": "Nagrodzony/a za wygranie {{tournament}}", "tournament_other": "Nagrodzony/a {{tournament}} (×{{count}})", "forYourEvent": "Odznaka dla twojego eventu?", - "madeBy": "Odznaki robione przez <2>borzoic", "managedBy": "Zarządzane przez {{users}}" } diff --git a/locales/pt-BR/badges.json b/locales/pt-BR/badges.json index 94cfd41f00..a2cc996870 100644 --- a/locales/pt-BR/badges.json +++ b/locales/pt-BR/badges.json @@ -5,7 +5,6 @@ "tournament_one": "Premiado(a) por vencer o(a) {{tournament}}", "tournament_other": "Premiado(a) por vencer o(a) {{tournament}} (×{{count}})", "forYourEvent": "Quer insígnia(s) para o seu evento?", - "madeBy": "Insígnias por <2>borzoic", "managedBy": "Gerenciado por/pela {{users}}", "own.divider": "Ingínias gerenciadas", "other.divider": "Outras insígnias" diff --git a/locales/ru/badges.json b/locales/ru/badges.json index f88925a56e..6ac8634744 100644 --- a/locales/ru/badges.json +++ b/locales/ru/badges.json @@ -5,6 +5,5 @@ "tournament_one": "Награда за победу в {{tournament}}", "tournament_other": "Награда за победу в {{tournament}} (×{{count}})", "forYourEvent": "Значок для вашего события?", - "madeBy": "Значки от <2>borzoic", "managedBy": "Выдаётся {{users}}" } diff --git a/locales/zh/badges.json b/locales/zh/badges.json index 4fd8c0c9c2..4058eccaf2 100644 --- a/locales/zh/badges.json +++ b/locales/zh/badges.json @@ -5,7 +5,6 @@ "tournament_one": "{{tournament}}冠军奖励", "tournament_other": "{{tournament}} (×{{count}})冠军奖励", "forYourEvent": "在我的活动中设置徽章", - "madeBy": "由<2>borzoic设计", "managedBy": "由{{users}}管理", "own.divider": "管理徽章", "other.divider": "其他徽章" diff --git a/migrations/071-homemade-badges.js b/migrations/071-homemade-badges.js new file mode 100644 index 0000000000..3f09f2140d --- /dev/null +++ b/migrations/071-homemade-badges.js @@ -0,0 +1,9 @@ +export function up(db) { + db.transaction(() => { + db.prepare(/* sql */ `alter table "Badge" add "authorId" integer`).run(); + })(); + + db.prepare( + /* sql */ `create index badge_author_id on "Badge"("authorId")`, + ).run(); +} diff --git a/scripts/sync-homemade-badges.ts b/scripts/sync-homemade-badges.ts new file mode 100644 index 0000000000..0f3a2ba722 --- /dev/null +++ b/scripts/sync-homemade-badges.ts @@ -0,0 +1,168 @@ +import "dotenv/config"; + +import { db } from "~/db/sql"; +import { homemadeBadges } from "~/features/badges/homemade"; +import { logger } from "~/utils/logger"; + +async function main() { + let deleted = 0; + let updated = 0; + + const isGood = validateHomemadeBadges(); + + if (!isGood) { + logger.error( + "Homemade badges are not valid, skipping badge update process", + ); + return; + } + + // update existing + for (const existingBadge of await homemadeBadgesInDb()) { + const badge = homemadeBadges.find( + (badge) => badge.fileName === existingBadge.code, + ); + + if (!badge) { + await deleteBadge(existingBadge.id); + deleted++; + continue; + } + + const author = await findUserByDiscordId(badge.authorDiscordId); + + if (!author) { + logger.warn( + `Author not found for badge with id: ${existingBadge.id}, skipping`, + ); + continue; + } + + if ( + badge.displayName !== existingBadge.displayName || + badge.authorDiscordId !== existingBadge.discordId + ) { + await updateBadge(existingBadge.id, { + displayName: badge.displayName, + authorId: author.id, + }); + updated++; + } + } + + const homemadeAfterUpdates = await homemadeBadgesInDb(); + + let added = 0; + + // add new + for (const badge of homemadeBadges) { + const existing = homemadeAfterUpdates.find( + (existingBadge) => badge.fileName === existingBadge.code, + ); + + if (existing) { + continue; + } + + const author = await findUserByDiscordId(badge.authorDiscordId); + if (!author) { + logger.warn( + `Author not found for badge with fileName: ${badge.fileName}, skipping`, + ); + continue; + } + + await addBadge({ + code: badge.fileName, + displayName: badge.displayName, + authorId: author.id, + }); + + added++; + } + + logger.info( + `Deleted ${deleted}, updated ${updated}, added ${added} homemade badges`, + ); +} + +function validateHomemadeBadges() { + const names = new Set(); + + for (const badge of homemadeBadges) { + if (names.has(badge.fileName)) { + return false; + } + + names.add(badge.fileName); + } + + return true; +} + +async function homemadeBadgesInDb() { + return db + .selectFrom("Badge") + .innerJoin("User", "Badge.authorId", "User.id") + .select(["Badge.id", "Badge.code", "User.discordId", "Badge.displayName"]) + .execute(); +} + +async function findUserByDiscordId(discordId: string) { + return db + .selectFrom("User") + .select("id") + .where("discordId", "=", discordId) + .executeTakeFirst(); +} + +async function deleteBadge(badgeId: number) { + const owners = await db + .selectFrom("BadgeOwner") + .where("badgeId", "=", badgeId) + .execute(); + + if (owners.length > 0) { + logger.warn(`Refusing to delete badge ${badgeId} because it has owners`); + return; + } + + await db.transaction().execute(async (trx) => { + await trx + .deleteFrom("BadgeManager") + .where("badgeId", "=", badgeId) + .execute(); + await trx.deleteFrom("Badge").where("id", "=", badgeId).execute(); + }); +} + +async function updateBadge( + badgeId: number, + badge: { displayName: string; authorId: number }, +) { + return db + .updateTable("Badge") + .set({ + displayName: badge.displayName, + authorId: badge.authorId, + }) + .where("id", "=", badgeId) + .execute(); +} + +async function addBadge(badge: { + code: string; + displayName: string; + authorId: number; +}) { + return db + .insertInto("Badge") + .values({ + code: badge.code, + displayName: badge.displayName, + authorId: badge.authorId, + }) + .execute(); +} + +main();