mirror of
https://github.com/Drop-OSS/drop.git
synced 2026-08-20 05:21:29 +10:00
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:
co-authored by
Robert Clabough
parent
b6b28afc2e
commit
26abc584a9
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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]: {},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user