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": { "groups": {
"addBannedRating": "Gesperrte Einstufung hinzufügen", "addBannedRating": "Gesperrte Einstufung hinzufügen",
"addMember": "Benutzer auswählen...", "addMember": "Benutzer auswählen...",
"advancedOverrides": "Erweitert: Manuelle Überschreibungen",
"applyAgeRestriction": "Altersbeschränkung anwenden",
"bannedRatings": "Gesperrte Alterseinstufungen", "bannedRatings": "Gesperrte Alterseinstufungen",
"createGroup": "Gruppe erstellen", "createGroup": "Gruppe erstellen",
"deleteConfirm": "Sind Sie sicher, dass Sie diese Gruppe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", "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.", "description": "Benutzergruppen und Altersbeschränkungen verwalten.",
"descriptionField": "Beschreibung", "descriptionField": "Beschreibung",
"details": "Details", "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", "members": "Mitglieder",
"name": "Name", "name": "Name",
"noBannedRatings": "Keine gesperrten Einstufungen konfiguriert.", "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.", "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", "removeBannedRating": "Entfernen",
"removeMember": "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", "title": "Benutzergruppen",
"unratedNote": "Spiele ohne Alterseinstufung werden für Benutzer in dieser Gruppe ebenfalls ausgeblendet." "unratedNote": "Spiele ohne Alterseinstufung werden für Benutzer in dieser Gruppe ebenfalls ausgeblendet."
}, },
+6
View File
@@ -501,6 +501,8 @@
"groups": { "groups": {
"addBannedRating": "Add forbidden rating", "addBannedRating": "Add forbidden rating",
"addMember": "Pick a scallywag...", "addMember": "Pick a scallywag...",
"advancedOverrides": "Advanced: Manual Overrides",
"applyAgeRestriction": "Apply age restriction",
"bannedRatings": "Forbidden Ratings", "bannedRatings": "Forbidden Ratings",
"createGroup": "Form a Crew", "createGroup": "Form a Crew",
"deleteConfirm": "Be ye sure ye want to disband this crew? There be no turnin' back!", "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.", "description": "Manage yer crew groups and age restrictions on the plunder.",
"descriptionField": "Description", "descriptionField": "Description",
"details": "Details", "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", "members": "Crew Members",
"name": "Name", "name": "Name",
"noBannedRatings": "No forbidden ratings set, captain.", "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.", "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", "removeBannedRating": "Remove",
"removeMember": "Cast overboard", "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", "title": "Crew Groups",
"unratedNote": "Unrated plunder will also be hidden from scallywags in this crew." "unratedNote": "Unrated plunder will also be hidden from scallywags in this crew."
}, },
+6
View File
@@ -820,6 +820,8 @@
"groups": { "groups": {
"addBannedRating": "Add banned rating", "addBannedRating": "Add banned rating",
"addMember": "Select a user...", "addMember": "Select a user...",
"advancedOverrides": "Advanced: Manual Overrides",
"applyAgeRestriction": "Apply age restriction",
"bannedRatings": "Banned Ratings", "bannedRatings": "Banned Ratings",
"createGroup": "Create Group", "createGroup": "Create Group",
"deleteConfirm": "Are you sure you want to delete this group? This action cannot be undone.", "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.", "description": "Manage user groups and age rating restrictions.",
"descriptionField": "Description", "descriptionField": "Description",
"details": "Details", "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", "members": "Members",
"name": "Name", "name": "Name",
"noBannedRatings": "No banned ratings configured.", "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.", "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", "removeBannedRating": "Remove",
"removeMember": "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", "title": "User Groups",
"unratedNote": "Games without age ratings will also be hidden from users in this group." "unratedNote": "Games without age ratings will also be hidden from users in this group."
}, },
+6
View File
@@ -702,6 +702,8 @@
"groups": { "groups": {
"addBannedRating": "Ajouter une classification interdite", "addBannedRating": "Ajouter une classification interdite",
"addMember": "Sélectionner un utilisateur...", "addMember": "Sélectionner un utilisateur...",
"advancedOverrides": "Avancé : Remplacements manuels",
"applyAgeRestriction": "Appliquer la restriction d'âge",
"bannedRatings": "Classifications interdites", "bannedRatings": "Classifications interdites",
"createGroup": "Créer un groupe", "createGroup": "Créer un groupe",
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer ce groupe ? Cette action est irréversible.", "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.", "description": "Gérer les groupes d'utilisateurs et les restrictions d'âge.",
"descriptionField": "Description", "descriptionField": "Description",
"details": "Détails", "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", "members": "Membres",
"name": "Nom", "name": "Nom",
"noBannedRatings": "Aucune classification interdite configurée.", "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é.", "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", "removeBannedRating": "Retirer",
"removeMember": "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", "title": "Groupes d'utilisateurs",
"unratedNote": "Les jeux sans classification d'âge seront également masqués pour les utilisateurs de ce groupe." "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": { "groups": {
"addBannedRating": "Dodaj zablokowaną kategorię", "addBannedRating": "Dodaj zablokowaną kategorię",
"addMember": "Wybierz użytkownika...", "addMember": "Wybierz użytkownika...",
"advancedOverrides": "Zaawansowane: Ręczne zastępowanie",
"applyAgeRestriction": "Zastosuj ograniczenie wiekowe",
"bannedRatings": "Zablokowane kategorie wiekowe", "bannedRatings": "Zablokowane kategorie wiekowe",
"createGroup": "Utwórz grupę", "createGroup": "Utwórz grupę",
"deleteConfirm": "Czy na pewno chcesz usunąć tę grupę? Tej akcji nie można cofnąć.", "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.", "description": "Zarządzaj grupami użytkowników i ograniczeniami wiekowymi.",
"descriptionField": "Opis", "descriptionField": "Opis",
"details": "Szczegóły", "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", "members": "Członkowie",
"name": "Nazwa", "name": "Nazwa",
"noBannedRatings": "Brak skonfigurowanych zablokowanych kategorii.", "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.", "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ń", "removeBannedRating": "Usuń",
"removeMember": "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", "title": "Grupy użytkowników",
"unratedNote": "Gry bez kategorii wiekowej będą również ukryte przed użytkownikami w tej grupie." "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> <option value="" disabled>
{{ $t("users.admin.groups.addMember") }} {{ $t("users.admin.groups.addMember") }}
</option> </option>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text --> <!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<option <option
v-for="user in availableUsers" v-for="user in availableUsers"
:key="user.id" :key="user.id"
@@ -100,6 +100,7 @@
> >
{{ user.displayName }} ({{ user.username }}) {{ user.displayName }} ({{ user.username }})
</option> </option>
<!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
</select> </select>
<button <button
class="rounded-md bg-blue-600 px-2 py-1 text-sm font-semibold text-white hover:bg-blue-500" 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>
</div> </div>
<!-- Banned Ratings Section --> <!-- Age Restriction Slider Section -->
<div class="mt-8 rounded-lg border border-zinc-800 bg-zinc-900 p-6"> <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"> <h2 class="text-base font-semibold text-zinc-100 mb-4">
{{ $t("users.admin.groups.bannedRatings") }} {{ $t("users.admin.groups.maximumContentAge") }}
</h2> </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"> <p class="text-sm text-zinc-400 mb-4">
{{ $t("users.admin.groups.unratedNote") }} {{ $t("users.admin.groups.unratedNote") }}
</p> </p>
<div <div
v-if="group.bannedAgeRatings.length === 0 && !showAddRating" v-if="hasManualOverrides"
class="text-sm text-zinc-400" 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>
<div class="space-y-2"> <div class="max-w-md">
<div <div class="flex items-center justify-between mb-2">
v-for="(br, idx) in group.bannedAgeRatings" <!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
:key="br.id" <span class="text-sm font-medium text-zinc-100">
class="flex items-center gap-2" {{ sliderAge }}+
>
<!-- 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 }}
</span> </span>
<button <!-- eslint-enable @intlify/vue-i18n/no-raw-text -->
type="button" </div>
class="text-red-400 hover:text-red-300 text-sm" <input
@click="removeRating(idx)" 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") }} <!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
</button> {{ bp.organization }}: {{ bp.rating }}
</span>
</div> </div>
</div> </div>
<div v-else class="mt-4">
<div v-if="showAddRating" class="mt-4 flex items-center gap-2"> <p class="text-sm text-zinc-400">
<select {{ $t("users.admin.groups.noBannedRatings") }}
v-model="newRatingOrg" </p>
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> </div>
<button <button
v-if="!showAddRating"
type="button" type="button"
class="mt-4 text-sm text-blue-400 hover:text-blue-300" class="mt-4 rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white hover:bg-blue-500"
@click="showAddRating = true" @click="applySlider"
> >
{{ $t("users.admin.groups.addBannedRating") }} {{ $t("users.admin.groups.applyAgeRestriction") }}
</button> </button>
</div> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AuthMec } from "~/prisma/client/enums"; 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"; import { AgeRatingOrganization } from "~/prisma/client/enums";
useHead({ title: "Edit Group" }); useHead({ title: "Edit Group" });
@@ -239,6 +326,18 @@ const newRatingValue = ref("");
const organizations = Object.values(AgeRatingOrganization); 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(() => { const availableRatingsForOrg = computed(() => {
if (!newRatingOrg.value) return []; if (!newRatingOrg.value) return [];
return getAvailableRatings(newRatingOrg.value as AgeRatingOrganization); return getAvailableRatings(newRatingOrg.value as AgeRatingOrganization);
@@ -255,6 +354,10 @@ const fetchGroup = async () => {
); );
editName.value = group.value.name; editName.value = group.value.name;
editDescription.value = group.value.description; 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 // Fetch all users for member add dropdown
@@ -323,6 +426,16 @@ const removeMember = async (userId: string) => {
await fetchGroup(); 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 () => { const addRating = async () => {
if (!group.value || !newRatingOrg.value || !newRatingValue.value) return; if (!group.value || !newRatingOrg.value || !newRatingValue.value) return;
const newRatings = [ const newRatings = [
+13 -13
View File
@@ -114,10 +114,10 @@ const GB_BOARD_TO_ORG: Record<string, AgeRatingOrganization> = {
}; };
function lowercaseNormMap( function lowercaseNormMap(
ratings: Record<string, string>, ratings: Record<string, { value: string; age: number }>,
): Record<string, string> { ): Record<string, string> {
return Object.fromEntries( 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]: { [AgeRatingOrganization.ESRB]: {
...lowercaseNormMap(ESRBRating), ...lowercaseNormMap(ESRBRating),
"early childhood": ESRBRating.EC, "early childhood": ESRBRating.EC.value,
everyone: ESRBRating.E, everyone: ESRBRating.E.value,
"everyone 10+": ESRBRating.E10, "everyone 10+": ESRBRating.E10.value,
teen: ESRBRating.T, teen: ESRBRating.T.value,
mature: ESRBRating.M, mature: ESRBRating.M.value,
"mature 17+": ESRBRating.M, "mature 17+": ESRBRating.M.value,
"adults only": ESRBRating.AO, "adults only": ESRBRating.AO.value,
"adults only 18+": ESRBRating.AO, "adults only 18+": ESRBRating.AO.value,
}, },
[AgeRatingOrganization.PEGI]: lowercaseNormMap(PEGIRating), [AgeRatingOrganization.PEGI]: lowercaseNormMap(PEGIRating),
[AgeRatingOrganization.CERO]: lowercaseNormMap(CEROrating), [AgeRatingOrganization.CERO]: lowercaseNormMap(CEROrating),
@@ -142,9 +142,9 @@ const GB_RATING_NORMALIZE: Record<
[AgeRatingOrganization.GRAC]: lowercaseNormMap(GRACRating), [AgeRatingOrganization.GRAC]: lowercaseNormMap(GRACRating),
[AgeRatingOrganization.ACB]: { [AgeRatingOrganization.ACB]: {
...lowercaseNormMap(ACBRating), ...lowercaseNormMap(ACBRating),
"ma 15+": ACBRating.MA15, "ma 15+": ACBRating.MA15.value,
"r 18+": ACBRating.R18, "r 18+": ACBRating.R18.value,
"refused classification": ACBRating.RC, "refused classification": ACBRating.RC.value,
}, },
[AgeRatingOrganization.ClassInd]: {}, [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> = { const IGDB_RATING_TO_STRING: Record<number, string> = {
// PEGI // PEGI
1: PEGIRating["3"], 1: PEGIRating["3"].value,
2: PEGIRating["7"], 2: PEGIRating["7"].value,
3: PEGIRating["12"], 3: PEGIRating["12"].value,
4: PEGIRating["16"], 4: PEGIRating["16"].value,
5: PEGIRating["18"], 5: PEGIRating["18"].value,
// ESRB // ESRB
7: ESRBRating.EC, 7: ESRBRating.EC.value,
8: ESRBRating.E, 8: ESRBRating.E.value,
9: ESRBRating.E10, 9: ESRBRating.E10.value,
10: ESRBRating.T, 10: ESRBRating.T.value,
11: ESRBRating.M, 11: ESRBRating.M.value,
12: ESRBRating.AO, 12: ESRBRating.AO.value,
// CERO // CERO
13: CEROrating.A, 13: CEROrating.A.value,
14: CEROrating.B, 14: CEROrating.B.value,
15: CEROrating.C, 15: CEROrating.C.value,
16: CEROrating.D, 16: CEROrating.D.value,
17: CEROrating.Z, 17: CEROrating.Z.value,
// USK // USK
18: USKRating["0"], 18: USKRating["0"].value,
19: USKRating["6"], 19: USKRating["6"].value,
20: USKRating["12"], 20: USKRating["12"].value,
21: USKRating["16"], 21: USKRating["16"].value,
22: USKRating["18"], 22: USKRating["18"].value,
// GRAC // GRAC
23: GRACRating.ALL, 23: GRACRating.ALL.value,
24: GRACRating["12"], 24: GRACRating["12"].value,
25: GRACRating["15"], 25: GRACRating["15"].value,
26: GRACRating["18"], 26: GRACRating["18"].value,
// CLASS_IND // CLASS_IND
28: ClassIndRating.L, 28: ClassIndRating.L.value,
29: ClassIndRating["10"], 29: ClassIndRating["10"].value,
30: ClassIndRating["12"], 30: ClassIndRating["12"].value,
31: ClassIndRating["14"], 31: ClassIndRating["14"].value,
32: ClassIndRating["16"], 32: ClassIndRating["16"].value,
33: ClassIndRating["18"], 33: ClassIndRating["18"].value,
// ACB // ACB
34: ACBRating.G, 34: ACBRating.G.value,
35: ACBRating.PG, 35: ACBRating.PG.value,
36: ACBRating.M, 36: ACBRating.M.value,
37: ACBRating.MA15, 37: ACBRating.MA15.value,
38: ACBRating.R18, 38: ACBRating.R18.value,
39: ACBRating.RC, 39: ACBRating.RC.value,
}; };
// https://api-docs.igdb.com/?shell#game // 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( function lowercaseNormMap(
ratings: Record<string, string>, ratings: Record<string, { value: string; age: number }>,
): Record<string, string> { ): Record<string, string> {
return Object.fromEntries( 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. * 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. * 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 = { export const ESRBRating = {
EC: "EC", EC: { value: "EC", age: 3 },
E: "E", E: { value: "E", age: 6 },
E10: "E10", E10: { value: "E10", age: 10 },
T: "T", T: { value: "T", age: 13 },
M: "M", M: { value: "M", age: 17 },
AO: "AO", AO: { value: "AO", age: 18 },
} as const; } as const;
export const PEGIRating = { export const PEGIRating = {
"3": "3", "3": { value: "3", age: 3 },
"7": "7", "7": { value: "7", age: 7 },
"12": "12", "12": { value: "12", age: 12 },
"16": "16", "16": { value: "16", age: 16 },
"18": "18", "18": { value: "18", age: 18 },
} as const; } as const;
export const CEROrating = { export const CEROrating = {
A: "A", A: { value: "A", age: 0 },
B: "B", B: { value: "B", age: 12 },
C: "C", C: { value: "C", age: 15 },
D: "D", D: { value: "D", age: 17 },
Z: "Z", Z: { value: "Z", age: 18 },
} as const; } as const;
export const USKRating = { export const USKRating = {
"0": "0", "0": { value: "0", age: 0 },
"6": "6", "6": { value: "6", age: 6 },
"12": "12", "12": { value: "12", age: 12 },
"16": "16", "16": { value: "16", age: 16 },
"18": "18", "18": { value: "18", age: 18 },
} as const; } as const;
export const GRACRating = { export const GRACRating = {
ALL: "ALL", ALL: { value: "ALL", age: 0 },
"12": "12", "12": { value: "12", age: 12 },
"15": "15", "15": { value: "15", age: 15 },
"18": "18", "18": { value: "18", age: 18 },
} as const; } as const;
export const ClassIndRating = { export const ClassIndRating = {
L: "L", L: { value: "L", age: 0 },
"10": "10", "10": { value: "10", age: 10 },
"12": "12", "12": { value: "12", age: 12 },
"14": "14", "14": { value: "14", age: 14 },
"16": "16", "16": { value: "16", age: 16 },
"18": "18", "18": { value: "18", age: 18 },
} as const; } as const;
export const ACBRating = { export const ACBRating = {
G: "G", G: { value: "G", age: 0 },
PG: "PG", PG: { value: "PG", age: 8 },
M: "M", M: { value: "M", age: 15 },
MA15: "MA15", MA15: { value: "MA15", age: 15 },
R18: "R18", R18: { value: "R18", age: 18 },
RC: "RC", RC: { value: "RC", age: 18 },
} as const; } as const;
export const RATINGS_FOR_ORGANIZATION = { export const RATINGS_FOR_ORGANIZATION = {
@@ -73,8 +80,62 @@ export const RATINGS_FOR_ORGANIZATION = {
[AgeRatingOrganization.GRAC]: GRACRating, [AgeRatingOrganization.GRAC]: GRACRating,
[AgeRatingOrganization.ClassInd]: ClassIndRating, [AgeRatingOrganization.ClassInd]: ClassIndRating,
[AgeRatingOrganization.ACB]: ACBRating, [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[] { 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;
} }