Set Age Restrictions on library with a slider (#468)

* Adding groups logic and restrictions

* Cleaning up some redundant code

* hastily made translations

* Linter issues

* More linting

* PR comments

* Covering other APIs with age filters per comment

* Add ages to age ratings

* Making the linter happy

---------

Co-authored-by: Robert Clabough <robert@clabough.tech>
This commit is contained in:
AgentScrubbles
2026-08-06 16:13:43 +10:00
committed by GitHub
co-authored by Robert Clabough
parent b6b28afc2e
commit 26abc584a9
10 changed files with 357 additions and 153 deletions
+6
View File
@@ -702,6 +702,8 @@
"groups": {
"addBannedRating": "Gesperrte Einstufung hinzufügen",
"addMember": "Benutzer auswählen...",
"advancedOverrides": "Erweitert: Manuelle Überschreibungen",
"applyAgeRestriction": "Altersbeschränkung anwenden",
"bannedRatings": "Gesperrte Alterseinstufungen",
"createGroup": "Gruppe erstellen",
"deleteConfirm": "Sind Sie sicher, dass Sie diese Gruppe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
@@ -709,6 +711,9 @@
"description": "Benutzergruppen und Altersbeschränkungen verwalten.",
"descriptionField": "Beschreibung",
"details": "Details",
"manualOverrideNote": "Die aktuellen gesperrten Bewertungen entsprechen keiner Standard-Altersschwelle. Sie wurden möglicherweise manuell konfiguriert.",
"maximumContentAge": "Altersbeschränkung",
"maximumContentAgeDescription": "Legen Sie das maximale Inhaltsalter für Mitglieder dieser Gruppe fest. Bewertungen über diesem Alter werden gesperrt.",
"members": "Mitglieder",
"name": "Name",
"noBannedRatings": "Keine gesperrten Einstufungen konfiguriert.",
@@ -716,6 +721,7 @@
"oidcManagedNote": "Die Gruppenmitgliedschaft wird von Ihrem OIDC-Anbieter verwaltet. Mitglieder werden bei der Anmeldung automatisch synchronisiert. Um die Gruppenmitgliedschaft zu ändern, aktualisieren Sie die Gruppenzuweisungen Ihres Identitätsanbieters.",
"removeBannedRating": "Entfernen",
"removeMember": "Entfernen",
"sliderOverrideWarning": "Das manuelle Hinzufügen oder Entfernen von Bewertungen überschreibt den Schieberegler. Verwenden Sie den obigen Schieberegler, um auf eine Standard-Altersschwelle zurückzusetzen.",
"title": "Benutzergruppen",
"unratedNote": "Spiele ohne Alterseinstufung werden für Benutzer in dieser Gruppe ebenfalls ausgeblendet."
},
+6
View File
@@ -501,6 +501,8 @@
"groups": {
"addBannedRating": "Add forbidden rating",
"addMember": "Pick a scallywag...",
"advancedOverrides": "Advanced: Manual Overrides",
"applyAgeRestriction": "Apply age restriction",
"bannedRatings": "Forbidden Ratings",
"createGroup": "Form a Crew",
"deleteConfirm": "Be ye sure ye want to disband this crew? There be no turnin' back!",
@@ -508,6 +510,9 @@
"description": "Manage yer crew groups and age restrictions on the plunder.",
"descriptionField": "Description",
"details": "Details",
"manualOverrideNote": "The current banned ratings don't match a standard age threshold. They may've been manually set by the captain.",
"maximumContentAge": "Age Restriction",
"maximumContentAgeDescription": "Set the maximum content age fer members of this crew. Ratings above this age be banned, arr!",
"members": "Crew Members",
"name": "Name",
"noBannedRatings": "No forbidden ratings set, captain.",
@@ -515,6 +520,7 @@
"oidcManagedNote": "Crew membership be managed by yer OIDC harbourmaster. Members be synced when they come aboard. To change crew membership, update yer identity provider's assignments.",
"removeBannedRating": "Remove",
"removeMember": "Cast overboard",
"sliderOverrideWarning": "Manually addin' or removin' ratings here will override the slider. Use the slider above to reset to a standard age threshold.",
"title": "Crew Groups",
"unratedNote": "Unrated plunder will also be hidden from scallywags in this crew."
},
+6
View File
@@ -820,6 +820,8 @@
"groups": {
"addBannedRating": "Add banned rating",
"addMember": "Select a user...",
"advancedOverrides": "Advanced: Manual Overrides",
"applyAgeRestriction": "Apply age restriction",
"bannedRatings": "Banned Ratings",
"createGroup": "Create Group",
"deleteConfirm": "Are you sure you want to delete this group? This action cannot be undone.",
@@ -827,6 +829,9 @@
"description": "Manage user groups and age rating restrictions.",
"descriptionField": "Description",
"details": "Details",
"manualOverrideNote": "The current banned ratings do not match a standard age threshold. They may have been manually configured.",
"maximumContentAge": "Age Restriction",
"maximumContentAgeDescription": "Set the maximum content age allowed for members of this group. Ratings above this age will be banned.",
"members": "Members",
"name": "Name",
"noBannedRatings": "No banned ratings configured.",
@@ -834,6 +839,7 @@
"oidcManagedNote": "Group membership is managed by your OIDC provider. Members are automatically synced when users log in. To change group membership, update your identity provider's group assignments.",
"removeBannedRating": "Remove",
"removeMember": "Remove",
"sliderOverrideWarning": "Manually adding or removing ratings here will override the slider. Use the slider above to reset to a standard age threshold.",
"title": "User Groups",
"unratedNote": "Games without age ratings will also be hidden from users in this group."
},
+6
View File
@@ -702,6 +702,8 @@
"groups": {
"addBannedRating": "Ajouter une classification interdite",
"addMember": "Sélectionner un utilisateur...",
"advancedOverrides": "Avancé : Remplacements manuels",
"applyAgeRestriction": "Appliquer la restriction d'âge",
"bannedRatings": "Classifications interdites",
"createGroup": "Créer un groupe",
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer ce groupe ? Cette action est irréversible.",
@@ -709,6 +711,9 @@
"description": "Gérer les groupes d'utilisateurs et les restrictions d'âge.",
"descriptionField": "Description",
"details": "Détails",
"manualOverrideNote": "Les classifications interdites actuelles ne correspondent pas à un seuil d'âge standard. Elles ont peut-être été configurées manuellement.",
"maximumContentAge": "Restriction d'âge",
"maximumContentAgeDescription": "Définissez l'âge maximum du contenu autorisé pour les membres de ce groupe. Les classifications au-dessus de cet âge seront interdites.",
"members": "Membres",
"name": "Nom",
"noBannedRatings": "Aucune classification interdite configurée.",
@@ -716,6 +721,7 @@
"oidcManagedNote": "L'appartenance aux groupes est gérée par votre fournisseur OIDC. Les membres sont automatiquement synchronisés lors de la connexion. Pour modifier l'appartenance aux groupes, mettez à jour les affectations de groupes de votre fournisseur d'identité.",
"removeBannedRating": "Retirer",
"removeMember": "Retirer",
"sliderOverrideWarning": "L'ajout ou la suppression manuelle de classifications remplacera le curseur. Utilisez le curseur ci-dessus pour revenir à un seuil d'âge standard.",
"title": "Groupes d'utilisateurs",
"unratedNote": "Les jeux sans classification d'âge seront également masqués pour les utilisateurs de ce groupe."
},
+6
View File
@@ -678,6 +678,8 @@
"groups": {
"addBannedRating": "Dodaj zablokowaną kategorię",
"addMember": "Wybierz użytkownika...",
"advancedOverrides": "Zaawansowane: Ręczne zastępowanie",
"applyAgeRestriction": "Zastosuj ograniczenie wiekowe",
"bannedRatings": "Zablokowane kategorie wiekowe",
"createGroup": "Utwórz grupę",
"deleteConfirm": "Czy na pewno chcesz usunąć tę grupę? Tej akcji nie można cofnąć.",
@@ -685,6 +687,9 @@
"description": "Zarządzaj grupami użytkowników i ograniczeniami wiekowymi.",
"descriptionField": "Opis",
"details": "Szczegóły",
"manualOverrideNote": "Obecne zablokowane oceny nie odpowiadają standardowemu progowi wiekowemu. Mogły zostać skonfigurowane ręcznie.",
"maximumContentAge": "Ograniczenie wiekowe",
"maximumContentAgeDescription": "Ustaw maksymalny wiek treści dozwolony dla członków tej grupy. Oceny powyżej tego wieku zostaną zablokowane.",
"members": "Członkowie",
"name": "Nazwa",
"noBannedRatings": "Brak skonfigurowanych zablokowanych kategorii.",
@@ -692,6 +697,7 @@
"oidcManagedNote": "Członkostwo w grupach jest zarządzane przez dostawcę OIDC. Członkowie są automatycznie synchronizowani podczas logowania. Aby zmienić członkostwo w grupach, zaktualizuj przypisania grup u dostawcy tożsamości.",
"removeBannedRating": "Usuń",
"removeMember": "Usuń",
"sliderOverrideWarning": "Ręczne dodawanie lub usuwanie ocen zastąpi suwak. Użyj suwaka powyżej, aby powrócić do standardowego progu wiekowego.",
"title": "Grupy użytkowników",
"unratedNote": "Gry bez kategorii wiekowej będą również ukryte przed użytkownikami w tej grupie."
},
+175 -62
View File
@@ -92,7 +92,7 @@
<option value="" disabled>
{{ $t("users.admin.groups.addMember") }}
</option>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<option
v-for="user in availableUsers"
:key="user.id"
@@ -100,6 +100,7 @@
>
{{ user.displayName }} ({{ user.username }})
</option>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
</select>
<button
class="rounded-md bg-blue-600 px-2 py-1 text-sm font-semibold text-white hover:bg-blue-500"
@@ -111,93 +112,179 @@
</div>
</div>
<!-- Banned Ratings Section -->
<!-- Age Restriction Slider Section -->
<div class="mt-8 rounded-lg border border-zinc-800 bg-zinc-900 p-6">
<h2 class="text-base font-semibold text-zinc-100 mb-4">
{{ $t("users.admin.groups.bannedRatings") }}
{{ $t("users.admin.groups.maximumContentAge") }}
</h2>
<p class="text-sm text-zinc-400 mb-4">
{{ $t("users.admin.groups.maximumContentAgeDescription") }}
</p>
<p class="text-sm text-zinc-400 mb-4">
{{ $t("users.admin.groups.unratedNote") }}
</p>
<div
v-if="group.bannedAgeRatings.length === 0 && !showAddRating"
class="text-sm text-zinc-400"
v-if="hasManualOverrides"
class="mb-4 rounded-md bg-yellow-900/30 border border-yellow-700/50 p-3"
>
{{ $t("users.admin.groups.noBannedRatings") }}
<p class="text-sm text-yellow-300">
{{ $t("users.admin.groups.manualOverrideNote") }}
</p>
</div>
<div class="space-y-2">
<div
v-for="(br, idx) in group.bannedAgeRatings"
:key="br.id"
class="flex items-center gap-2"
>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span
class="inline-flex items-center rounded-full bg-zinc-800 px-2.5 py-0.5 text-sm font-medium text-zinc-100"
>
{{ br.organization }}: {{ br.rating }}
<div class="max-w-md">
<div class="flex items-center justify-between mb-2">
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<span class="text-sm font-medium text-zinc-100">
{{ sliderAge }}+
</span>
<button
type="button"
class="text-red-400 hover:text-red-300 text-sm"
@click="removeRating(idx)"
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
</div>
<input
v-model.number="sliderAge"
type="range"
min="0"
max="18"
step="1"
class="w-full cursor-pointer accent-blue-600"
/>
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<div class="flex justify-between text-xs text-zinc-500 mt-1">
<span>0</span>
<span>6</span>
<span>12</span>
<span>18</span>
</div>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
</div>
<div v-if="sliderBannedPreview.length > 0" class="mt-4">
<p class="text-sm text-zinc-400 mb-2">
{{ $t("users.admin.groups.bannedRatings") }}
</p>
<div class="flex flex-wrap gap-1">
<span
v-for="bp in sliderBannedPreview"
:key="`${bp.organization}:${bp.rating}`"
class="inline-flex items-center rounded-full bg-red-900/30 border border-red-700/50 px-2 py-0.5 text-xs text-red-300"
>
{{ $t("users.admin.groups.removeBannedRating") }}
</button>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
{{ bp.organization }}: {{ bp.rating }}
</span>
</div>
</div>
<div v-if="showAddRating" class="mt-4 flex items-center gap-2">
<select
v-model="newRatingOrg"
class="rounded-md bg-zinc-800 px-2 py-1 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 focus:outline-blue-600"
>
<option v-for="org in organizations" :key="org" :value="org">
{{ org }}
</option>
</select>
<select
v-model="newRatingValue"
:disabled="!newRatingOrg"
class="rounded-md bg-zinc-800 px-2 py-1 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 focus:outline-blue-600"
>
<option v-for="r in availableRatingsForOrg" :key="r" :value="r">
{{ r }}
</option>
</select>
<button
type="button"
class="rounded-md bg-blue-600 px-2 py-1 text-sm font-semibold text-white hover:bg-blue-500"
:disabled="!newRatingOrg || !newRatingValue"
@click="addRating"
>
{{ $t("add") }}
</button>
<button
type="button"
class="text-zinc-400 hover:text-zinc-300 text-sm"
@click="showAddRating = false"
>
{{ $t("cancel") }}
</button>
<div v-else class="mt-4">
<p class="text-sm text-zinc-400">
{{ $t("users.admin.groups.noBannedRatings") }}
</p>
</div>
<button
v-if="!showAddRating"
type="button"
class="mt-4 text-sm text-blue-400 hover:text-blue-300"
@click="showAddRating = true"
class="mt-4 rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
@click="applySlider"
>
{{ $t("users.admin.groups.addBannedRating") }}
{{ $t("users.admin.groups.applyAgeRestriction") }}
</button>
</div>
<!-- Advanced: Manual Overrides -->
<div class="mt-8 rounded-lg border border-zinc-800 bg-zinc-900 p-6">
<details>
<summary class="cursor-pointer text-base font-semibold text-zinc-100">
{{ $t("users.admin.groups.advancedOverrides") }}
</summary>
<div class="mt-4">
<p class="text-sm text-zinc-400 mb-4">
{{ $t("users.admin.groups.sliderOverrideWarning") }}
</p>
<div
v-if="group.bannedAgeRatings.length === 0 && !showAddRating"
class="text-sm text-zinc-400"
>
{{ $t("users.admin.groups.noBannedRatings") }}
</div>
<div class="space-y-2">
<div
v-for="(br, idx) in group.bannedAgeRatings"
:key="br.id"
class="flex items-center gap-2"
>
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<span
class="inline-flex items-center rounded-full bg-zinc-800 px-2.5 py-0.5 text-sm font-medium text-zinc-100"
>
{{ br.organization }}: {{ br.rating }}
</span>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
<button
type="button"
class="text-red-400 hover:text-red-300 text-sm"
@click="removeRating(idx)"
>
{{ $t("users.admin.groups.removeBannedRating") }}
</button>
</div>
</div>
<div v-if="showAddRating" class="mt-4 flex items-center gap-2">
<select
v-model="newRatingOrg"
class="rounded-md bg-zinc-800 px-2 py-1 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 focus:outline-blue-600"
>
<option v-for="org in organizations" :key="org" :value="org">
{{ org }}
</option>
</select>
<select
v-model="newRatingValue"
:disabled="!newRatingOrg"
class="rounded-md bg-zinc-800 px-2 py-1 text-sm text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 focus:outline-blue-600"
>
<option v-for="r in availableRatingsForOrg" :key="r" :value="r">
{{ r }}
</option>
</select>
<button
type="button"
class="rounded-md bg-blue-600 px-2 py-1 text-sm font-semibold text-white hover:bg-blue-500"
:disabled="!newRatingOrg || !newRatingValue"
@click="addRating"
>
{{ $t("add") }}
</button>
<button
type="button"
class="text-zinc-400 hover:text-zinc-300 text-sm"
@click="showAddRating = false"
>
{{ $t("cancel") }}
</button>
</div>
<button
v-if="!showAddRating"
type="button"
class="mt-4 text-sm text-blue-400 hover:text-blue-300"
@click="showAddRating = true"
>
{{ $t("users.admin.groups.addBannedRating") }}
</button>
</div>
</details>
</div>
</div>
</template>
<script setup lang="ts">
import type { AuthMec } from "~/prisma/client/enums";
import { getAvailableRatings } from "~/utils/ageRatings";
import {
getAvailableRatings,
getBannedRatingsForMaxAge,
inferMaxAgeFromBannedRatings,
} from "~/utils/ageRatings";
import { AgeRatingOrganization } from "~/prisma/client/enums";
useHead({ title: "Edit Group" });
@@ -239,6 +326,18 @@ const newRatingValue = ref("");
const organizations = Object.values(AgeRatingOrganization);
// Slider state
const sliderAge = ref(18);
const sliderBannedPreview = computed(() =>
getBannedRatingsForMaxAge(sliderAge.value),
);
const hasManualOverrides = computed(() => {
if (!group.value) return false;
return inferMaxAgeFromBannedRatings(group.value.bannedAgeRatings) === null;
});
const availableRatingsForOrg = computed(() => {
if (!newRatingOrg.value) return [];
return getAvailableRatings(newRatingOrg.value as AgeRatingOrganization);
@@ -255,6 +354,10 @@ const fetchGroup = async () => {
);
editName.value = group.value.name;
editDescription.value = group.value.description;
// Initialize slider from existing banned ratings
const inferred = inferMaxAgeFromBannedRatings(group.value.bannedAgeRatings);
sliderAge.value = inferred ?? 18;
};
// Fetch all users for member add dropdown
@@ -323,6 +426,16 @@ const removeMember = async (userId: string) => {
await fetchGroup();
};
const applySlider = async () => {
if (!group.value) return;
const bannedRatings = getBannedRatingsForMaxAge(sliderAge.value);
await $dropFetch(`/api/v1/admin/groups/${groupId}/ratings`, {
method: "PATCH",
body: { bannedRatings },
});
await fetchGroup();
};
const addRating = async () => {
if (!group.value || !newRatingOrg.value || !newRatingValue.value) return;
const newRatings = [
+13 -13
View File
@@ -114,10 +114,10 @@ const GB_BOARD_TO_ORG: Record<string, AgeRatingOrganization> = {
};
function lowercaseNormMap(
ratings: Record<string, string>,
ratings: Record<string, { value: string; age: number }>,
): Record<string, string> {
return Object.fromEntries(
Object.values(ratings).map((v) => [v.toLowerCase(), v]),
Object.values(ratings).map((r) => [r.value.toLowerCase(), r.value]),
);
}
@@ -127,14 +127,14 @@ const GB_RATING_NORMALIZE: Record<
> = {
[AgeRatingOrganization.ESRB]: {
...lowercaseNormMap(ESRBRating),
"early childhood": ESRBRating.EC,
everyone: ESRBRating.E,
"everyone 10+": ESRBRating.E10,
teen: ESRBRating.T,
mature: ESRBRating.M,
"mature 17+": ESRBRating.M,
"adults only": ESRBRating.AO,
"adults only 18+": ESRBRating.AO,
"early childhood": ESRBRating.EC.value,
everyone: ESRBRating.E.value,
"everyone 10+": ESRBRating.E10.value,
teen: ESRBRating.T.value,
mature: ESRBRating.M.value,
"mature 17+": ESRBRating.M.value,
"adults only": ESRBRating.AO.value,
"adults only 18+": ESRBRating.AO.value,
},
[AgeRatingOrganization.PEGI]: lowercaseNormMap(PEGIRating),
[AgeRatingOrganization.CERO]: lowercaseNormMap(CEROrating),
@@ -142,9 +142,9 @@ const GB_RATING_NORMALIZE: Record<
[AgeRatingOrganization.GRAC]: lowercaseNormMap(GRACRating),
[AgeRatingOrganization.ACB]: {
...lowercaseNormMap(ACBRating),
"ma 15+": ACBRating.MA15,
"r 18+": ACBRating.R18,
"refused classification": ACBRating.RC,
"ma 15+": ACBRating.MA15.value,
"r 18+": ACBRating.R18.value,
"refused classification": ACBRating.RC.value,
},
[AgeRatingOrganization.ClassInd]: {},
};
+37 -37
View File
@@ -109,49 +109,49 @@ const IGDB_CATEGORY_TO_ORG: Record<number, AgeRatingOrganization> = {
const IGDB_RATING_TO_STRING: Record<number, string> = {
// PEGI
1: PEGIRating["3"],
2: PEGIRating["7"],
3: PEGIRating["12"],
4: PEGIRating["16"],
5: PEGIRating["18"],
1: PEGIRating["3"].value,
2: PEGIRating["7"].value,
3: PEGIRating["12"].value,
4: PEGIRating["16"].value,
5: PEGIRating["18"].value,
// ESRB
7: ESRBRating.EC,
8: ESRBRating.E,
9: ESRBRating.E10,
10: ESRBRating.T,
11: ESRBRating.M,
12: ESRBRating.AO,
7: ESRBRating.EC.value,
8: ESRBRating.E.value,
9: ESRBRating.E10.value,
10: ESRBRating.T.value,
11: ESRBRating.M.value,
12: ESRBRating.AO.value,
// CERO
13: CEROrating.A,
14: CEROrating.B,
15: CEROrating.C,
16: CEROrating.D,
17: CEROrating.Z,
13: CEROrating.A.value,
14: CEROrating.B.value,
15: CEROrating.C.value,
16: CEROrating.D.value,
17: CEROrating.Z.value,
// USK
18: USKRating["0"],
19: USKRating["6"],
20: USKRating["12"],
21: USKRating["16"],
22: USKRating["18"],
18: USKRating["0"].value,
19: USKRating["6"].value,
20: USKRating["12"].value,
21: USKRating["16"].value,
22: USKRating["18"].value,
// GRAC
23: GRACRating.ALL,
24: GRACRating["12"],
25: GRACRating["15"],
26: GRACRating["18"],
23: GRACRating.ALL.value,
24: GRACRating["12"].value,
25: GRACRating["15"].value,
26: GRACRating["18"].value,
// CLASS_IND
28: ClassIndRating.L,
29: ClassIndRating["10"],
30: ClassIndRating["12"],
31: ClassIndRating["14"],
32: ClassIndRating["16"],
33: ClassIndRating["18"],
28: ClassIndRating.L.value,
29: ClassIndRating["10"].value,
30: ClassIndRating["12"].value,
31: ClassIndRating["14"].value,
32: ClassIndRating["16"].value,
33: ClassIndRating["18"].value,
// ACB
34: ACBRating.G,
35: ACBRating.PG,
36: ACBRating.M,
37: ACBRating.MA15,
38: ACBRating.R18,
39: ACBRating.RC,
34: ACBRating.G.value,
35: ACBRating.PG.value,
36: ACBRating.M.value,
37: ACBRating.MA15.value,
38: ACBRating.R18.value,
39: ACBRating.RC.value,
};
// https://api-docs.igdb.com/?shell#game
+2 -2
View File
@@ -200,10 +200,10 @@ const STEAM_RATING_KEY_TO_ORG: Record<string, AgeRatingOrganization> = {
};
function lowercaseNormMap(
ratings: Record<string, string>,
ratings: Record<string, { value: string; age: number }>,
): Record<string, string> {
return Object.fromEntries(
Object.values(ratings).map((v) => [v.toLowerCase(), v]),
Object.values(ratings).map((r) => [r.value.toLowerCase(), r.value]),
);
}
+100 -39
View File
@@ -5,64 +5,71 @@ import { AgeRatingOrganization } from "~/prisma/client/enums";
* will be to normalize to these ratings. Admins of course can override and set a separate rating if needed.
*
* These will change, but historically very infrequently.
*
* Each rating has a `value` (the string stored in the database) and an `age` (the minimum age for that rating).
*/
interface RatingDef {
readonly value: string;
readonly age: number;
}
export const ESRBRating = {
EC: "EC",
E: "E",
E10: "E10",
T: "T",
M: "M",
AO: "AO",
EC: { value: "EC", age: 3 },
E: { value: "E", age: 6 },
E10: { value: "E10", age: 10 },
T: { value: "T", age: 13 },
M: { value: "M", age: 17 },
AO: { value: "AO", age: 18 },
} as const;
export const PEGIRating = {
"3": "3",
"7": "7",
"12": "12",
"16": "16",
"18": "18",
"3": { value: "3", age: 3 },
"7": { value: "7", age: 7 },
"12": { value: "12", age: 12 },
"16": { value: "16", age: 16 },
"18": { value: "18", age: 18 },
} as const;
export const CEROrating = {
A: "A",
B: "B",
C: "C",
D: "D",
Z: "Z",
A: { value: "A", age: 0 },
B: { value: "B", age: 12 },
C: { value: "C", age: 15 },
D: { value: "D", age: 17 },
Z: { value: "Z", age: 18 },
} as const;
export const USKRating = {
"0": "0",
"6": "6",
"12": "12",
"16": "16",
"18": "18",
"0": { value: "0", age: 0 },
"6": { value: "6", age: 6 },
"12": { value: "12", age: 12 },
"16": { value: "16", age: 16 },
"18": { value: "18", age: 18 },
} as const;
export const GRACRating = {
ALL: "ALL",
"12": "12",
"15": "15",
"18": "18",
ALL: { value: "ALL", age: 0 },
"12": { value: "12", age: 12 },
"15": { value: "15", age: 15 },
"18": { value: "18", age: 18 },
} as const;
export const ClassIndRating = {
L: "L",
"10": "10",
"12": "12",
"14": "14",
"16": "16",
"18": "18",
L: { value: "L", age: 0 },
"10": { value: "10", age: 10 },
"12": { value: "12", age: 12 },
"14": { value: "14", age: 14 },
"16": { value: "16", age: 16 },
"18": { value: "18", age: 18 },
} as const;
export const ACBRating = {
G: "G",
PG: "PG",
M: "M",
MA15: "MA15",
R18: "R18",
RC: "RC",
G: { value: "G", age: 0 },
PG: { value: "PG", age: 8 },
M: { value: "M", age: 15 },
MA15: { value: "MA15", age: 15 },
R18: { value: "R18", age: 18 },
RC: { value: "RC", age: 18 },
} as const;
export const RATINGS_FOR_ORGANIZATION = {
@@ -73,8 +80,62 @@ export const RATINGS_FOR_ORGANIZATION = {
[AgeRatingOrganization.GRAC]: GRACRating,
[AgeRatingOrganization.ClassInd]: ClassIndRating,
[AgeRatingOrganization.ACB]: ACBRating,
} as const satisfies Record<AgeRatingOrganization, Record<string, string>>;
} as const satisfies Record<AgeRatingOrganization, Record<string, RatingDef>>;
export function getAvailableRatings(org: AgeRatingOrganization): string[] {
return Object.values(RATINGS_FOR_ORGANIZATION[org]);
return Object.values(RATINGS_FOR_ORGANIZATION[org]).map((r) => r.value);
}
/**
* Given a maximum allowed age, returns all org:rating pairs that exceed it (should be banned).
*/
export function getBannedRatingsForMaxAge(
maxAge: number,
): Array<{ organization: AgeRatingOrganization; rating: string }> {
const banned: Array<{ organization: AgeRatingOrganization; rating: string }> =
[];
for (const org of Object.values(AgeRatingOrganization)) {
const ratings = RATINGS_FOR_ORGANIZATION[org];
for (const def of Object.values(ratings)) {
if (def.age > maxAge) {
banned.push({ organization: org, rating: def.value });
}
}
}
return banned;
}
/**
* Given a set of banned ratings, infer what max age slider position would produce them.
* Returns null if the set doesn't match any clean slider threshold (manual overrides).
*/
export function inferMaxAgeFromBannedRatings(
banned: Array<{ organization: string; rating: string }>,
): number | null {
if (banned.length === 0) return 18;
// Try each possible age and check if the banned set matches exactly
const possibleAges = new Set<number>();
for (const orgRatings of Object.values(RATINGS_FOR_ORGANIZATION)) {
for (const def of Object.values(orgRatings)) {
possibleAges.add(def.age);
}
}
const bannedSet = new Set(banned.map((b) => `${b.organization}:${b.rating}`));
for (const age of Array.from(possibleAges).sort((a, b) => b - a)) {
const expected = getBannedRatingsForMaxAge(age);
const expectedSet = new Set(
expected.map((e) => `${e.organization}:${e.rating}`),
);
if (
expectedSet.size === bannedSet.size &&
[...expectedSet].every((e) => bannedSet.has(e))
) {
return age;
}
}
return null;
}