Skip to content

feat: automatically ban common profanities #133

Merged
merged 5 commits into from
May 2, 2026
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"canvas-confetti": "^1.9.4",
"leo-profanity": "^1.9.0",
"lucide-vue-next": "^1.0.0",
"pinia": "^3.0.4",
"tailwindcss": "^4.2.2",
Expand Down
35 changes: 33 additions & 2 deletions src/classrooms/pages/ClassroomModerationPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
requestUpdateTeacherClassroomBannedWord,
type ClassroomBannedWordResponse,
} from '@/shared/moderation/moderation.api'
import { useProfanityFilter } from '@/shared/composables/useProfanityFilter'
const route = useRoute()
const { t } = useI18n()
Expand All @@ -22,6 +23,7 @@ const errorMessage = ref<string | null>(null)
const newWord = ref('')
const editingId = ref<number | null>(null)
const editingWord = ref('')
const { hasProfanity } = useProfanityFilter()
const authorizationHeader = computed(() => authStore.authorizationHeader)
Expand Down Expand Up @@ -50,6 +52,10 @@ async function loadBannedWords() {
}
}
function normalizeWord(word: string) {
return word.trim().toLowerCase()
}
function startEditing(entry: ClassroomBannedWordResponse) {
editingId.value = entry.id
editingWord.value = entry.word
Expand All @@ -66,12 +72,24 @@ async function createWord() {
return
}
const normalizedWord = newWord.value.trim()
const normalizedWord = normalizeWord(newWord.value)
if (!normalizedWord) {
errorMessage.value = t('classroomModeration.errors.missingWord')
return
}
if (hasProfanity(normalizedWord)) {
errorMessage.value = t('classroomModeration.errors.alreadyBlockedGlobally')
return
}
// avoid duplicates
if (bannedWords.value.some((w) => normalizeWord(w.word) === normalizedWord)) {
errorMessage.value = t('classroomModeration.errors.duplicateWord')
return
}
status.value = 'saving'
errorMessage.value = null
Expand Down Expand Up @@ -106,6 +124,19 @@ async function saveWord(entryId: number) {
return
}
if (hasProfanity(normalizedWord)) {
errorMessage.value = t('classroomModeration.errors.alreadyBlockedGlobally')
return
}
if (
bannedWords.value.some(
(w) => w.id !== entryId && normalizeWord(w.word) === normalizedWord,
)
) {
errorMessage.value = t('classroomModeration.errors.duplicateWord')
return
}
status.value = 'saving'
errorMessage.value = null
Expand Down Expand Up @@ -167,7 +198,7 @@ onMounted(() => {

<template>
<main
class="mx-auto flex min-h-screen w-full max-w-5xl flex-col gap-6 px-6 py-10"
class="mx-auto flex min-h-screen w-full max-w-5xl flex-col gap-6 px-6 py-10 pt-30"
>
<header class="space-y-2">
<h1 class="text-4xl font-extrabold text-zinc-900">
Expand Down
5 changes: 4 additions & 1 deletion src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ export const en = {
namePlaceholder: 'Detective name',
nameUpdateSuccess: 'Display name updated.',
nameUpdateError: 'Could not update the display name.',
nameProfanityError: 'Display name cannot contain banned words',
avatarTitle: 'Avatar',
avatarPlaceholder:
'The avatar builder is not fully implemented yet, but this is where avatar editing belongs.',
Expand Down Expand Up @@ -1059,7 +1060,7 @@ export const en = {
classroomModeration: {
title: 'Word filter',
description:
'Add, edit, and remove words that should be blocked in pupil text.',
'Add, edit, and remove words that should be blocked in pupil text. Typical profanities are blocked automatically.',
addLabel: 'New word',
addPlaceholder: 'Enter a word or phrase',
addAction: 'Add',
Expand All @@ -1075,6 +1076,8 @@ export const en = {
update: 'Could not update the word.',
delete: 'Could not delete the word.',
missingWord: 'Enter a word before saving.',
alreadyBlockedGlobally: 'This word is already blocked globally.',
duplicateWord: 'This word has already been blocked.',
},
},
mystery: {
Expand Down
5 changes: 4 additions & 1 deletion src/locales/nb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,7 @@ export const nb = {
namePlaceholder: 'Detektivnavn',
nameUpdateSuccess: 'Visningsnavnet er oppdatert.',
nameUpdateError: 'Kunne ikke oppdatere visningsnavnet.',
nameProfanityError: 'Navnet kan ikke inneholde forbudte ord',
avatarTitle: 'Avatar',
avatarPlaceholder:
'Avatarbyggeren er ikke fullt implementert ennå, men dette er stedet for avatarredigering.',
Expand Down Expand Up @@ -1101,7 +1102,7 @@ export const nb = {
classroomModeration: {
title: 'Ordfilter',
description:
'Legg til, endre og fjern ord som skal blokkeres i elevtekster.',
'Legg til, endre og fjern ord som skal blokkeres i elevtekster. Typiske banneord er automatisk blokkerte.',
addLabel: 'Nytt ord',
addPlaceholder: 'Skriv inn ord eller uttrykk',
addAction: 'Legg til',
Expand All @@ -1117,6 +1118,8 @@ export const nb = {
update: 'Kunne ikke oppdatere ordet.',
delete: 'Kunne ikke slette ordet.',
missingWord: 'Skriv inn et ord før du lagrer.',
alreadyBlockedGlobally: 'Ordet er allerede blokkert globalt.',
duplicateWord: 'Ordet har allerede blitt blokkert.',
},
},
pendingApproval: {
Expand Down
36 changes: 25 additions & 11 deletions src/mystery/components/MysterySubmissionForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { moderateText } from '@/shared/moderation/banned-words'
import { useProfanityFilter } from '@/shared/composables/useProfanityFilter.ts'
const { t } = useI18n()
const { hasProfanity } = useProfanityFilter()
const props = defineProps<{
submitting: boolean
Expand All @@ -22,21 +24,33 @@ const description = ref('')
const attachmentFile = ref<File | null>(null)
const attachmentPreviewUrl = ref<string | null>(null)
const uploadError = ref<string | null>(null)
const moderation = computed(() =>
moderateText([description.value], props.bannedWords ?? []),
)
const moderationError = computed(() =>
moderation.value.hasBlockedWords
? t('mystery.submitForm.bannedWords', {
words: moderation.value.blockedWords.join(', '),
})
: null,
)
const moderation = computed(() => {
const text = description.value
const bannedCheck = moderateText([text], props.bannedWords ?? [])
const profanityDetected = hasProfanity(text)
return {
...bannedCheck,
hasProfanity: profanityDetected,
hasAnyViolation: bannedCheck.hasBlockedWords || profanityDetected,
}
})
const moderationError = computed(() => {
if (moderation.value.hasBlockedWords) {
return t('mystery.submitForm.bannedWords', {
words: moderation.value.blockedWords.join(', '),
})
}
if (moderation.value.hasProfanity) {
return t('mystery.submitForm.bannedWords')
}
return null
})
const canSubmit = () =>
description.value.trim().length > 0 &&
!uploadError.value &&
!moderation.value.hasBlockedWords
!moderation.value.hasAnyViolation
function onFileSelected(event: Event) {
const input = event.target as HTMLInputElement
Expand Down
42 changes: 30 additions & 12 deletions src/notebook/components/NotebookComponent.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type {
} from '@/notebook/model/notebook.types'
import { requestClassroomBannedWords } from '@/shared/moderation/moderation.api'
import { moderateText } from '@/shared/moderation/banned-words'
import { useProfanityFilter } from '@/shared/composables/useProfanityFilter.ts'
const authStore = useAuthStore()
const { t } = useI18n()
Expand All @@ -54,6 +55,7 @@ const errorMessage = ref<string | null>(null)
const newEntryTitle = ref('')
const newEntryContent = ref('')
const bannedWords = ref<string[]>([])
const { hasProfanity } = useProfanityFilter()
const currentPageByStopId = ref<Record<number, number>>({})
const pageBodyElement = ref<HTMLElement | null>(null)
const formElement = ref<HTMLElement | null>(null)
Expand Down Expand Up @@ -134,16 +136,31 @@ const entriesByStopId = computed(() =>
const defaultCreateStopId = computed(
() => currentStopId.value ?? mapStops.value[0]?.stopId ?? null,
)
const notebookModeration = computed(() =>
moderateText([newEntryTitle.value, newEntryContent.value], bannedWords.value),
)
const notebookModerationMessage = computed(() =>
notebookModeration.value.hasBlockedWords
? t('notebook.errors.bannedWords', {
words: notebookModeration.value.blockedWords.join(', '),
})
: null,
)
const notebookModeration = computed(() => {
const title = newEntryTitle.value
const content = newEntryContent.value
const bannedCheck = moderateText([title, content], bannedWords.value)
const profanityDetected = hasProfanity(title) || hasProfanity(content)
return {
...bannedCheck,
hasProfanity: profanityDetected,
hasAnyViolation: bannedCheck.hasBlockedWords || profanityDetected,
}
})
const notebookModerationMessage = computed(() => {
if (notebookModeration.value.hasBlockedWords) {
return t('notebook.errors.bannedWords', {
words: notebookModeration.value.blockedWords.join(', '),
})
}
if (notebookModeration.value.hasProfanity) {
return t('notebook.errors.profanity')
}
return null
})
const pageCapacity = computed(() =>
Math.max(availablePageHeight.value || defaultPageHeight, 220),
)
Expand Down Expand Up @@ -361,7 +378,7 @@ async function createEntry() {
return
}
if (notebookModeration.value.hasBlockedWords) {
if (notebookModeration.value.hasAnyViolation) {
status.value = 'error'
errorMessage.value = notebookModerationMessage.value
return
Expand Down Expand Up @@ -707,7 +724,8 @@ onBeforeUnmount(() => {
:disabled="
status === 'saving' ||
currentClassroomId === null ||
defaultCreateStopId === null
defaultCreateStopId === null ||
notebookModeration.hasAnyViolation
"
type="submit"
>
Expand Down
Loading