Files
ko-grammar/docs/.vitepress/theme/components/VocabQuiz.vue
T
2026-07-18 11:01:25 +07:00

471 lines
11 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"></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="isKoFirst ? '' : 'spoiler'">
<SentenceAudio :v="v" lang="ko" ref="vaud" />
</span>
<span>{{ " = " }}</span>
<span :class="isKoFirst ? 'spoiler' : ''"> {{ v.en }} </span>
</div>
<details v-if="example || v.notes" ref="sdetails">
<summary>
<SentenceAudio
v-if="example"
:v="example"
lang="ko"
:spoiler="!isKoFirst"
ref="saud"
/>
</summary>
<SentenceAudio v-if="example" :v="example" lang="en" />
<blockquote
v-if="v.notes"
v-html="v.notes"
:class="$style.notes"
></blockquote>
</details>
</nav>
<details :class="$style.options">
<summary>Quiz options</summary>
<nav>
<label v-for="c in vCats" :key="c">
<input type="checkbox" :name="c" checked @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="isKoFirst"
/>
<span role="slider"></span>
<span>EN</span>
</label>
</span>
<label>
<input
type="checkbox"
name="autoplay-audio"
v-model="isAutoplayAudio"
/>
autoplay sentence audio
</label>
</nav>
</details>
</div>
</template>
<script setup lang="ts">
import { useData } from "vitepress";
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
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";
const { fmKey = "vocabulary" } = defineProps<{
fmKey?: string;
}>();
const vCats = ref<string[]>([]);
const vChosenCats = 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 isAutoplayAudio = ref(true);
const isKoFirst = ref(true);
const modalRef = useTemplateRef("modal");
const sentenceDetails = useTemplateRef("sdetails");
const saud = useTemplateRef<InstanceType<typeof SentenceAudio>>("saud");
const vaud = useTemplateRef<InstanceType<typeof SentenceAudio>>("vaud");
const v = computed(() => vList.value[vIndex.value]);
const example = computed(() => {
if (!v.value.examples?.length) return null;
const ex = v.value.examples;
return ex[exIndex.value];
});
const MAX_VOC = 5;
const { frontmatter, page } = useData();
const vocabulary: Record<string, IVoc[]> = {};
for (const fk of fmKey.split(/[, ]+/g)) {
for (const [k, vs] of Object.entries(
frontmatter.value[fk] as Record<string, IVoc[]>,
)) {
vocabulary[k] = [...(vocabulary[k] || []), ...vs];
if (!vCats.value.includes(k)) {
vCats.value = [...vCats.value, k];
}
}
}
vChosenCats.value = [...vCats.value];
watch(isKoFirst, () => {
makeVList();
});
watch(isEdit, () => {
if (isEdit.value) {
const { ko, ...vContent } = v.value;
vEditContent.value = yaml.stringify(vContent);
} else {
vEditContent.value = "";
}
});
function makeVList() {
nextTick(() => {
if (sentenceDetails.value) {
sentenceDetails.value.open = false;
}
});
const newVList = Object.entries(vocabulary)
.filter(([k]) => vChosenCats.value.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),
);
const ex = v.value.examples;
exIndex.value = ex ? Math.floor(Math.random() * ex.length) : 0;
}
function mark(isRight: boolean) {
nextTick(() => {
if (sentenceDetails.value) {
sentenceDetails.value.open = false;
}
if (isAutoplayAudio.value) {
nextTick(() => {
if (saud.value) {
saud.value.playAudio();
} else if (vaud.value && isKoFirst.value) {
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;
}
const ex = v.value.examples;
exIndex.value = ex ? Math.floor(Math.random() * ex.length) : 0;
}
function updateCats(cat: string) {
const i = vChosenCats.value.indexOf(cat);
if (i === -1) {
vChosenCats.value = [...vChosenCats.value, cat];
} else {
vChosenCats.value = [...vChosenCats.value.filter((_, j) => j !== i)];
}
makeVList();
}
async function onVContentSave() {
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) {
// TODO: handling multiple fmKey comma-separated
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;
}
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);
});
onBeforeUnmount(() => {
document.addEventListener("click", onModalClickOutside);
});
</script>
<style module>
.mark {
float: right;
> * + * {
margin-inline-start: 1em;
}
}
.notes {
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: calc(120px + env(safe-area-inset-top));
position: absolute;
top: var(--margin-top);
left: 50%;
transform: translateX(-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%;
height: calc(90vh - var(--margin-top) - env(safe-area-inset-bottom));
padding: 1em;
border-radius: 0 0 0.5em 0.5em;
font-family: sans-serif;
resize: none;
}
}
</style>