Files
ko-grammar/docs/.vitepress/theme/components/VocabQuiz.vue
T
2026-10-16 00:28:05 +07:00

704 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<nav v-if="v">
<Teleport to="body">
<div v-if="isEdit" :class="$style.modal" ref="modal">
<nav>
<span> ko: {{ v.ko }} </span>
<div style="flex-grow: 1"></div>
<button type="button" @click="onVContentSave()">Save</button>
<button type="button" @click="isEdit = false">×</button>
</nav>
<textarea
v-model="vEditContent"
spellcheck="false"
ref="textarea"
></textarea>
</div>
</Teleport>
<div :class="$style.mark">
<button type="button" name="edit" @click="isEdit = true">✎</button>
<button type="button" name="right" @click="mark(true)">✓</button>
<button
type="button"
name="wrong"
@click="mark(false)"
style="font-size: large"
>
↻
</button>
</div>
<div>
<span :class="quizState.isKoFirst ? '' : 'spoiler'">
<SentenceAudio :v="v" lang="ko" ref="vaud" />
</span>
<span>{{ " = " }}</span>
<span :class="quizState.isKoFirst ? 'spoiler' : ''">
<SentenceAudio :v="v" lang="en" />
</span>
</div>
<details v-if="sent_ko || v.notes" ref="sdetails">
<summary>
<SentenceAudio
v-if="example"
:v="example"
lang="ko"
:spoiler="!quizState.isKoFirst"
ref="saud"
/>
<button
type="button"
name="new-sentence"
v-if="v.examples && v.examples.length > 1"
@click="makeNewExIndex()"
style="margin-inline-start: 0.5em"
>
↻
</button>
</summary>
<SentenceAudio v-if="example" :v="example" lang="en" />
<blockquote v-if="notes.length" :class="$style.notes">
<div v-for="(n, i) in notes" :key="i" v-html="n"></div>
</blockquote>
<div>
<button
type="button"
v-if="sent_ko"
:disabled="exCurrentKo !== sent_ko"
@click="
if (exNotes) {
exNotes = '';
}
explainSentence(sent_ko);
"
>
<span v-if="exCurrentKo !== sent_ko">
{{ exNotesLoading || "..." }}
</span>
<span v-else-if="!!exNotesLoading"> {{ exNotesLoading }} </span>
<span v-else-if="!exNotes"> Explain Sentence </span>
<span v-else> New explanation </span>
</button>
<blockquote v-if="exNotes" v-html="markdownIt.render(exNotes)" />
</div>
</details>
</nav>
<details :class="$style.options">
<summary>🧐 Quiz options</summary>
<nav v-if="vCats.length > 1">
<label v-for="c in vCats" :key="c">
<input
type="checkbox"
:name="c"
:checked="quizState.vChosenCats.includes(c)"
@change="updateCats(c)"
/>
{{ c.replace(/_/g, " ") }}
</label>
</nav>
<nav>
<span>
<label role="switch">
<span>KR</span>
<input
type="checkbox"
name="front"
role="switch"
v-model="quizState.isKoFirst"
/>
<span role="slider"></span>
<span>EN</span>
</label>
</span>
<label>
<input
type="checkbox"
name="autoplay-audio"
v-model="quizState.isAutoplayAudio"
/>
autoplay sentence audio
</label>
</nav>
</details>
</div>
</template>
<script setup lang="ts">
import { useData } from "vitepress";
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
useTemplateRef,
watch,
} from "vue";
import yaml from "yaml";
import SentenceAudio from "./SentenceAudio.vue";
import { supabase } from "../supabase.ts";
import { IVoc, Voc } from "../types.ts";
import { LLMstream } from "../llm.ts";
import MarkdownIt from "markdown-it";
const { fmKey = "vocabulary", category } = defineProps<{
fmKey?: string;
category?: string;
}>();
const vCats = ref<string[]>([]);
const vList = ref<IVoc[]>([]);
const vListOriginalIndex = ref<{ s: IVoc; k: number }[]>([]);
const vIndex = ref(-1);
const exIndex = ref(0);
const isEdit = ref(false);
const vEditContent = ref("");
const exCurrentKo = ref("");
const exNotes = ref("");
const exNotesLoading = ref("");
const quizState = reactive({
vChosenCats: [] as string[],
isAutoplayAudio: true,
isKoFirst: true,
});
const modalRef = useTemplateRef("modal");
const textareaRef = useTemplateRef("textarea");
const sentenceDetails = useTemplateRef("sdetails");
const saud = useTemplateRef<InstanceType<typeof SentenceAudio>>("saud");
const vaud = useTemplateRef<InstanceType<typeof SentenceAudio>>("vaud");
const v = computed<IVoc | null>(() => vList.value[vIndex.value] || null);
const example = computed(() => {
if (!v.value?.examples?.length) return null;
const ex = v.value.examples;
return ex[exIndex.value] || null;
});
const sent_ko = computed(() => {
if (example.value) return example.value.ko || "";
if (fmKey === "vocabulary") return "";
return v.value?.ko || "";
});
const notes = computed(() => [
...[example.value?.notes || []].flat(),
...[v.value?.notes || []].flat(),
]);
const MAX_VOC = 5;
const { frontmatter, page } = useData();
const LOCALSTORAGE_STATE = JSON.stringify([
page.value.filePath,
fmKey,
category,
]);
if (typeof window !== "undefined" && window.localStorage) {
const oldStateString = window.localStorage.getItem(LOCALSTORAGE_STATE);
if (oldStateString) {
try {
Object.assign(quizState, JSON.parse(oldStateString));
} catch (e) {}
}
}
function saveQuizState() {
window.localStorage.setItem(LOCALSTORAGE_STATE, JSON.stringify(quizState));
}
watch(quizState, () => {
saveQuizState();
});
let timeoutSetExNotes = 0;
watch(sent_ko, () => {
if (timeoutSetExNotes) return;
timeoutSetExNotes = window.setTimeout(() => {
timeoutSetExNotes = 0;
if (!exNotesLoading.value) {
exNotes.value = "";
}
if (!sent_ko.value) return;
let ko = sent_ko.value;
ko = ko.replaceAll("**", "");
if (!exNotesLoading.value) {
exCurrentKo.value = sent_ko.value;
}
if (supabase) {
supabase
.from("sentence-explain")
.select<any, { explain: string }>("explain")
.eq("ko", ko)
.then((r) => {
if (!r.data?.[0]) return;
exNotes.value = r.data[0].explain;
});
}
}, 50);
});
const vocabulary: Record<string, IVoc[]> = {};
for (const [k, vs] of Object.entries(
frontmatter.value[fmKey] as Record<string, IVoc[]>,
)) {
vocabulary[k] = [...(vocabulary[k] || []), ...vs];
if (!vCats.value.includes(k)) {
vCats.value = [...vCats.value, k];
}
}
if (category) {
const vCatsSet = new Set(category.split(/[, ]+/g));
const newVCats = vCats.value.filter((c) => vCatsSet.has(c));
if (newVCats.length) {
vCats.value = newVCats;
}
}
if (!quizState.vChosenCats.some((c) => vCats.value.includes(c))) {
quizState.vChosenCats = [...vCats.value];
}
watch(
() => quizState.isKoFirst,
() => {
makeVList();
},
);
watch(isEdit, () => {
if (!v.value) return;
if (isEdit.value) {
const { ko, ...vContent } = v.value;
vEditContent.value = yaml.stringify(vContent);
nextTick(() => {
if (textareaRef.value) {
textareaRef.value.style.height = textareaRef.value.scrollHeight + "px";
}
});
} else {
vEditContent.value = "";
}
});
function makeVList() {
nextTick(() => {
if (sentenceDetails.value) {
sentenceDetails.value.open = false;
}
});
const newVList = Object.entries(vocabulary)
.filter(([k]) => quizState.vChosenCats.includes(k))
.flatMap(([, vs]) => vs);
vListOriginalIndex.value = newVList.map((s) => ({
s,
k: Math.random(),
}));
vList.value = vListOriginalIndex.value
.sort((a, b) => a.k - b.k)
.map((a) => a.s);
vIndex.value = Math.floor(
Math.random() * Math.min(MAX_VOC, vList.value.length),
);
if (!v.value) return;
const ex = v.value.examples;
exIndex.value = ex ? Math.floor(Math.random() * ex.length) : 0;
}
function makeNewExIndex() {
if (!v.value) return;
if (v.value.examples && v.value.examples.length > 1) {
const newValue = Math.floor(Math.random() * (v.value.examples.length - 1));
exIndex.value = newValue >= exIndex.value ? newValue + 1 : newValue;
nextTick(() => {
if (saud.value && quizState.isAutoplayAudio) {
saud.value.playAudio();
}
});
}
}
function mark(isRight: boolean) {
exNotes.value = "";
nextTick(() => {
if (sentenceDetails.value) {
sentenceDetails.value.open = false;
}
if (quizState.isAutoplayAudio) {
nextTick(() => {
if (saud.value) {
saud.value.playAudio();
} else if (vaud.value && quizState.isKoFirst) {
vaud.value.playAudio();
}
});
}
});
if (isRight) {
vList.value = [...vList.value.filter((_, i) => i !== vIndex.value)];
vIndex.value = -1;
if (vList.value.length <= 1) {
makeVList();
return;
}
}
const i = Math.floor(
Math.random() * (Math.min(MAX_VOC, vList.value.length) - 1),
);
if (i >= vIndex.value) {
vIndex.value = i + 1;
} else {
vIndex.value = i;
}
if (!v.value) return;
const ex = v.value.examples;
exIndex.value = ex ? Math.floor(Math.random() * ex.length) : 0;
}
function updateCats(cat: string) {
const i = quizState.vChosenCats.indexOf(cat);
if (i === -1) {
quizState.vChosenCats = [...quizState.vChosenCats, cat];
} else {
quizState.vChosenCats = [
...quizState.vChosenCats.filter((_, j) => j !== i),
];
}
makeVList();
saveQuizState();
}
async function onVContentSave() {
if (!v.value) return;
const oldV = v.value;
const newV = Voc.parse({ ko: oldV.ko, ...yaml.parse(vEditContent.value) });
let key = "";
let vKey = oldV.ko;
Object.entries(vocabulary).map(([k, vs]) => {
const vCount = new Map<string, number>();
vs.map((v, i_vs) => {
let vK = v.ko;
let i = vCount.get(v.ko) || 0;
if (i) {
vK = vK + `(${i})`;
}
vCount.set(v.ko, i + 1);
if (oldV.ko === v.ko && oldV.en === v.en) {
key = JSON.stringify([fmKey, k]);
vKey = vK;
vocabulary[k][i_vs] = newV;
}
});
});
if (supabase && key) {
const user_id = await supabase.auth.getUser().then((r) => r.data.user?.id);
await supabase.from("quiz").upsert(
{
url: page.value.filePath,
key,
v: vKey,
data: newV,
user_id,
},
{ onConflict: "url,key,v,user_id" },
);
}
const newVList = [...vList.value];
newVList[vIndex.value] = newV;
vList.value = newVList;
isEdit.value = false;
}
const markdownIt = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
typographer: true,
});
let loadingIntervalId = 0;
async function explainSentence(ko: string) {
if (loadingIntervalId) return;
ko = ko.replaceAll("**", "");
exNotesLoading.value = ".";
loadingIntervalId = window.setInterval(() => {
exNotesLoading.value += ".";
}, 1000);
// Needs to be specified, or DeepSeek will vacillate between Korean, English, and Chinese.
const LANG_EXPLAIN = "Japanese";
for await (const data of LLMstream({
model: "deepseek-v4-flash",
messages: [
{
role: "system",
content: `
Explain in ${LANG_EXPLAIN} how this sentence works in 500 characters.
Give me key vocabularies in a table.
Don't give romanization forms.
`,
},
{ role: "user", content: ko },
],
})) {
if (data.done) break;
if (data.content) {
exNotes.value += data.content;
}
}
clearInterval(loadingIntervalId);
exNotesLoading.value = "";
loadingIntervalId = 0;
if (exNotes.value && supabase) {
const user_id = await supabase.auth.getUser().then((r) => r.data.user?.id);
await supabase.from("sentence-explain").upsert(
{
user_id,
ko,
explain: exNotes.value,
},
{ onConflict: "ko,user_id" },
);
}
}
function onModalClickOutside(ev: MouseEvent) {
if (!(ev.target instanceof HTMLElement)) return;
if (ev.target.matches('[name="edit"]')) return;
if (!modalRef.value) {
isEdit.value = false;
} else if (!modalRef.value.contains(ev.target.parentNode)) {
isEdit.value = false;
}
}
onMounted(async () => {
document.addEventListener("click", onModalClickOutside);
makeVList();
if (supabase) {
await supabase
.from("quiz")
.select<any, { v: string; data: IVoc }>("v,data")
.eq("url", page.value.filePath)
.then((r) => {
if (!r.data?.length) return;
const replacement = new Map<string, IVoc>();
for (const { v, data } of r.data) {
replacement.set(v, data);
}
Object.entries(vocabulary).map(([k, vs]) => {
const vCount = new Map<string, number>();
vocabulary[k] = vs.map((v) => {
let vKey = v.ko;
let i = vCount.get(v.ko) || 0;
if (i) {
vKey = vKey + `(${i})`;
}
vCount.set(v.ko, i + 1);
return replacement.get(vKey) || v;
});
});
});
}
// const newVList = Object.entries(vocabulary)
// .filter(([k]) => vChosenCats.value.includes(k))
// .flatMap(([, vs]) => vs);
// vList.value = vListOriginalIndex.value
// .map((o, i) => {
// o.s = newVList[i] || o.s;
// return o;
// })
// .sort((a, b) => a.k - b.k)
// .map((o) => o.s);
makeVList();
});
onBeforeUnmount(() => {
document.addEventListener("click", onModalClickOutside);
if (loadingIntervalId) {
clearInterval(loadingIntervalId);
}
});
</script>
<style module>
.mark {
float: right;
> * + * {
margin-inline-start: 1em;
}
}
.notes {
div + div,
p + p {
margin-top: 0.5em !important;
}
}
.options {
label {
white-space: nowrap;
}
* + label {
margin-inline-start: 0.5em;
}
label[role="switch"] {
--slider-width: 50px;
position: relative;
display: inline-block;
width: calc(var(--slider-width) + 4em);
height: 24px;
input[role="switch"] {
opacity: 0;
width: 0;
height: 0;
}
/* The track */
[role="slider"] {
position: absolute;
cursor: pointer;
background-color: #ccc;
border-radius: 24px;
width: var(--slider-width);
height: 100%;
transition: background-color 0.3s;
+ span {
margin-left: calc(var(--slider-width) + 0.5em);
}
}
/* The circular slider */
[role="slider"]::before {
content: "";
position: absolute;
height: 20px;
width: 20px;
left: 4px;
bottom: 2px;
background-color: rgb(87, 87, 87);
border-radius: 50%;
transition: transform 0.3s;
}
/* Toggled state */
input:not(:checked) {
+ [role="slider"] {
/* background-color: #4caf50; */
background-color: gainsboro;
}
+ [role="slider"]::before {
transform: translateX(23px);
}
}
}
}
.modal {
--margin-top: 120px;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: calc(100vw - 4em);
max-width: 1000px;
nav {
display: flex;
flex-direction: row;
background-color: rgba(128, 128, 128, 0.8);
padding: 0.5em;
border-radius: 0.5em 0.5em 0 0;
* + * {
margin-inline-start: 1em;
}
}
textarea {
width: 100%;
max-height: calc(90vh - var(--margin-top));
padding: 1em;
border-radius: 0 0 0.5em 0.5em;
font-family: sans-serif;
resize: none;
}
}
</style>