mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
parent
17c8596c7d
commit
7f9c9f25fb
|
|
@ -69,10 +69,12 @@ export interface ArtUserMetadata {
|
|||
}
|
||||
|
||||
export interface Badge {
|
||||
id: GeneratedAlways<number>;
|
||||
code: string;
|
||||
displayName: string;
|
||||
hue: number | null;
|
||||
id: GeneratedAlways<number>;
|
||||
/** Who made the badge? If null, a legacy badge. */
|
||||
authorId: number | null;
|
||||
}
|
||||
|
||||
export interface BadgeManager {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type { Unpacked } from "~/utils/types";
|
|||
import { badgeExplanationText } from "../badges-utils";
|
||||
|
||||
interface BadgeDisplayProps {
|
||||
badges: Array<Tables["Badge"] & { count?: number }>;
|
||||
badges: Array<Omit<Tables["Badge"], "authorId"> & { count?: number }>;
|
||||
onBadgeRemove?: (badgeId: number) => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
17
app/features/badges/homemade.ts
Normal file
17
app/features/badges/homemade.ts
Normal file
|
|
@ -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",
|
||||
// },
|
||||
];
|
||||
|
|
@ -50,14 +50,15 @@ export default function BadgeDetailsPage() {
|
|||
<div className="badges__explanation">
|
||||
{badgeExplanationText(t, badge)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx("badges__managers", {
|
||||
invisible: data.managers.length === 0,
|
||||
})}
|
||||
>
|
||||
<div className="badges__managers">
|
||||
{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",
|
||||
})}
|
||||
)
|
||||
</div>
|
||||
</div>
|
||||
{isMod(user) || canEditBadgeOwners({ user, managers: data.managers }) ? (
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
<div className="badges__general-info-texts">
|
||||
<p>
|
||||
<Trans i18nKey="madeBy" t={t}>
|
||||
Badges by{" "}
|
||||
<a href={BORZOIC_TWITTER} target="_blank" rel="noreferrer">
|
||||
borzoic
|
||||
</a>
|
||||
</Trans>
|
||||
</p>
|
||||
{/* <p>
|
||||
<Link to={FAQ_PAGE}>{t("forYourEvent")}</Link>
|
||||
</p> */}
|
||||
</p>
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
|
|
|
|||
62
docs/badges.md
Normal file
62
docs/badges.md
Normal file
|
|
@ -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:
|
||||
|
||||

|
||||
|
||||
- Also update app/features/badges/homemade.ts file (read the comments to understand each value):
|
||||
|
||||

|
||||
|
||||
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.
|
||||
BIN
docs/img/badges-1.png
Normal file
BIN
docs/img/badges-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/img/badges-2.png
Normal file
BIN
docs/img/badges-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
|
|
@ -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</2>",
|
||||
"managedBy": "Administreres af {{users}}",
|
||||
"own.divider": "Administrerede mærker",
|
||||
"other.divider": "Andre mærker"
|
||||
|
|
|
|||
|
|
@ -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</2>",
|
||||
"managedBy": "Verwaltet durch {{users}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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</2>",
|
||||
"managedBy": "Managed by {{users}}",
|
||||
"madeBy": "made by {{user}}",
|
||||
"own.divider": "Managed badges",
|
||||
"other.divider": "Other badges"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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</2>",
|
||||
"managedBy": "Administrado por {{users}}",
|
||||
"own.divider": "Insignias administradas",
|
||||
"other.divider": "Otras insignias"
|
||||
|
|
|
|||
|
|
@ -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</2>",
|
||||
"managedBy": "Administrado por {{users}}",
|
||||
"own.divider": "Insignias administradas",
|
||||
"other.divider": "Otras insignias"
|
||||
|
|
|
|||
|
|
@ -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</2>",
|
||||
"managedBy": "Géré par {{users}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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</2>",
|
||||
"managedBy": "Géré par {{users}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,5 @@
|
|||
"tournament_one": "מוענק עבור ניצחון {{tournament}}",
|
||||
"tournament_other": "מוענק עבור ניצחון {{tournament}} (×{{count}})",
|
||||
"forYourEvent": "תג לאירוע שלכם?",
|
||||
"madeBy": "תגים מאת <2>borzoic</2>",
|
||||
"managedBy": "מנוהל על ידי {{users}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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</2>",
|
||||
"managedBy": "Gestito da {{users}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
"tournament_one": "{{tournament}} の勝者",
|
||||
"tournament_other": "{{tournament}} の勝者 (×{{count}})",
|
||||
"forYourEvent": "イベント専用のバッジ?",
|
||||
"madeBy": "<2>borzoic</2> によって作成されたバッジ",
|
||||
"managedBy": "{{users}} によって管理されています",
|
||||
"own.divider": "管理しているバッジ",
|
||||
"other.divider": "他のバッジ"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,5 @@
|
|||
"tournament_one": "{{tournament}} 우승 기념",
|
||||
"tournament_other": "{{tournament}} (×{{count}}) 우승 기념",
|
||||
"forYourEvent": "이벤트에 배지를 원하나요?",
|
||||
"madeBy": "<2>borzoic</2> 제작",
|
||||
"managedBy": "{{users}}가 관리"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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</2>",
|
||||
"managedBy": "Beheerd door {{users}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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</2>",
|
||||
"managedBy": "Zarządzane przez {{users}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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</2>",
|
||||
"managedBy": "Gerenciado por/pela {{users}}",
|
||||
"own.divider": "Ingínias gerenciadas",
|
||||
"other.divider": "Outras insígnias"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,5 @@
|
|||
"tournament_one": "Награда за победу в {{tournament}}",
|
||||
"tournament_other": "Награда за победу в {{tournament}} (×{{count}})",
|
||||
"forYourEvent": "Значок для вашего события?",
|
||||
"madeBy": "Значки от <2>borzoic</2>",
|
||||
"managedBy": "Выдаётся {{users}}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
"tournament_one": "{{tournament}}冠军奖励",
|
||||
"tournament_other": "{{tournament}} (×{{count}})冠军奖励",
|
||||
"forYourEvent": "在我的活动中设置徽章",
|
||||
"madeBy": "由<2>borzoic</2>设计",
|
||||
"managedBy": "由{{users}}管理",
|
||||
"own.divider": "管理徽章",
|
||||
"other.divider": "其他徽章"
|
||||
|
|
|
|||
9
migrations/071-homemade-badges.js
Normal file
9
migrations/071-homemade-badges.js
Normal file
|
|
@ -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();
|
||||
}
|
||||
168
scripts/sync-homemade-badges.ts
Normal file
168
scripts/sync-homemade-badges.ts
Normal file
|
|
@ -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<string>();
|
||||
|
||||
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();
|
||||
Loading…
Reference in New Issue
Block a user