Skip to content

Commit

Permalink
feat: automatically ban common profanities (#133)
Browse files Browse the repository at this point in the history
* Add profanity filter

* Make normal profanities already banned

* Check for normal profanities as well as banned words

* i18n

* formatting
  • Loading branch information
marikola authored May 2, 2026
1 parent 3f8df87 commit 9d7eaa4
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 28 deletions.
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 @@ -461,6 +461,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 @@ -1109,7 +1110,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 @@ -1125,6 +1126,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.',
},
},
pendingApproval: {
Expand Down
5 changes: 4 additions & 1 deletion src/locales/nb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,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 @@ -1115,7 +1116,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 @@ -1131,6 +1132,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

0 comments on commit 9d7eaa4

Please sign in to comment.