678 lines
15 KiB
Vue
678 lines
15 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"
|
||
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="example || 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="v.notes" :class="$style.notes">
|
||
<div v-if="Array.isArray(v.notes)">
|
||
<div v-for="(n, i) in v.notes" :key="i" v-html="n"></div>
|
||
</div>
|
||
<div v-else v-html="v.notes"></div>
|
||
</blockquote>
|
||
<div v-if="example">
|
||
<button
|
||
type="button"
|
||
:disabled="exCurrentKo !== example.ko || !!exNotesLoading"
|
||
@click="
|
||
if (exNotes) {
|
||
exNotes = '';
|
||
}
|
||
explainSentence(example.ko);
|
||
"
|
||
>
|
||
<span v-if="exCurrentKo !== example.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(() => 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 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(example, () => {
|
||
if (timeoutSetExNotes) return;
|
||
|
||
timeoutSetExNotes = window.setTimeout(() => {
|
||
timeoutSetExNotes = 0;
|
||
|
||
if (!exNotesLoading.value) {
|
||
exNotes.value = "";
|
||
}
|
||
|
||
if (!example.value) return;
|
||
|
||
let { ko } = example.value;
|
||
ko = ko.replaceAll("**", "");
|
||
|
||
if (!exNotesLoading.value) {
|
||
exCurrentKo.value = example.value.ko;
|
||
}
|
||
|
||
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 (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),
|
||
);
|
||
|
||
const ex = v.value.examples;
|
||
exIndex.value = ex ? Math.floor(Math.random() * ex.length) : 0;
|
||
}
|
||
|
||
function makeNewExIndex() {
|
||
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;
|
||
}
|
||
|
||
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() {
|
||
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.
|
||
`,
|
||
},
|
||
{ 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);
|
||
});
|
||
</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: calc(120px + env(safe-area-inset-top));
|
||
|
||
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) - env(safe-area-inset-bottom));
|
||
padding: 1em;
|
||
border-radius: 0 0 0.5em 0.5em;
|
||
font-family: sans-serif;
|
||
resize: none;
|
||
}
|
||
}
|
||
</style>
|