381 lines
8.4 KiB
Vue
381 lines
8.4 KiB
Vue
<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("_", " ") }}
|
||
</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 SentenceAudio, { IVoc } from "./SentenceAudio.vue";
|
||
|
||
const { fmKey = "vocabulary" } = defineProps<{
|
||
fmKey?: string;
|
||
}>();
|
||
|
||
const vCats = ref<string[]>([]);
|
||
const vChosenCats = ref<string[]>([]);
|
||
const vList = ref<IVoc[]>([]);
|
||
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 } = 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];
|
||
makeVList();
|
||
|
||
watch(isKoFirst, () => {
|
||
makeVList();
|
||
});
|
||
|
||
watch(isEdit, () => {
|
||
if (isEdit.value) {
|
||
const { ko, ...vContent } = v.value;
|
||
vEditContent.value = JSON.stringify(vContent, null, 2);
|
||
} else {
|
||
vEditContent.value = "";
|
||
}
|
||
});
|
||
|
||
function makeVList() {
|
||
nextTick(() => {
|
||
if (sentenceDetails.value) {
|
||
sentenceDetails.value.open = false;
|
||
}
|
||
});
|
||
|
||
vList.value = Object.entries(vocabulary)
|
||
.filter(([k]) => vChosenCats.value.includes(k))
|
||
.flatMap(([, v]) => v);
|
||
vList.value = vList.value
|
||
.map((s) => ({ s, k: Math.random() }))
|
||
.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();
|
||
}
|
||
|
||
function onVContentSave() {
|
||
const newV = { ko: v.value.ko, ...JSON.parse(vEditContent.value) };
|
||
|
||
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(() => {
|
||
document.addEventListener("click", onModalClickOutside);
|
||
});
|
||
|
||
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>
|