Skip to content

feat: behaviour control #98

Closed
wants to merge 13 commits into from
Closed
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 @@ -22,6 +22,7 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"leo-profanity": "^1.9.0",
"canvas-confetti": "^1.9.4",
"lucide-vue-next": "^1.0.0",
"pinia": "^3.0.4",
Expand Down
127 changes: 73 additions & 54 deletions src/mystery/components/MysterySubmissionForm.vue
Original file line number Diff line number Diff line change
@@ -1,89 +1,108 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { moderateText } from '@/shared/moderation/banned-words'
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 { t } = useI18n();
const { hasProfanity } = useProfanityFilter();

const props = defineProps<{
submitting: boolean
successMessage?: string | null
errorMessage?: string | null
bannedWords?: string[]
}>()
submitting: boolean;
successMessage?: string | null;
errorMessage?: string | null;
bannedWords?: string[];
}>();

const emit = defineEmits<{
submit: [description: string, file: File | null]
}>()

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']

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,
)
submit: [description: string, file: File | null];
}>();

const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];

const description = ref("");
const attachmentFile = ref<File | null>(null);
const attachmentPreviewUrl = ref<string | null>(null);
const uploadError = ref<string | null>(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.profanity");
}

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
const file = input.files?.[0] ?? null
const input = event.target as HTMLInputElement;
const file = input.files?.[0] ?? null;

uploadError.value = null
uploadError.value = null;

if (attachmentPreviewUrl.value) {
URL.revokeObjectURL(attachmentPreviewUrl.value)
attachmentPreviewUrl.value = null
URL.revokeObjectURL(attachmentPreviewUrl.value);
attachmentPreviewUrl.value = null;
}

if (file && !ALLOWED_TYPES.includes(file.type)) {
uploadError.value = t('mystery.submitForm.unsupportedFileType')
input.value = ''
return
uploadError.value = t("mystery.submitForm.unsupportedFileType");
input.value = "";
return;
}

attachmentFile.value = file
if (file) attachmentPreviewUrl.value = URL.createObjectURL(file)
attachmentFile.value = file;
if (file) attachmentPreviewUrl.value = URL.createObjectURL(file);
}

function clearAttachment() {
if (attachmentPreviewUrl.value)
URL.revokeObjectURL(attachmentPreviewUrl.value)
attachmentFile.value = null
attachmentPreviewUrl.value = null
uploadError.value = null
URL.revokeObjectURL(attachmentPreviewUrl.value);
attachmentFile.value = null;
attachmentPreviewUrl.value = null;
uploadError.value = null;
}

function handleSubmit() {
if (!canSubmit()) return
emit('submit', description.value.trim(), attachmentFile.value)
if (!canSubmit()) return;
emit("submit", description.value.trim(), attachmentFile.value);
}

defineExpose({ reset })
defineExpose({ reset });

function reset() {
description.value = ''
clearAttachment()
description.value = "";
clearAttachment();
}
</script>

<template>
<div class="mx-auto max-w-xl rounded-2xl bg-white p-6 shadow-md">
<h2 class="mb-4 text-lg font-semibold text-gray-900">
{{ t('mystery.submitForm.heading') }}
{{ t("mystery.submitForm.heading") }}
</h2>

<div
Expand All @@ -100,7 +119,7 @@ function reset() {
for="mystery-description"
class="mb-1 block text-sm font-semibold text-gray-700"
>
{{ t('mystery.submitForm.descriptionLabel') }}
{{ t("mystery.submitForm.descriptionLabel") }}
</label>
<textarea
id="mystery-description"
Expand All @@ -127,7 +146,7 @@ function reset() {
class="w-full rounded-lg border border-red-300 bg-white py-1 text-xs font-semibold text-red-600 hover:bg-red-50"
@click="clearAttachment"
>
{{ t('mystery.submitForm.removeImage') }}
{{ t("mystery.submitForm.removeImage") }}
</button>
</div>

Expand All @@ -138,7 +157,7 @@ function reset() {
>
<span class="text-2xl text-amber-500">&#128247;</span>
<span class="text-center text-xs font-semibold text-gray-400">
{{ t('mystery.submitForm.uploadLabel') }}
{{ t("mystery.submitForm.uploadLabel") }}
</span>
<input
id="mystery-file"
Expand Down Expand Up @@ -166,8 +185,8 @@ function reset() {
:disabled="!canSubmit() || submitting"
@click="handleSubmit"
>
<span v-if="submitting">{{ t('mystery.submitForm.submitting') }}</span>
<span v-else>{{ t('mystery.submitForm.submit') }}</span>
<span v-if="submitting">{{ t("mystery.submitForm.submitting") }}</span>
<span v-else>{{ t("mystery.submitForm.submit") }}</span>
</button>
</div>
</template>
1 change: 1 addition & 0 deletions src/notifications/notifications.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export const notificationTypes = [
'GAME_COMPLETED',
'PUPIL_STUCK',
'WEEKLY_MYSTERY_SUBMITTED',
'INAPPROPRIATE_BEHAVIOUR',
] as const

export type NotificationType = (typeof notificationTypes)[number]
Expand Down
Loading
Loading