finish srs system #2
@@ -74,7 +74,6 @@ const nextSubject = async (subjectOptions: SubjectOptions): Promise<ReviewItem |
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const item = res.data
|
const item = res.data
|
||||||
if (!item || item.message === 'No reviews due') return null
|
if (!item || item.message === 'No reviews due') return null
|
||||||
|
|
||||||
@@ -96,22 +95,17 @@ const nextSubject = async (subjectOptions: SubjectOptions): Promise<ReviewItem |
|
|||||||
} else if (mode === 'jp_en_reading') {
|
} else if (mode === 'jp_en_reading') {
|
||||||
subjectText = item.characters
|
subjectText = item.characters
|
||||||
answers = item.readings?.filter(r => r.accepted_answer).map(r => r.reading) || []
|
answers = item.readings?.filter(r => r.accepted_answer).map(r => r.reading) || []
|
||||||
|
if (answers.length === 0)
|
||||||
|
answers = [item.characters]
|
||||||
} else if (mode === 'en_jp_writing') {
|
} else if (mode === 'en_jp_writing') {
|
||||||
subjectText = item.meanings?.filter(m => m.primary).map(m => m.meaning).join(', ') || ''
|
subjectText = item.meanings?.filter(m => m.primary).map(m => m.meaning).join(', ') || ''
|
||||||
if (subjectOptions.writingMode === 'kanji') {
|
if (subjectOptions.writingMode === 'kanji') {
|
||||||
answers = [item.characters]
|
answers = [item.characters]
|
||||||
} else if (subjectOptions.writingMode === 'kana') {
|
} else if (subjectOptions.writingMode === 'kana') {
|
||||||
answers = item.readings?.filter(r => r.accepted_answer).map(r => r.reading) || []
|
answers = item.readings?.filter(r => r.accepted_answer).map(r => r.reading) || []
|
||||||
|
} else if (answers.length === 0)
|
||||||
|
answers = [item.characters]
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
console.log({
|
|
||||||
type: item.type,
|
|
||||||
subject: subjectText,
|
|
||||||
answers,
|
|
||||||
pronunciation_audios,
|
|
||||||
mode,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: item.type,
|
type: item.type,
|
||||||
@@ -122,7 +116,6 @@ const nextSubject = async (subjectOptions: SubjectOptions): Promise<ReviewItem |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const getStatistics = async () => {
|
const getStatistics = async () => {
|
||||||
const res = await axios.get('/api/v1/review/stats', {
|
const res = await axios.get('/api/v1/review/stats', {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
type ReviewQueueItem = {
|
|
||||||
type: 'kanji' | 'vocab',
|
|
||||||
subject: any,
|
|
||||||
mode: 'meaning' | 'writing',
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Reviews {
|
|
||||||
private static instance: Reviews
|
|
||||||
|
|
||||||
public data: {
|
|
||||||
kanji: any[],
|
|
||||||
vocab: any[],
|
|
||||||
}
|
|
||||||
|
|
||||||
private queue: { type: 'kanji' | 'vocab', subject: any, mode: 'meaning' | 'writing' }[] = []
|
|
||||||
|
|
||||||
private options: {
|
|
||||||
type: 'kanji' | 'vocab' | 'both',
|
|
||||||
mode: 'meaning' | 'writing' | 'both',
|
|
||||||
}
|
|
||||||
|
|
||||||
private constructor(options: { type?: 'kanji' | 'vocab' | 'both', mode?: 'meaning' | 'writing' | 'both' }) {
|
|
||||||
this.data = {
|
|
||||||
kanji: [],
|
|
||||||
vocab: [],
|
|
||||||
}
|
|
||||||
this.options = {
|
|
||||||
type: options.type ?? 'both',
|
|
||||||
mode: options.mode ?? 'both',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async getInstance(options: { type?: 'kanji' | 'vocab' | 'both', mode?: 'meaning' | 'writing' | 'both' } = {}): Promise<Reviews> {
|
|
||||||
if (!Reviews.instance) {
|
|
||||||
const instance = new Reviews(options)
|
|
||||||
await instance.loadSubjects()
|
|
||||||
instance.buildQueue()
|
|
||||||
Reviews.instance = instance
|
|
||||||
}
|
|
||||||
return Reviews.instance
|
|
||||||
}
|
|
||||||
|
|
||||||
public async setOptions(newOptions: { type?: 'kanji' | 'vocab' | 'both', mode?: 'meaning' | 'writing' | 'both' }) {
|
|
||||||
let needsReload = false
|
|
||||||
|
|
||||||
if (newOptions.type && newOptions.type !== this.options.type) {
|
|
||||||
this.options.type = newOptions.type
|
|
||||||
needsReload = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newOptions.mode && newOptions.mode !== this.options.mode) {
|
|
||||||
this.options.mode = newOptions.mode
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needsReload) {
|
|
||||||
await this.loadSubjects()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.buildQueue()
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadSubjects() {
|
|
||||||
const typesToLoad: ('kanji' | 'vocab')[] =
|
|
||||||
this.options.type === 'both'
|
|
||||||
? ['kanji', 'vocab']
|
|
||||||
: [this.options.type]
|
|
||||||
|
|
||||||
for (const type of typesToLoad) {
|
|
||||||
await this.getSubjects(type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildQueue() {
|
|
||||||
this.queue = []
|
|
||||||
|
|
||||||
const types: ('kanji' | 'vocab')[] =
|
|
||||||
this.options.type === 'both'
|
|
||||||
? ['kanji', 'vocab']
|
|
||||||
: [this.options.type]
|
|
||||||
|
|
||||||
for (const type of types) {
|
|
||||||
for (const subject of this.data[type]) {
|
|
||||||
switch (this.options.mode) {
|
|
||||||
case 'meaning':
|
|
||||||
this.queue.push({ type, subject, mode: 'meaning' })
|
|
||||||
break
|
|
||||||
case 'writing':
|
|
||||||
this.queue.push({ type, subject, mode: 'writing' })
|
|
||||||
break
|
|
||||||
case 'both':
|
|
||||||
this.queue.push({ type, subject, mode: 'meaning' })
|
|
||||||
this.queue.push({ type, subject, mode: 'writing' })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.shuffleQueue()
|
|
||||||
}
|
|
||||||
|
|
||||||
private shuffleQueue() {
|
|
||||||
for (let i = this.queue.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1))
|
|
||||||
const tmp = this.queue[i]!
|
|
||||||
this.queue[i] = this.queue[j]!
|
|
||||||
this.queue[j] = tmp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getSubjects(type: 'kanji' | 'vocab') {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/v1/subject/${type}`)
|
|
||||||
if (!res.ok) throw new Error(res.statusText)
|
|
||||||
this.data[type] = await res.json()
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public nextSubject(): ReviewQueueItem | null {
|
|
||||||
return (this.queue.shift() as ReviewQueueItem | undefined) ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
public getStats() {
|
|
||||||
const stats: {
|
|
||||||
mode: 'meaning' | 'writing' | 'both',
|
|
||||||
type: 'kanji' | 'vocab' | 'both',
|
|
||||||
kanjiCount: number,
|
|
||||||
vocabCount: number,
|
|
||||||
total: number,
|
|
||||||
queueRemaining: number,
|
|
||||||
} = {
|
|
||||||
mode: this.options.mode,
|
|
||||||
type: this.options.type,
|
|
||||||
kanjiCount: this.data.kanji.length,
|
|
||||||
vocabCount: this.data.vocab.length,
|
|
||||||
queueRemaining: this.queue.length,
|
|
||||||
total: this.queue.length,
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
v-container.fill-height.d-flex.justify-center.align-center
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
v-card.pa-12.rounded-lg.elevation-12
|
v-card.pa-12.rounded-lg.elevation-12
|
||||||
template(v-if="!finished")
|
template(v-if="!allDone")
|
||||||
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
span.text-primary Full Trainer
|
span.text-primary Full Trainer
|
||||||
|
|
||||||
@@ -61,20 +61,25 @@ v-container.fill-height.d-flex.justify-center.align-center
|
|||||||
@click="submitAnswer"
|
@click="submitAnswer"
|
||||||
) Resolve
|
) Resolve
|
||||||
template(v-else)
|
template(v-else)
|
||||||
v-card-title.text-center.text-h2.font-weight-bold.mb-6.text-success
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
span.text-success.text-gradient Well Done!
|
span.text-primary 🎉 Congratulations!
|
||||||
v-card-text.text-center.text-h5
|
v-card-text.text-center
|
||||||
| You’ve completed all cards for this session.
|
| You have completed all reviews for today!
|
||||||
v-row.justify-center.mt-6
|
v-row.justify-center.mt-6
|
||||||
v-col(cols="6")
|
v-col(cols="6")
|
||||||
v-btn(color="primary" block @click="quitSession") Return Home
|
v-btn(
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
@click="quitSession"
|
||||||
|
) Back to Home
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, nextTick, onBeforeUnmount, computed } from 'vue'
|
import { ref, onMounted, nextTick, onBeforeUnmount, computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import * as wanakana from 'wanakana'
|
import * as wanakana from 'wanakana'
|
||||||
import { Reviews } from '../composables/subject.ts'
|
|
||||||
|
import { nextSubject, correct, incorrect } from '../composables/srs.scoring.ts'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -86,21 +91,12 @@ const isDisabled = ref<boolean>(false)
|
|||||||
const isWanakanaBound = ref<boolean>(false)
|
const isWanakanaBound = ref<boolean>(false)
|
||||||
const inputMode = ref<'meaning' | 'writing' | 'kanji'>('meaning')
|
const inputMode = ref<'meaning' | 'writing' | 'kanji'>('meaning')
|
||||||
const resultMessage = ref<string>('')
|
const resultMessage = ref<string>('')
|
||||||
const reviews = ref<Reviews | null>(null)
|
|
||||||
const subject = ref<any>(null)
|
const subject = ref<any>(null)
|
||||||
|
const allDone = ref(false)
|
||||||
const direction = computed(() => route.query.direction)
|
|
||||||
const options = computed(() => JSON.parse(route.query.options as string))
|
|
||||||
|
|
||||||
const finished = ref<boolean>(false)
|
|
||||||
const subjectType = ref<string>('')
|
|
||||||
|
|
||||||
const subjectColor = computed(() => {
|
const subjectColor = computed(() => {
|
||||||
if (!subject.value) return 'var(--color-all)'
|
if (!subject.value) return 'var(--color-all)'
|
||||||
|
switch (subject.value.type) {
|
||||||
console.log(subjectType.value)
|
|
||||||
|
|
||||||
switch (subjectType.value) {
|
|
||||||
case 'kanji':
|
case 'kanji':
|
||||||
return 'var(--color-kanji)'
|
return 'var(--color-kanji)'
|
||||||
case 'vocab':
|
case 'vocab':
|
||||||
@@ -110,71 +106,61 @@ const subjectColor = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function createReview() {
|
const options = computed(() => JSON.parse(route.query.options as string))
|
||||||
if (!reviews.value) return
|
|
||||||
|
|
||||||
const next = reviews.value.nextSubject()
|
const optionsCalculated = ref({
|
||||||
|
subject: options.value.type === 'writing' ? 'kanji' : options.value.type === 'all' ? ['kanji', 'vocab'] : options.value.type,
|
||||||
|
mode: options.value.writing && options.value.meaning ? ['reading', 'meaning'] : options.value.writing ? 'reading' : options.value.meaning ? 'meaning' : 'reading',
|
||||||
|
writingPractice: options.value.type === 'writing',
|
||||||
|
direction: options.value.direction,
|
||||||
|
writingMode: options.value.kanji ? 'kanji' : 'kana',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function createReview() {
|
||||||
|
const next = await nextSubject(optionsCalculated.value)
|
||||||
if (!next) {
|
if (!next) {
|
||||||
finished.value = true
|
allDone.value = true
|
||||||
|
character.value = ''
|
||||||
|
resultMessage.value = '✅ All done!'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
subject.value = next.subject
|
allDone.value = false
|
||||||
inputMode.value = next.mode
|
subject.value = next
|
||||||
subjectType.value = next.type
|
|
||||||
|
|
||||||
if (direction.value === 'jp->en') {
|
if (optionsCalculated.value.direction === 'en->jp' && optionsCalculated.value.writingMode === 'kanji')
|
||||||
character.value = subject.value.characters
|
inputMode.value = 'kanji'
|
||||||
} else {
|
else if (subject.value.mode === 'jp_en_reading' || optionsCalculated.value.direction === 'en->jp')
|
||||||
character.value = subject.value.meanings
|
inputMode.value = 'writing'
|
||||||
.filter((m: any) => m.primary)
|
else
|
||||||
.map((m: any) => m.meaning)
|
inputMode.value = 'meaning'
|
||||||
.join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTickWrapper()
|
character.value = subject.value.subject
|
||||||
isDisabled.value = false
|
isDisabled.value = false
|
||||||
resultMessage.value = ''
|
resultMessage.value = ''
|
||||||
isCorrect.value = false
|
isCorrect.value = false
|
||||||
|
nextTickWrapper()
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitAnswer() {
|
async function submitAnswer() {
|
||||||
|
if (!answerInput.value || !subject) return
|
||||||
|
|
||||||
if (isDisabled.value) {
|
if (isDisabled.value) {
|
||||||
createReview()
|
createReview()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!answerInput.value || !subject.value) return
|
|
||||||
|
|
||||||
const userAnswer = answerInput.value.value.trim()
|
const userAnswer = answerInput.value.value.trim()
|
||||||
|
isCorrect.value = subject.value.answers.some(a => a.trim().toLowerCase() === userAnswer)
|
||||||
if (direction.value === 'jp->en')
|
|
||||||
isCorrect.value = inputMode.value === 'writing'
|
|
||||||
? subject.value.readings.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
|
||||||
: subject.value.meanings.some((m: any) => m.accepted_answer && m.meaning.trim().toLowerCase() === userAnswer.toLowerCase())
|
|
||||||
else
|
|
||||||
isCorrect.value = inputMode.value === 'kanji'
|
|
||||||
? subject.value.characters.trim() === userAnswer
|
|
||||||
: (subject.value.readings?.length === 0
|
|
||||||
? subject.value.characters.trim() === userAnswer
|
|
||||||
: subject.value.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer))
|
|
||||||
|
|
||||||
|
|
||||||
const getAnswerText = () => {
|
|
||||||
if (direction.value === 'jp->en')
|
|
||||||
return inputMode.value === 'writing'
|
|
||||||
? subject.value.readings.filter((r: any) => r.accepted_answer).map((r: any) => r.reading).join(', ')
|
|
||||||
: subject.value.meanings.filter((m: any) => m.accepted_answer).map((m: any) => m.meaning).join(', ')
|
|
||||||
else
|
|
||||||
return inputMode.value === 'kanji'
|
|
||||||
? subject.value.characters
|
|
||||||
: subject.value.readings?.length === 0
|
|
||||||
? subject.value.characters
|
|
||||||
: subject.value.readings.filter((r: any) => r.accepted_answer).map((r: any) => r.reading).join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
resultMessage.value = isCorrect.value ? 'Correct: ' : 'Wrong: '
|
resultMessage.value = isCorrect.value ? 'Correct: ' : 'Wrong: '
|
||||||
resultMessage.value += getAnswerText()
|
resultMessage.value += subject.value.answers.join(', ')
|
||||||
|
|
||||||
|
if (isCorrect.value) {
|
||||||
|
await correct()
|
||||||
|
} else {
|
||||||
|
await incorrect()
|
||||||
|
}
|
||||||
|
|
||||||
answerInput.value.value = ''
|
answerInput.value.value = ''
|
||||||
isDisabled.value = true
|
isDisabled.value = true
|
||||||
@@ -215,14 +201,6 @@ function nextTickWrapper() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const mode = options.value.meaning && options.value.writing
|
|
||||||
? 'both'
|
|
||||||
: options.value.meaning
|
|
||||||
? 'meaning'
|
|
||||||
: 'writing'
|
|
||||||
|
|
||||||
reviews.value = await Reviews.getInstance()
|
|
||||||
await reviews.value.setOptions({ type: 'both', mode })
|
|
||||||
createReview()
|
createReview()
|
||||||
window.addEventListener('keydown', handleGlobalKey)
|
window.addEventListener('keydown', handleGlobalKey)
|
||||||
nextTickWrapper()
|
nextTickWrapper()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ v-container.fill-height.d-flex.justify-center.align-center.position-relative
|
|||||||
color="var(--color-all)"
|
color="var(--color-all)"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
|
:disabled="!hasSelectedOptions"
|
||||||
@click="_startTraining('all')"
|
@click="_startTraining('all')"
|
||||||
)
|
)
|
||||||
span.font-weight-bold All
|
span.font-weight-bold All
|
||||||
@@ -30,6 +31,7 @@ v-container.fill-height.d-flex.justify-center.align-center.position-relative
|
|||||||
color="var(--color-kanji)"
|
color="var(--color-kanji)"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
|
:disabled="!hasSelectedOptions"
|
||||||
@click="_startTraining('kanji')"
|
@click="_startTraining('kanji')"
|
||||||
)
|
)
|
||||||
span.font-weight-bold Kanji
|
span.font-weight-bold Kanji
|
||||||
@@ -39,14 +41,17 @@ v-container.fill-height.d-flex.justify-center.align-center.position-relative
|
|||||||
color="var(--color-vocab)"
|
color="var(--color-vocab)"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
|
:disabled="!hasSelectedOptions"
|
||||||
@click="_startTraining('vocab')"
|
@click="_startTraining('vocab')"
|
||||||
)
|
)
|
||||||
span.font-weight-bold Vocabulary
|
span.font-weight-bold Vocabulary
|
||||||
|
|
||||||
v-col(cols="12" sm="3")
|
v-col(cols="12" sm="3")
|
||||||
v-btn.py-6(
|
v-btn.py-6(
|
||||||
color="var(--color-writing)"
|
color="var(--color-writing)"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
|
:disabled="!hasSelectedOptions"
|
||||||
@click="_startTraining('writing')"
|
@click="_startTraining('writing')"
|
||||||
)
|
)
|
||||||
span.font-weight-bold Writing
|
span.font-weight-bold Writing
|
||||||
@@ -65,7 +70,7 @@ v-container.fill-height.d-flex.justify-center.align-center.position-relative
|
|||||||
v-card.elevation-3.rounded-lg.text-center.pa-4.d-flex.flex-column.align-center.justify-center(
|
v-card.elevation-3.rounded-lg.text-center.pa-4.d-flex.flex-column.align-center.justify-center(
|
||||||
color="var(--color-kanji)"
|
color="var(--color-kanji)"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
height="120px"
|
height="100px"
|
||||||
)
|
)
|
||||||
v-icon.size-36.mb-2 mdi-book-open-variant
|
v-icon.size-36.mb-2 mdi-book-open-variant
|
||||||
.text-subtitle-2 Kanji
|
.text-subtitle-2 Kanji
|
||||||
@@ -74,7 +79,7 @@ v-container.fill-height.d-flex.justify-center.align-center.position-relative
|
|||||||
v-card.elevation-3.rounded-lg.text-center.pa-4.d-flex.flex-column.align-center.justify-center(
|
v-card.elevation-3.rounded-lg.text-center.pa-4.d-flex.flex-column.align-center.justify-center(
|
||||||
color="var(--color-vocab)"
|
color="var(--color-vocab)"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
height="120px"
|
height="100px"
|
||||||
)
|
)
|
||||||
v-icon.size-36.mb-2 mdi-translate
|
v-icon.size-36.mb-2 mdi-translate
|
||||||
.text-subtitle-3 Vocabulary
|
.text-subtitle-3 Vocabulary
|
||||||
@@ -141,10 +146,6 @@ import { onMounted, ref } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
import { Reviews } from '../composables/subject.ts'
|
|
||||||
|
|
||||||
const reviews = ref<Reviews | null>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const apiKey = ref<string>('')
|
const apiKey = ref<string>('')
|
||||||
@@ -155,11 +156,15 @@ const stats = ref<{ kanjiCount: number, vocabCount: number }>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const options = ref({
|
const options = ref({
|
||||||
writing: false,
|
writing: true,
|
||||||
meaning: false,
|
meaning: false,
|
||||||
kanji: false,
|
kanji: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasSelectedOptions = computed(() => {
|
||||||
|
return options.value.writing || options.value.meaning
|
||||||
|
})
|
||||||
|
|
||||||
async function logoutHandler() {
|
async function logoutHandler() {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -167,7 +172,7 @@ async function logoutHandler() {
|
|||||||
|
|
||||||
function _startTraining(type: 'kanji' | 'vocab' | 'all' | 'writing') {
|
function _startTraining(type: 'kanji' | 'vocab' | 'all' | 'writing') {
|
||||||
router.push({
|
router.push({
|
||||||
path: '/' + type,
|
path: type === 'writing' ? '/writing' : 'trainer',
|
||||||
query: {
|
query: {
|
||||||
direction: direction.value,
|
direction: direction.value,
|
||||||
options: JSON.stringify({
|
options: JSON.stringify({
|
||||||
@@ -222,19 +227,24 @@ async function deleteApiKey() {
|
|||||||
async function syncWanikani() {
|
async function syncWanikani() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/v1/wanikani/sync')
|
await fetch('/api/v1/wanikani/sync')
|
||||||
|
await updateStats()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error syncing Wanikani data:', error)
|
console.error('Error syncing Wanikani data:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStats() {
|
import { getStatistics } from '@/composables/srs.scoring'
|
||||||
if (!reviews.value) return
|
|
||||||
stats.value = reviews.value.getStats()
|
async function updateStats() {
|
||||||
|
const statsTemp = await getStatistics()
|
||||||
|
console.log(statsTemp)
|
||||||
|
if (!stats) return
|
||||||
|
stats.value.kanjiCount = statsTemp.totalKanji || 0
|
||||||
|
stats.value.vocabCount = statsTemp.totalVocab || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
reviews.value = await Reviews.getInstance()
|
|
||||||
getApiKey()
|
getApiKey()
|
||||||
updateStats()
|
await updateStats()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
v-container.fill-height.d-flex.justify-center.align-center
|
|
||||||
v-card.pa-12.rounded-lg.elevation-12
|
|
||||||
template(v-if="!allDone")
|
|
||||||
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
|
||||||
span.text-primary Kanji Trainer
|
|
||||||
|
|
||||||
v-sheet.d-flex.justify-center.align-center.rounded-lg.py-8.px-4.elevation-12(
|
|
||||||
:style="{backgroundColor: 'var(--color-kanji)', fontSize: '5rem', fontWeight: 'bold', whiteSpace: 'nowrap'}"
|
|
||||||
)
|
|
||||||
span {{ character }}
|
|
||||||
|
|
||||||
v-card-text.text-center.my-4.rounded-lg(
|
|
||||||
:style="{ backgroundColor: 'hsl(320, 100%, 50%, 0.1)', fontSize: '1.1rem', fontWeight: '500', color: 'var(--color-kanji)'}"
|
|
||||||
)
|
|
||||||
| {{ inputMode === 'kanji' ? 'Kanji' : inputMode === 'writing' ? 'Writing' : inputMode }}
|
|
||||||
|
|
||||||
transition(name="alert-grow")
|
|
||||||
v-alert.mb-4(
|
|
||||||
v-if="resultMessage"
|
|
||||||
:type="isCorrect ? 'success' : 'error'"
|
|
||||||
border="top"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
) {{ resultMessage }}
|
|
||||||
|
|
||||||
v-row.align-center
|
|
||||||
v-col(cols="9")
|
|
||||||
input#answer-input.customInput(
|
|
||||||
ref="answerInput"
|
|
||||||
placeholder="Type your answer"
|
|
||||||
:disabled="isDisabled"
|
|
||||||
)
|
|
||||||
v-col(cols="3")
|
|
||||||
v-btn(
|
|
||||||
color="primary"
|
|
||||||
block
|
|
||||||
@click="submitAnswer"
|
|
||||||
) Enter
|
|
||||||
|
|
||||||
v-row.justify-space-between
|
|
||||||
v-col(cols="4")
|
|
||||||
v-btn(
|
|
||||||
color="error"
|
|
||||||
variant="flat"
|
|
||||||
block
|
|
||||||
@click="quitSession"
|
|
||||||
) Quit
|
|
||||||
v-col(cols="4")
|
|
||||||
v-btn(
|
|
||||||
color="secondary"
|
|
||||||
variant="flat"
|
|
||||||
block
|
|
||||||
@click="createReview"
|
|
||||||
) Skip
|
|
||||||
v-col(cols="4")
|
|
||||||
v-btn(
|
|
||||||
color="success"
|
|
||||||
variant="flat"
|
|
||||||
block
|
|
||||||
@click="submitAnswer"
|
|
||||||
) Resolve
|
|
||||||
|
|
||||||
template(v-else)
|
|
||||||
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
|
||||||
span.text-primary 🎉 Congratulations!
|
|
||||||
v-card-text.text-center
|
|
||||||
| You have completed all reviews for today!
|
|
||||||
v-row.justify-center.mt-6
|
|
||||||
v-col(cols="6")
|
|
||||||
v-btn(
|
|
||||||
color="primary"
|
|
||||||
block
|
|
||||||
@click="quitSession"
|
|
||||||
) Back to Home
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, nextTick, onBeforeUnmount, computed } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import * as wanakana from 'wanakana'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const answerInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const character = ref<string>('')
|
|
||||||
const isCorrect = ref<boolean>(false)
|
|
||||||
const isDisabled = ref<boolean>(false)
|
|
||||||
const isWanakanaBound = ref<boolean>(false)
|
|
||||||
const inputMode = ref<'meaning' | 'writing' | 'kanji'>('meaning')
|
|
||||||
const resultMessage = ref<string>('')
|
|
||||||
const subject = ref<any>(null)
|
|
||||||
const allDone = ref(false)
|
|
||||||
|
|
||||||
const options = computed(() => JSON.parse(route.query.options as string))
|
|
||||||
|
|
||||||
const optionsCalculated = ref({
|
|
||||||
subject: options.value.type === 'writing' ? 'kanji' : options.value.type === 'all' ? ['kanji', 'vocab'] : options.value.type,
|
|
||||||
mode: options.value.writing && options.value.meaning ? ['reading', 'meaning'] : options.value.writing ? 'reading' : options.value.meaning ? 'meaning' : 'reading',
|
|
||||||
writingPractice: options.value.type === 'writing',
|
|
||||||
direction: options.value.direction,
|
|
||||||
writingMode: options.value.kanji ? 'kanji' : 'kana',
|
|
||||||
})
|
|
||||||
|
|
||||||
import { nextSubject, correct, incorrect } from '../composables/srs.scoring.ts'
|
|
||||||
|
|
||||||
async function createReview() {
|
|
||||||
console.log(optionsCalculated.value.mode)
|
|
||||||
const next = await nextSubject(optionsCalculated.value)
|
|
||||||
if (!next) {
|
|
||||||
allDone.value = true
|
|
||||||
character.value = ''
|
|
||||||
resultMessage.value = '✅ All done!'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
allDone.value = false
|
|
||||||
subject.value = next
|
|
||||||
|
|
||||||
if (optionsCalculated.value.direction === 'en->jp' && optionsCalculated.value.writingMode === 'kanji')
|
|
||||||
inputMode.value = 'kanji'
|
|
||||||
else if (subject.value.mode === 'jp_en_reading' || optionsCalculated.value.direction === 'en->jp')
|
|
||||||
inputMode.value = 'writing'
|
|
||||||
else
|
|
||||||
inputMode.value = 'meaning'
|
|
||||||
|
|
||||||
character.value = subject.value.subject
|
|
||||||
isDisabled.value = false
|
|
||||||
resultMessage.value = ''
|
|
||||||
isCorrect.value = false
|
|
||||||
nextTickWrapper()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitAnswer() {
|
|
||||||
if (!answerInput.value || !subject) return
|
|
||||||
console.log(subject.value)
|
|
||||||
const userAnswer = answerInput.value.value.trim()
|
|
||||||
isCorrect.value = subject.value.answers.some(a => a.trim().toLowerCase() === userAnswer)
|
|
||||||
|
|
||||||
resultMessage.value = isCorrect.value ? 'Correct: ' : 'Wrong: '
|
|
||||||
resultMessage.value += subject.value.answers.join(', ')
|
|
||||||
|
|
||||||
if (isCorrect.value) {
|
|
||||||
await correct()
|
|
||||||
} else {
|
|
||||||
await incorrect()
|
|
||||||
}
|
|
||||||
|
|
||||||
answerInput.value.value = ''
|
|
||||||
isDisabled.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function quitSession() {
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGlobalKey(event: KeyboardEvent) {
|
|
||||||
switch (event.key) {
|
|
||||||
case 'Enter':
|
|
||||||
!isDisabled.value ? submitAnswer() : createReview()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindWanakana(input: HTMLInputElement | null) {
|
|
||||||
if (!input || isWanakanaBound.value) return
|
|
||||||
wanakana.bind(input, { IMEMode: true })
|
|
||||||
isWanakanaBound.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function unbindWanakana(input: HTMLInputElement | null) {
|
|
||||||
if (!input || !isWanakanaBound.value) return
|
|
||||||
wanakana.unbind(input)
|
|
||||||
isWanakanaBound.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextTickWrapper() {
|
|
||||||
nextTick(() => {
|
|
||||||
if (!answerInput.value) return
|
|
||||||
inputMode.value === 'writing' || inputMode.value === 'kanji'
|
|
||||||
? bindWanakana(answerInput.value)
|
|
||||||
: unbindWanakana(answerInput.value)
|
|
||||||
answerInput.value.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
createReview()
|
|
||||||
window.addEventListener('keydown', handleGlobalKey)
|
|
||||||
nextTickWrapper()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => window.removeEventListener('keydown', handleGlobalKey))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
$dark-background: #121212;
|
|
||||||
$dark-surface: #1e1e1e;
|
|
||||||
$dark-text: #ffffff;
|
|
||||||
$dark-hint: #ffffff99;
|
|
||||||
$error-color: #cf6679;
|
|
||||||
|
|
||||||
$dark-border: #ffffff14;
|
|
||||||
|
|
||||||
$accent-color: hsl(320, 100%, 50%);
|
|
||||||
|
|
||||||
@mixin vuetify-dark-input-base {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px 16px;
|
|
||||||
min-height: 56px;
|
|
||||||
|
|
||||||
background-color: $dark-surface;
|
|
||||||
|
|
||||||
border: 1px solid $dark-border;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
color: $dark-text;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.5;
|
|
||||||
|
|
||||||
transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: $dark-hint;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-grow-enter-active,
|
|
||||||
.alert-grow-leave-active {
|
|
||||||
transition: all 0.3s ease-in-out;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-grow-enter-from,
|
|
||||||
.alert-grow-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
max-height: 0;
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
padding-top: 0 !important;
|
|
||||||
padding-bottom: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-grow-enter-to,
|
|
||||||
.alert-grow-leave-from {
|
|
||||||
opacity: 1;
|
|
||||||
max-height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customInput {
|
|
||||||
@include vuetify-dark-input-base;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: lighten($dark-surface, 3%);
|
|
||||||
border-color: lighten($dark-border, 5%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: $accent-color;
|
|
||||||
box-shadow: 0 0 0 1px $accent-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
background-color: $dark-surface;
|
|
||||||
border-color: $dark-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:read-only {
|
|
||||||
opacity: 0.8;
|
|
||||||
background-color: $dark-surface;
|
|
||||||
cursor: default;
|
|
||||||
border-color: $dark-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-error {
|
|
||||||
border-color: $error-color !important;
|
|
||||||
box-shadow: 0 0 0 1px $error-color !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
v-checkbox(
|
v-checkbox(
|
||||||
label="Remember me"
|
label="Remember me"
|
||||||
class="my-4"
|
class="my-4"
|
||||||
|
v-model="rememberMe"
|
||||||
)
|
)
|
||||||
v-btn(
|
v-btn(
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -47,10 +48,15 @@ const auth = useAuthStore()
|
|||||||
|
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
|
const rememberMe = ref(false)
|
||||||
|
|
||||||
async function loginHandler() {
|
async function loginHandler() {
|
||||||
const success = await auth.login(username.value, password.value)
|
const success = await auth.login(username.value, password.value, rememberMe.value)
|
||||||
if (success) router.push('/')
|
if (success) router.push('/')
|
||||||
else console.error(auth.error)
|
else console.error(auth.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
auth.fetchUser()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
312
client/src/pages/trainer.vue
Normal file
312
client/src/pages/trainer.vue
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
<template lang="pug">
|
||||||
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
|
v-card.pa-12.rounded-lg.elevation-12
|
||||||
|
template(v-if="!allDone")
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary {{ trainerTitle }}
|
||||||
|
|
||||||
|
v-sheet.d-flex.justify-center.align-center.rounded-lg.py-8.px-4.elevation-12(
|
||||||
|
:style="{ backgroundColor: subjectColor, fontSize: '5rem', fontWeight: 'bold', whiteSpace: 'nowrap' }"
|
||||||
|
)
|
||||||
|
span {{ character }}
|
||||||
|
|
||||||
|
v-card-text.text-center.my-4.rounded-lg(
|
||||||
|
:style="{ backgroundColor: subjectColor + '20', fontSize: '1.1rem', fontWeight: '500', color: subjectColor }"
|
||||||
|
)
|
||||||
|
| {{ inputMode.charAt(0).toUpperCase() + inputMode.slice(1) }}
|
||||||
|
|
||||||
|
transition(name="alert-grow")
|
||||||
|
v-alert.mb-4(
|
||||||
|
v-if="resultMessage"
|
||||||
|
:type="isCorrect ? 'success' : 'error'"
|
||||||
|
border="top"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
) {{ resultMessage }}
|
||||||
|
|
||||||
|
v-row.align-center
|
||||||
|
v-col(cols="9")
|
||||||
|
input#answer-input.customInput(
|
||||||
|
ref="answerInput"
|
||||||
|
placeholder="Type your answer"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
)
|
||||||
|
v-col(cols="3")
|
||||||
|
v-btn(
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
@click="submitAnswer"
|
||||||
|
) Enter
|
||||||
|
|
||||||
|
v-row.justify-space-between
|
||||||
|
v-col(cols="4")
|
||||||
|
v-btn(
|
||||||
|
color="error"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="quitSession"
|
||||||
|
) Quit
|
||||||
|
v-col(cols="4")
|
||||||
|
v-btn(
|
||||||
|
color="secondary"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="createReview"
|
||||||
|
) Skip
|
||||||
|
v-col(cols="4")
|
||||||
|
v-btn(
|
||||||
|
color="success"
|
||||||
|
variant="flat"
|
||||||
|
block
|
||||||
|
@click="submitAnswer"
|
||||||
|
) Resolve
|
||||||
|
|
||||||
|
template(v-else)
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary 🎉 Congratulations!
|
||||||
|
v-card-text.text-center
|
||||||
|
| You have completed all reviews for today!
|
||||||
|
v-row.justify-center.mt-6
|
||||||
|
v-col(cols="6")
|
||||||
|
v-btn(
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
@click="quitSession"
|
||||||
|
) Back to Home
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, nextTick, onBeforeUnmount, computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import * as wanakana from 'wanakana'
|
||||||
|
import { nextSubject, correct, incorrect } from '../composables/srs.scoring.ts'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const answerInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const character = ref<string>('')
|
||||||
|
const isCorrect = ref<boolean>(false)
|
||||||
|
const isDisabled = ref<boolean>(false)
|
||||||
|
const isWanakanaBound = ref<boolean>(false)
|
||||||
|
const inputMode = ref<'meaning' | 'writing' | 'kanji'>('meaning')
|
||||||
|
const resultMessage = ref<string>('')
|
||||||
|
const subject = ref<any>(null)
|
||||||
|
const allDone = ref(false)
|
||||||
|
|
||||||
|
const options = computed(() => JSON.parse(route.query.options as string))
|
||||||
|
|
||||||
|
const optionsCalculated = ref({
|
||||||
|
subject: options.value.type === 'writing' ? 'kanji' : options.value.type === 'all' ? ['kanji', 'vocab'] : options.value.type,
|
||||||
|
mode: options.value.writing && options.value.meaning ? ['reading', 'meaning'] : options.value.writing ? 'reading' : options.value.meaning ? 'meaning' : 'reading',
|
||||||
|
writingPractice: options.value.type === 'writing',
|
||||||
|
direction: options.value.direction,
|
||||||
|
writingMode: options.value.kanji ? 'kanji' : 'kana',
|
||||||
|
})
|
||||||
|
|
||||||
|
const trainerTitle = computed(() => {
|
||||||
|
if (!subject.value) return 'Trainer'
|
||||||
|
switch (subject.value.type) {
|
||||||
|
case 'kanji':
|
||||||
|
return 'Kanji Trainer'
|
||||||
|
case 'vocab':
|
||||||
|
return 'Vocabulary Trainer'
|
||||||
|
default:
|
||||||
|
return 'Full Trainer'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const subjectColor = computed(() => {
|
||||||
|
if (!subject.value) return 'var(--color-all)'
|
||||||
|
switch (subject.value.type) {
|
||||||
|
case 'kanji':
|
||||||
|
return 'var(--color-kanji)'
|
||||||
|
case 'vocab':
|
||||||
|
return 'var(--color-vocab)'
|
||||||
|
default:
|
||||||
|
return 'var(--color-all)'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function createReview() {
|
||||||
|
const next = await nextSubject(optionsCalculated.value)
|
||||||
|
if (!next) {
|
||||||
|
allDone.value = true
|
||||||
|
character.value = ''
|
||||||
|
resultMessage.value = '✅ All done!'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allDone.value = false
|
||||||
|
subject.value = next
|
||||||
|
|
||||||
|
if (optionsCalculated.value.direction === 'en->jp' && optionsCalculated.value.writingMode === 'kanji')
|
||||||
|
inputMode.value = 'kanji'
|
||||||
|
else if (subject.value.mode === 'jp_en_reading' || subject.value.mode === 'en_jp_writing')
|
||||||
|
inputMode.value = 'writing'
|
||||||
|
else
|
||||||
|
inputMode.value = 'meaning'
|
||||||
|
|
||||||
|
character.value = subject.value.subject
|
||||||
|
isDisabled.value = false
|
||||||
|
resultMessage.value = ''
|
||||||
|
isCorrect.value = false
|
||||||
|
nextTickWrapper()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAnswer() {
|
||||||
|
if (!answerInput.value || !subject.value) return
|
||||||
|
|
||||||
|
if (isDisabled.value) {
|
||||||
|
createReview()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAnswer = answerInput.value.value.trim()
|
||||||
|
isCorrect.value = subject.value.answers.some(a => a.trim().toLowerCase() === userAnswer)
|
||||||
|
|
||||||
|
resultMessage.value = isCorrect.value ? 'Correct: ' : 'Wrong: '
|
||||||
|
resultMessage.value += subject.value.answers.join(', ')
|
||||||
|
|
||||||
|
if (isCorrect.value) {
|
||||||
|
await correct()
|
||||||
|
} else {
|
||||||
|
await incorrect()
|
||||||
|
}
|
||||||
|
|
||||||
|
answerInput.value.value = ''
|
||||||
|
isDisabled.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function quitSession() {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGlobalKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
!isDisabled.value ? submitAnswer() : createReview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindWanakana(input: HTMLInputElement | null) {
|
||||||
|
if (!input || isWanakanaBound.value) return
|
||||||
|
wanakana.bind(input, { IMEMode: true })
|
||||||
|
isWanakanaBound.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbindWanakana(input: HTMLInputElement | null) {
|
||||||
|
if (!input || !isWanakanaBound.value) return
|
||||||
|
wanakana.unbind(input)
|
||||||
|
isWanakanaBound.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextTickWrapper() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (!answerInput.value) return
|
||||||
|
if (inputMode.value === 'writing' || inputMode.value === 'kanji') bindWanakana(answerInput.value)
|
||||||
|
else unbindWanakana(answerInput.value)
|
||||||
|
answerInput.value.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
createReview()
|
||||||
|
window.addEventListener('keydown', handleGlobalKey)
|
||||||
|
nextTickWrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', handleGlobalKey))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
$dark-background: #121212;
|
||||||
|
$dark-surface: #1e1e1e;
|
||||||
|
$dark-text: #ffffff;
|
||||||
|
$dark-hint: #ffffff99;
|
||||||
|
$error-color: #cf6679;
|
||||||
|
|
||||||
|
$dark-border: #ffffff14;
|
||||||
|
|
||||||
|
$accent-color: hsl(320, 100%, 50%);
|
||||||
|
|
||||||
|
@mixin vuetify-dark-input-base {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
min-height: 56px;
|
||||||
|
|
||||||
|
background-color: $dark-surface;
|
||||||
|
|
||||||
|
border: 1px solid $dark-border;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
color: $dark-text;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $dark-hint;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-active,
|
||||||
|
.alert-grow-leave-active {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-from,
|
||||||
|
.alert-grow-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-grow-enter-to,
|
||||||
|
.alert-grow-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customInput {
|
||||||
|
@include vuetify-dark-input-base;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: lighten($dark-surface, 3%);
|
||||||
|
border-color: lighten($dark-border, 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $accent-color;
|
||||||
|
box-shadow: 0 0 0 1px $accent-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: $dark-surface;
|
||||||
|
border-color: $dark-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:read-only {
|
||||||
|
opacity: 0.8;
|
||||||
|
background-color: $dark-surface;
|
||||||
|
cursor: default;
|
||||||
|
border-color: $dark-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
border-color: $error-color !important;
|
||||||
|
box-shadow: 0 0 0 1px $error-color !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
<template lang="pug">
|
|
||||||
v-container.fill-height.d-flex.justify-center.align-center
|
|
||||||
v-card.pa-12.rounded-lg.elevation-12
|
|
||||||
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
|
||||||
span.text-primary Vocabulary Trainer
|
|
||||||
|
|
||||||
v-sheet.d-flex.justify-center.align-center.rounded-lg.py-8.px-4.elevation-12(
|
|
||||||
:style="{backgroundColor: 'var(--color-vocab)', fontSize: '5rem', fontWeight: 'bold', whiteSpace: 'nowrap'}"
|
|
||||||
)
|
|
||||||
span {{ character }}
|
|
||||||
|
|
||||||
v-card-text.text-center.my-4.rounded-lg(
|
|
||||||
:style="{ backgroundColor: 'hsl(320, 100%, 50%, 0.1)', fontSize: '1.1rem', fontWeight: '500', color: 'var(--color-vocab)'}"
|
|
||||||
)
|
|
||||||
| {{ inputMode === 'kanji' ? 'Kanji' : inputMode === 'writing' ? 'Writing' : inputMode }}
|
|
||||||
|
|
||||||
transition(name="alert-grow")
|
|
||||||
v-alert.mb-4(
|
|
||||||
v-if="resultMessage"
|
|
||||||
:type="isCorrect ? 'success' : 'error'"
|
|
||||||
border="top"
|
|
||||||
variant="outlined"
|
|
||||||
density="compact"
|
|
||||||
) {{ resultMessage }}
|
|
||||||
|
|
||||||
v-row.align-center
|
|
||||||
v-col(cols="9")
|
|
||||||
input#answer-input.customInput(
|
|
||||||
ref="answerInput"
|
|
||||||
placeholder="Type your answer"
|
|
||||||
:disabled="isDisabled"
|
|
||||||
)
|
|
||||||
v-col(cols="3")
|
|
||||||
v-btn(
|
|
||||||
color="primary"
|
|
||||||
block
|
|
||||||
@click="submitAnswer"
|
|
||||||
) Enter
|
|
||||||
|
|
||||||
v-row.justify-space-between
|
|
||||||
v-col(cols="4")
|
|
||||||
v-btn(
|
|
||||||
color="error"
|
|
||||||
variant="flat"
|
|
||||||
block
|
|
||||||
@click="quitSession"
|
|
||||||
) Quit
|
|
||||||
v-col(cols="4")
|
|
||||||
v-btn(
|
|
||||||
color="secondary"
|
|
||||||
variant="flat"
|
|
||||||
block
|
|
||||||
@click="createReview"
|
|
||||||
) Skip
|
|
||||||
v-col(cols="4")
|
|
||||||
v-btn(
|
|
||||||
color="success"
|
|
||||||
variant="flat"
|
|
||||||
block
|
|
||||||
@click="submitAnswer"
|
|
||||||
) Resolve
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, nextTick, onBeforeUnmount, computed } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import * as wanakana from 'wanakana'
|
|
||||||
import { Reviews } from '../composables/subject.ts'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const answerInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const character = ref<string>('')
|
|
||||||
const isCorrect = ref<boolean>(false)
|
|
||||||
const isDisabled = ref<boolean>(false)
|
|
||||||
const isWanakanaBound = ref<boolean>(false)
|
|
||||||
const inputMode = ref<'meaning' | 'writing' | 'kanji'>('meaning')
|
|
||||||
const resultMessage = ref<string>('')
|
|
||||||
const reviews = ref<Reviews | null>(null)
|
|
||||||
const subject = ref<any>(null)
|
|
||||||
|
|
||||||
const direction = computed(() => route.query.direction)
|
|
||||||
const options = computed(() => JSON.parse(route.query.options as string))
|
|
||||||
|
|
||||||
function createReview() {
|
|
||||||
if (!reviews.value) return
|
|
||||||
|
|
||||||
const next = reviews.value.nextSubject()
|
|
||||||
if (!next) {
|
|
||||||
// TODO: Add celebration or summary screen
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subject.value = next.subject
|
|
||||||
inputMode.value = next.mode
|
|
||||||
|
|
||||||
if (direction.value === 'jp->en') {
|
|
||||||
character.value = subject.value.characters
|
|
||||||
} else {
|
|
||||||
character.value = subject.value.meanings
|
|
||||||
.filter((m: any) => m.primary)
|
|
||||||
.map((m: any) => m.meaning)
|
|
||||||
.join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTickWrapper()
|
|
||||||
isDisabled.value = false
|
|
||||||
resultMessage.value = ''
|
|
||||||
isCorrect.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitAnswer() {
|
|
||||||
if (isDisabled.value) {
|
|
||||||
createReview()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!answerInput.value || !subject.value) return
|
|
||||||
|
|
||||||
const userAnswer = answerInput.value.value.trim()
|
|
||||||
|
|
||||||
if (direction.value === 'jp->en')
|
|
||||||
isCorrect.value = inputMode.value === 'writing'
|
|
||||||
? subject.value.readings.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
|
||||||
: subject.value.meanings.some((m: any) => m.accepted_answer && m.meaning.trim().toLowerCase() === userAnswer.toLowerCase())
|
|
||||||
else
|
|
||||||
isCorrect.value = inputMode.value === 'kanji'
|
|
||||||
? subject.value.characters.trim() === userAnswer
|
|
||||||
: (subject.value.readings?.length === 0
|
|
||||||
? subject.value.characters.trim() === userAnswer
|
|
||||||
: subject.value.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer))
|
|
||||||
|
|
||||||
const getAnswerText = () => {
|
|
||||||
if (direction.value === 'jp->en')
|
|
||||||
return inputMode.value === 'writing'
|
|
||||||
? subject.value.readings.filter((r: any) => r.accepted_answer).map((r: any) => r.reading).join(', ')
|
|
||||||
: subject.value.meanings.filter((m: any) => m.accepted_answer).map((m: any) => m.meaning).join(', ')
|
|
||||||
else
|
|
||||||
return inputMode.value === 'kanji'
|
|
||||||
? subject.value.characters
|
|
||||||
: subject.value.readings?.length === 0
|
|
||||||
? subject.value.characters
|
|
||||||
: subject.value.readings.filter((r: any) => r.accepted_answer).map((r: any) => r.reading).join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
resultMessage.value = isCorrect.value ? 'Correct: ' : 'Wrong: '
|
|
||||||
resultMessage.value += getAnswerText()
|
|
||||||
|
|
||||||
answerInput.value.value = ''
|
|
||||||
isDisabled.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function quitSession() {
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGlobalKey(event: KeyboardEvent) {
|
|
||||||
switch (event.key) {
|
|
||||||
case 'Enter':
|
|
||||||
!isDisabled.value ? submitAnswer() : createReview()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindWanakana(input: HTMLInputElement | null) {
|
|
||||||
if (!input || isWanakanaBound.value) return
|
|
||||||
wanakana.bind(input, { IMEMode: true })
|
|
||||||
isWanakanaBound.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function unbindWanakana(input: HTMLInputElement | null) {
|
|
||||||
if (!input || !isWanakanaBound.value) return
|
|
||||||
wanakana.unbind(input)
|
|
||||||
isWanakanaBound.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextTickWrapper() {
|
|
||||||
nextTick(() => {
|
|
||||||
if (!answerInput.value) return
|
|
||||||
inputMode.value === 'writing' || inputMode.value === 'kanji'
|
|
||||||
? bindWanakana(answerInput.value)
|
|
||||||
: unbindWanakana(answerInput.value)
|
|
||||||
answerInput.value.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
const mode = options.value.meaning && options.value.writing
|
|
||||||
? 'both'
|
|
||||||
: options.value.meaning
|
|
||||||
? 'meaning'
|
|
||||||
: 'writing'
|
|
||||||
|
|
||||||
reviews.value = await Reviews.getInstance()
|
|
||||||
await reviews.value.setOptions({ type: 'vocab', mode })
|
|
||||||
createReview()
|
|
||||||
window.addEventListener('keydown', handleGlobalKey)
|
|
||||||
nextTickWrapper()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => window.removeEventListener('keydown', handleGlobalKey))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
$dark-background: #121212;
|
|
||||||
$dark-surface: #1e1e1e;
|
|
||||||
$dark-text: #ffffff;
|
|
||||||
$dark-hint: #ffffff99;
|
|
||||||
$error-color: #cf6679;
|
|
||||||
|
|
||||||
$dark-border: #ffffff14;
|
|
||||||
|
|
||||||
$accent-color: hsl(320, 100%, 50%);
|
|
||||||
|
|
||||||
@mixin vuetify-dark-input-base {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
padding: 14px 16px;
|
|
||||||
min-height: 56px;
|
|
||||||
|
|
||||||
background-color: $dark-surface;
|
|
||||||
|
|
||||||
border: 1px solid $dark-border;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
color: $dark-text;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.5;
|
|
||||||
|
|
||||||
transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: $dark-hint;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-grow-enter-active,
|
|
||||||
.alert-grow-leave-active {
|
|
||||||
transition: all 0.3s ease-in-out;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-grow-enter-from,
|
|
||||||
.alert-grow-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
max-height: 0;
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
padding-top: 0 !important;
|
|
||||||
padding-bottom: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-grow-enter-to,
|
|
||||||
.alert-grow-leave-from {
|
|
||||||
opacity: 1;
|
|
||||||
max-height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customInput {
|
|
||||||
@include vuetify-dark-input-base;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: lighten($dark-surface, 3%);
|
|
||||||
border-color: lighten($dark-border, 5%);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: $accent-color;
|
|
||||||
box-shadow: 0 0 0 1px $accent-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
background-color: $dark-surface;
|
|
||||||
border-color: $dark-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:read-only {
|
|
||||||
opacity: 0.8;
|
|
||||||
background-color: $dark-surface;
|
|
||||||
cursor: default;
|
|
||||||
border-color: $dark-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-error {
|
|
||||||
border-color: $error-color !important;
|
|
||||||
box-shadow: 0 0 0 1px $error-color !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
v-container.fill-height.d-flex.justify-center.align-center
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
v-card.pa-12.rounded-lg.elevation-12
|
v-card.pa-12.rounded-lg.elevation-12
|
||||||
|
template(v-if="!allDone")
|
||||||
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
span.text-primary Handwriting Trainer
|
span.text-primary Handwriting Trainer
|
||||||
|
|
||||||
v-row.justify-center.mb-6
|
v-row.justify-center.mb-6
|
||||||
v-col(cols="12" class="text-center")
|
v-col(cols="12" class="text-center")
|
||||||
.text-h4.text-white.font-weight-bold
|
.text-h4.text-white.font-weight-bold
|
||||||
| Draw the kanji for: {{ subject?.meanings?.find(m => m.primary)?.meaning || subject?.readings?.[0]?.reading || '?' }}
|
| Draw the kanji for: {{ subject?.subject || '?' }}
|
||||||
|
|
||||||
v-row.justify-center
|
v-row.justify-center
|
||||||
v-sheet.elevation-8.justify-center(
|
v-sheet.elevation-8.justify-center(
|
||||||
@@ -53,28 +54,53 @@ v-container.fill-height.d-flex.justify-center.align-center
|
|||||||
v-btn(color="success" variant="flat" block :disabled="isDisabled" @click="clearCanvas") Clear
|
v-btn(color="success" variant="flat" block :disabled="isDisabled" @click="clearCanvas") Clear
|
||||||
v-col(cols="auto" class="mx-1")
|
v-col(cols="auto" class="mx-1")
|
||||||
v-btn(color="info" variant="flat" block :disabled="!isDisabled" @click="nextItem") Next
|
v-btn(color="info" variant="flat" block :disabled="!isDisabled" @click="nextItem") Next
|
||||||
|
|
||||||
|
template(v-else)
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary 🎉 Congratulations!
|
||||||
|
v-card-text.text-center
|
||||||
|
| You have completed all handwriting reviews for today!
|
||||||
|
v-row.justify-center.mt-6
|
||||||
|
v-col(cols="6")
|
||||||
|
v-btn(
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
@click="quitSession"
|
||||||
|
) Back to Home
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Reviews } from '../composables/subject.ts'
|
import { nextSubject, correct, incorrect } from '../composables/srs.scoring.ts'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const canvasId = 'kanjiCanvas'
|
const canvasId = 'kanjiCanvas'
|
||||||
const reviews = ref<Reviews | null>(null)
|
|
||||||
const subject = ref<any>(null)
|
const subject = ref<any>(null)
|
||||||
const candidates = ref<string[]>([])
|
const candidates = ref<string[]>([])
|
||||||
const recognizedKanji = ref('')
|
const recognizedKanji = ref('')
|
||||||
const isCorrect = ref(false)
|
const isCorrect = ref(false)
|
||||||
const resultMessage = ref('')
|
const resultMessage = ref('')
|
||||||
const isDisabled = ref(false)
|
const isDisabled = ref(false)
|
||||||
|
const allDone = ref(false)
|
||||||
|
|
||||||
async function createReview() {
|
async function createReview() {
|
||||||
if (!reviews.value) return
|
const options = {
|
||||||
const next = reviews.value.nextSubject()
|
subject: ['kanji'],
|
||||||
if (!next) return
|
mode: ['meaning'], // request the meaning since we draw the kanji
|
||||||
subject.value = next.subject
|
direction: 'en->jp',
|
||||||
|
writingPractice: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await nextSubject(options)
|
||||||
|
if (!next) {
|
||||||
|
allDone.value = true
|
||||||
|
resultMessage.value = '✅ All handwriting reviews done!'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subject.value = next
|
||||||
candidates.value = []
|
candidates.value = []
|
||||||
recognizedKanji.value = ''
|
recognizedKanji.value = ''
|
||||||
isDisabled.value = false
|
isDisabled.value = false
|
||||||
@@ -95,12 +121,14 @@ function recognizeKanji() {
|
|||||||
recognizedKanji.value = candidates.value.join(', ')
|
recognizedKanji.value = candidates.value.join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectCandidate(kanji: string) {
|
async function selectCandidate(kanji: string) {
|
||||||
if (!subject.value) return
|
if (!subject.value) return
|
||||||
|
isCorrect.value = kanji === subject.value.answers[0]
|
||||||
isCorrect.value = kanji === subject.value.characters
|
resultMessage.value = isCorrect.value ? 'Correct!' : `Wrong! Answer: ${subject.value.answers[0]}`
|
||||||
resultMessage.value = isCorrect.value ? 'Correct!' : `Wrong! Answer: ${subject.value.characters}`
|
|
||||||
isDisabled.value = true
|
isDisabled.value = true
|
||||||
|
|
||||||
|
if (isCorrect.value) await correct()
|
||||||
|
else await incorrect()
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCanvas() {
|
function clearCanvas() {
|
||||||
@@ -113,16 +141,11 @@ function clearCanvas() {
|
|||||||
|
|
||||||
function quitSession() { router.push('/') }
|
function quitSession() { router.push('/') }
|
||||||
function skipItem() { createReview() }
|
function skipItem() { createReview() }
|
||||||
|
function nextItem() { createReview() }
|
||||||
function nextItem() {
|
|
||||||
createReview()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
reviews.value = await Reviews.getInstance()
|
|
||||||
await reviews.value.setOptions({ type: 'kanji', mode: 'writing' })
|
|
||||||
createReview()
|
|
||||||
initCanvas()
|
initCanvas()
|
||||||
|
createReview()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
|||||||
@@ -34,19 +34,24 @@ router.isReady().then(() => {
|
|||||||
localStorage.removeItem('vuetify:dynamic-reload')
|
localStorage.removeItem('vuetify:dynamic-reload')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let hasFetchedUser = false
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
if (!auth.user && !auth.loading) {
|
|
||||||
await auth.fetchUser()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (to.path === '/login') return true
|
if (to.path === '/login') return true
|
||||||
|
|
||||||
|
if (!hasFetchedUser && !auth.user && !auth.loading) {
|
||||||
|
hasFetchedUser = true
|
||||||
|
await auth.fetchUser()
|
||||||
|
}
|
||||||
|
|
||||||
if (!auth.isAuthenticated) {
|
if (!auth.isAuthenticated) {
|
||||||
return { path: '/login' }
|
return { path: '/login', query: { redirect: to.fullPath } }
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number
|
id: number
|
||||||
@@ -12,6 +12,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
let refreshInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the current logged-in user from the server.
|
||||||
|
* Tries refresh if access token expired.
|
||||||
|
*/
|
||||||
async function fetchUser() {
|
async function fetchUser() {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -19,11 +25,17 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
user.value = data.user
|
user.value = data.user
|
||||||
error.value = null
|
error.value = null
|
||||||
} else if (res.status === 401) {
|
startAutoRefresh()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token expired or invalid → try refresh
|
||||||
|
if (res.status === 401) {
|
||||||
const refreshed = await refreshToken()
|
const refreshed = await refreshToken()
|
||||||
if (refreshed) return await fetchUser()
|
if (refreshed) return await fetchUser()
|
||||||
user.value = null
|
user.value = null
|
||||||
@@ -31,6 +43,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
throw new Error('Failed to fetch user')
|
throw new Error('Failed to fetch user')
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
console.warn('fetchUser failed:', err)
|
||||||
error.value = err.message
|
error.value = err.message
|
||||||
user.value = null
|
user.value = null
|
||||||
} finally {
|
} finally {
|
||||||
@@ -38,22 +51,30 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(username: string, password: string) {
|
/**
|
||||||
|
* Perform login and set cookies.
|
||||||
|
*/
|
||||||
|
async function login(username: string, password: string, remember: boolean) {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
const res = await fetch('/api/v1/auth/login', {
|
const res = await fetch('/api/v1/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password, remember }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) throw new Error('Invalid credentials')
|
if (!res.ok) throw new Error('Invalid credentials')
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (data.ok && data.user) {
|
if (data.ok && data.user) {
|
||||||
user.value = data.user
|
user.value = data.user
|
||||||
|
startAutoRefresh()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Login failed')
|
throw new Error('Login failed')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = err.message
|
error.value = err.message
|
||||||
@@ -63,24 +84,64 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the access token using refresh cookie.
|
||||||
|
*/
|
||||||
async function refreshToken() {
|
async function refreshToken() {
|
||||||
|
// Skip if no refresh cookie (expired or logged out)
|
||||||
if (!document.cookie.includes('refresh_token')) return false
|
if (!document.cookie.includes('refresh_token')) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/auth/refresh', {
|
const res = await fetch('/api/v1/auth/refresh', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
console.info('Token refreshed')
|
console.info('[Auth] Token refreshed')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
console.warn('Token refresh failed')
|
|
||||||
|
console.warn('[Auth] Token refresh failed with status', res.status)
|
||||||
return false
|
return false
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('[Auth] Refresh error', err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically refresh tokens before expiry.
|
||||||
|
*/
|
||||||
|
function startAutoRefresh() {
|
||||||
|
if (refreshInterval) clearInterval(refreshInterval)
|
||||||
|
|
||||||
|
// Refresh every 7.5 minutes (half of 15m access token)
|
||||||
|
refreshInterval = setInterval(async () => {
|
||||||
|
if (!user.value) return
|
||||||
|
const success = await refreshToken()
|
||||||
|
if (!success) {
|
||||||
|
console.warn('[Auth] Auto-refresh failed, trying fetchUser')
|
||||||
|
const ok = await fetchUser()
|
||||||
|
if (!ok) {
|
||||||
|
console.warn('[Auth] Session expired, logging out')
|
||||||
|
await logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 7.5 * 60 * 1000)
|
||||||
|
|
||||||
|
// Also refresh immediately if tab comes back from background
|
||||||
|
document.addEventListener('visibilitychange', async () => {
|
||||||
|
if (document.visibilityState === 'visible' && user.value) {
|
||||||
|
const success = await refreshToken()
|
||||||
|
if (!success) await logout()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the refresh timer and logout from backend.
|
||||||
|
*/
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/v1/auth/logout', {
|
await fetch('/api/v1/auth/logout', {
|
||||||
@@ -91,6 +152,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
console.warn('Logout error:', err)
|
console.warn('Logout error:', err)
|
||||||
} finally {
|
} finally {
|
||||||
user.value = null
|
user.value = null
|
||||||
|
if (refreshInterval) clearInterval(refreshInterval)
|
||||||
|
refreshInterval = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
client/src/typed-router.d.ts
vendored
11
client/src/typed-router.d.ts
vendored
@@ -20,9 +20,8 @@ declare module 'vue-router/auto-routes' {
|
|||||||
export interface RouteNamedMap {
|
export interface RouteNamedMap {
|
||||||
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
|
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
|
||||||
'/all': RouteRecordInfo<'/all', '/all', Record<never, never>, Record<never, never>>,
|
'/all': RouteRecordInfo<'/all', '/all', Record<never, never>, Record<never, never>>,
|
||||||
'/kanji': RouteRecordInfo<'/kanji', '/kanji', Record<never, never>, Record<never, never>>,
|
|
||||||
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
|
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
|
||||||
'/vocab': RouteRecordInfo<'/vocab', '/vocab', Record<never, never>, Record<never, never>>,
|
'/trainer': RouteRecordInfo<'/trainer', '/trainer', Record<never, never>, Record<never, never>>,
|
||||||
'/writing': RouteRecordInfo<'/writing', '/writing', Record<never, never>, Record<never, never>>,
|
'/writing': RouteRecordInfo<'/writing', '/writing', Record<never, never>, Record<never, never>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,16 +44,12 @@ declare module 'vue-router/auto-routes' {
|
|||||||
routes: '/all'
|
routes: '/all'
|
||||||
views: never
|
views: never
|
||||||
}
|
}
|
||||||
'src/pages/kanji.vue': {
|
|
||||||
routes: '/kanji'
|
|
||||||
views: never
|
|
||||||
}
|
|
||||||
'src/pages/login.vue': {
|
'src/pages/login.vue': {
|
||||||
routes: '/login'
|
routes: '/login'
|
||||||
views: never
|
views: never
|
||||||
}
|
}
|
||||||
'src/pages/vocab.vue': {
|
'src/pages/trainer.vue': {
|
||||||
routes: '/vocab'
|
routes: '/trainer'
|
||||||
views: never
|
views: never
|
||||||
}
|
}
|
||||||
'src/pages/writing.vue': {
|
'src/pages/writing.vue': {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function createRefreshToken(user: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.post('/login', async (req: Request, res: Response) => {
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
const { email, username, password } = req.body
|
const { username, password, remember } = req.body
|
||||||
if (!username || !password) return res.status(400).json({ error: 'Missing credentials' })
|
if (!username || !password) return res.status(400).json({ error: 'Missing credentials' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -40,7 +40,7 @@ router.post('/login', async (req: Request, res: Response) => {
|
|||||||
user = await UserModel.create({
|
user = await UserModel.create({
|
||||||
username: ldapUser.user.cn,
|
username: ldapUser.user.cn,
|
||||||
email: ldapUser.user.dn,
|
email: ldapUser.user.dn,
|
||||||
refresh_token: '',
|
refreshToken: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +51,12 @@ router.post('/login', async (req: Request, res: Response) => {
|
|||||||
await user.save()
|
await user.save()
|
||||||
|
|
||||||
res.cookie('access_token', accessToken, {
|
res.cookie('access_token', accessToken, {
|
||||||
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 15 * 60 * 1000,
|
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||||
})
|
})
|
||||||
res.cookie('refresh_token', refreshToken, {
|
const refreshMaxAge = remember > 7 ? 365 * 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000
|
||||||
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 7 * 24 * 3600 * 1000,
|
|
||||||
|
res.cookie('refreshToken', refreshToken, {
|
||||||
|
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: refreshMaxAge,
|
||||||
})
|
})
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -72,12 +74,14 @@ router.post('/login', async (req: Request, res: Response) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.post('/refresh', async (req: Request, res: Response) => {
|
router.post('/refresh', async (req: Request, res: Response) => {
|
||||||
const token = req.cookies.refresh_token
|
const token = req.cookies.refreshToken
|
||||||
if (!token) return res.status(401).json({ error: 'No refresh token' })
|
if (!token) return res.status(401).json({ error: 'No refresh token' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET)
|
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET) as any
|
||||||
const user = await UserModel.findById(payload.sub)
|
const user = await UserModel.findById(payload.sub)
|
||||||
if (!user || !user.refreshToken === token) return res.status(403).json({ error: 'Invalid refresh token' })
|
if (!user || user.refreshToken !== token)
|
||||||
|
return res.status(403).json({ error: 'Invalid refresh token' })
|
||||||
|
|
||||||
const newAccessToken = createAccessToken(user)
|
const newAccessToken = createAccessToken(user)
|
||||||
const newRefreshToken = createRefreshToken(user)
|
const newRefreshToken = createRefreshToken(user)
|
||||||
@@ -85,20 +89,33 @@ router.post('/refresh', async (req: Request, res: Response) => {
|
|||||||
user.refreshToken = newRefreshToken
|
user.refreshToken = newRefreshToken
|
||||||
await user.save()
|
await user.save()
|
||||||
|
|
||||||
|
const existingRefreshCookie = req.cookies.refreshToken
|
||||||
|
const decodedOld = jwt.decode(existingRefreshCookie) as any
|
||||||
|
const remainingDays = (decodedOld.exp * 1000 - Date.now()) / (1000 * 60 * 60 * 24)
|
||||||
|
|
||||||
|
const refreshMaxAge = remainingDays > 7 ? 365 * 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
res.cookie('access_token', newAccessToken, {
|
res.cookie('access_token', newAccessToken, {
|
||||||
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 15 * 60 * 1000,
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV !== 'dev',
|
||||||
|
maxAge: 15 * 60 * 1000,
|
||||||
})
|
})
|
||||||
res.cookie('refresh_token', newRefreshToken, {
|
res.cookie('refreshToken', newRefreshToken, {
|
||||||
httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV !== 'dev', maxAge: 7 * 24 * 3600 * 1000,
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV !== 'dev',
|
||||||
|
maxAge: refreshMaxAge,
|
||||||
})
|
})
|
||||||
res.json({ ok: true })
|
|
||||||
|
return res.json({ ok: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(401).json({ error: 'Invalid refresh token' })
|
return res.status(401).json({ error: 'Invalid refresh token' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.post('/logout', async (req: Request, res: Response) => {
|
router.post('/logout', async (req: Request, res: Response) => {
|
||||||
const token = req.cookies.refresh_token
|
const token = req.cookies.refreshToken
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET)
|
const payload = jwt.verify(token, REFRESH_TOKEN_SECRET)
|
||||||
@@ -110,7 +127,7 @@ router.post('/logout', async (req: Request, res: Response) => {
|
|||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
res.clearCookie('access_token')
|
res.clearCookie('access_token')
|
||||||
res.clearCookie('refresh_token')
|
res.clearCookie('refreshToken')
|
||||||
res.json({ loggedOut: true })
|
res.json({ loggedOut: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -27,17 +27,35 @@ router.post('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
|||||||
possibleModes.push({ key: 'writing_practice', type: 'writing_practice' })
|
possibleModes.push({ key: 'writing_practice', type: 'writing_practice' })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subjectOptions.direction === 'jp->en') {
|
const directions = Array.isArray(subjectOptions.direction)
|
||||||
const modes = Array.isArray(subjectOptions.mode) ? subjectOptions.mode : [subjectOptions.mode]
|
? subjectOptions.direction
|
||||||
if (modes.includes('meaning')) possibleModes.push({ key: 'jp_en_meaning', type: 'jp_en_meaning' })
|
: subjectOptions.direction === 'both'
|
||||||
if (modes.includes('reading')) possibleModes.push({ key: 'jp_en_reading', type: 'jp_en_reading' })
|
? ['jp->en', 'en->jp']
|
||||||
|
: [subjectOptions.direction]
|
||||||
|
|
||||||
|
for (const dir of directions) {
|
||||||
|
if (dir === 'jp->en') {
|
||||||
|
const modes = Array.isArray(subjectOptions.mode)
|
||||||
|
? subjectOptions.mode
|
||||||
|
: [subjectOptions.mode]
|
||||||
|
if (modes.includes('meaning')) {
|
||||||
|
possibleModes.push({ key: 'jp_en_meaning', type: 'jp_en_meaning' })
|
||||||
|
}
|
||||||
|
if (modes.includes('reading')) {
|
||||||
|
possibleModes.push({ key: 'jp_en_reading', type: 'jp_en_reading' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subjectOptions.direction === 'en->jp') {
|
if (dir === 'en->jp') {
|
||||||
if (subjectOptions.mode === 'writing' || subjectOptions.writingMode) {
|
const modes = Array.isArray(subjectOptions.mode)
|
||||||
|
? subjectOptions.mode
|
||||||
|
: [subjectOptions.mode]
|
||||||
|
|
||||||
|
if (!subjectOptions.meaning && (modes.includes('reading'))) {
|
||||||
possibleModes.push({ key: 'en_jp_writing', type: 'en_jp_writing' })
|
possibleModes.push({ key: 'en_jp_writing', type: 'en_jp_writing' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (possibleModes.length === 0) return res.status(200).json({ message: 'No reviews due' })
|
if (possibleModes.length === 0) return res.status(200).json({ message: 'No reviews due' })
|
||||||
|
|
||||||
@@ -89,7 +107,7 @@ router.post('/correct', verifyAccessToken, async (req: AuthRequest, res: Respons
|
|||||||
if (req.body.currentSubjectOptions.writingPractice) {
|
if (req.body.currentSubjectOptions.writingPractice) {
|
||||||
key = 'srs.writing_practice'
|
key = 'srs.writing_practice'
|
||||||
} else if (req.body.currentSubjectOptions.direction === 'jp->en') {
|
} else if (req.body.currentSubjectOptions.direction === 'jp->en') {
|
||||||
key = req.body.currentSubjectOptions.mode === 'reading' ? 'srs.jp_en_reading' : 'srs.jp_en_meaning'
|
key = req.body.currentSubjectOptions.mode === 'jp_en_reading' ? 'srs.jp_en_reading' : 'srs.jp_en_meaning'
|
||||||
} else {
|
} else {
|
||||||
key = 'srs.en_jp_writing'
|
key = 'srs.en_jp_writing'
|
||||||
}
|
}
|
||||||
@@ -155,23 +173,42 @@ router.get('/stats', verifyAccessToken, async (req: AuthRequest, res: Response)
|
|||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
|
// Total counts
|
||||||
const totalKanji = await ReviewItemModel.countDocuments({ userId, type: 'kanji' })
|
const totalKanji = await ReviewItemModel.countDocuments({ userId, type: 'kanji' })
|
||||||
const totalVocab = await ReviewItemModel.countDocuments({ userId, type: 'vocab' })
|
const totalVocab = await ReviewItemModel.countDocuments({ userId, type: 'vocab' })
|
||||||
|
|
||||||
|
// Any of the SRS fields that are due
|
||||||
|
const dueConditions = [
|
||||||
|
{ 'srs.jp_en_meaning.next_review': { $lte: now } },
|
||||||
|
{ 'srs.jp_en_reading.next_review': { $lte: now } },
|
||||||
|
{ 'srs.en_jp_writing.next_review': { $lte: now } },
|
||||||
|
{ 'srs.writing_practice.next_review': { $lte: now } },
|
||||||
|
]
|
||||||
|
|
||||||
const dueItems = await ReviewItemModel.countDocuments({
|
const dueItems = await ReviewItemModel.countDocuments({
|
||||||
userId,
|
userId,
|
||||||
srs_next_review: { $lte: now },
|
$or: dueConditions,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Waiting items = have next_review set to the future in any field
|
||||||
|
const waitingConditions = [
|
||||||
|
{ 'srs.jp_en_meaning.next_review': { $gt: now } },
|
||||||
|
{ 'srs.jp_en_reading.next_review': { $gt: now } },
|
||||||
|
{ 'srs.en_jp_writing.next_review': { $gt: now } },
|
||||||
|
{ 'srs.writing_practice.next_review': { $gt: now } },
|
||||||
|
]
|
||||||
|
|
||||||
const waitingItems = await ReviewItemModel.countDocuments({
|
const waitingItems = await ReviewItemModel.countDocuments({
|
||||||
userId,
|
userId,
|
||||||
srs_next_review: { $gt: now },
|
$or: waitingConditions,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Average SRS score per type (optional — kept from original)
|
||||||
const kanjiAgg = await ReviewItemModel.aggregate([
|
const kanjiAgg = await ReviewItemModel.aggregate([
|
||||||
{ $match: { userId, type: 'kanji' } },
|
{ $match: { userId, type: 'kanji' } },
|
||||||
{ $group: { _id: null, avgScore: { $avg: '$srs_score' } } },
|
{ $group: { _id: null, avgScore: { $avg: '$srs_score' } } },
|
||||||
])
|
])
|
||||||
|
|
||||||
const vocabAgg = await ReviewItemModel.aggregate([
|
const vocabAgg = await ReviewItemModel.aggregate([
|
||||||
{ $match: { userId, type: 'vocab' } },
|
{ $match: { userId, type: 'vocab' } },
|
||||||
{ $group: { _id: null, avgScore: { $avg: '$srs_score' } } },
|
{ $group: { _id: null, avgScore: { $avg: '$srs_score' } } },
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import express, { type Router, type Request, type Response } from 'express'
|
|
||||||
import { KanjiModel } from '../../../models/kanji.model.ts'
|
|
||||||
|
|
||||||
const router = express.Router()
|
|
||||||
|
|
||||||
|
|
||||||
router.get('/', async (_req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const doc = await KanjiModel.find()
|
|
||||||
res.json(doc)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching Kanji Subjects', error)
|
|
||||||
res.status(500).json({ error: 'Failed to fetch Kanji Subjects' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router as Router
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import express, { type Router, type Request, type Response } from 'express'
|
|
||||||
import { VocabularyModel } from '../../../models/vocabulary.model.ts'
|
|
||||||
|
|
||||||
const router = express.Router()
|
|
||||||
|
|
||||||
|
|
||||||
router.get('/', async (_req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const doc = await VocabularyModel.find()
|
|
||||||
|
|
||||||
res.json(doc)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching API key:', error)
|
|
||||||
res.status(500).json({ error: 'Failed to fetch API key' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router as Router
|
|
||||||
@@ -11,8 +11,6 @@ import { connectMongo } from './db/connect.ts'
|
|||||||
|
|
||||||
import keyRouter from './api/v1/key/index.ts'
|
import keyRouter from './api/v1/key/index.ts'
|
||||||
import syncRouter from './api/v1/wanikani/sync.ts'
|
import syncRouter from './api/v1/wanikani/sync.ts'
|
||||||
import kanjiRouter from './api/v1/subject/kanji.ts'
|
|
||||||
import vocabRouter from './api/v1/subject/vocab.ts'
|
|
||||||
import authRouter from './api/v1/auth/index.ts'
|
import authRouter from './api/v1/auth/index.ts'
|
||||||
import userRoutes from './api/v1/user/index.ts'
|
import userRoutes from './api/v1/user/index.ts'
|
||||||
import subjectRoutes from './api/v1/subject/index.ts'
|
import subjectRoutes from './api/v1/subject/index.ts'
|
||||||
@@ -56,11 +54,8 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }))
|
|
||||||
|
|
||||||
app.use('/api/v1/key', verifyAccessToken, keyRouter)
|
app.use('/api/v1/key', verifyAccessToken, keyRouter)
|
||||||
app.use('/api/v1/subject/kanji', verifyAccessToken, kanjiRouter)
|
|
||||||
app.use('/api/v1/subject/vocab', verifyAccessToken, vocabRouter)
|
|
||||||
app.use('/api/v1/user', verifyAccessToken, userRoutes)
|
app.use('/api/v1/user', verifyAccessToken, userRoutes)
|
||||||
app.use('/api/v1/wanikani', verifyAccessToken, syncRouter)
|
app.use('/api/v1/wanikani', verifyAccessToken, syncRouter)
|
||||||
app.use('/api/v1/auth', authRouter)
|
app.use('/api/v1/auth', authRouter)
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import mongoose from 'mongoose'
|
|
||||||
import type { KanjiItem } from '../types/wanikani.ts'
|
|
||||||
|
|
||||||
const KanjiSchema = new mongoose.Schema<KanjiItem>({
|
|
||||||
userId: { type: String, required: true },
|
|
||||||
characters: String,
|
|
||||||
meanings: Array,
|
|
||||||
readings: Array,
|
|
||||||
auxiliary_meanings: Array,
|
|
||||||
level: Number,
|
|
||||||
slug: String,
|
|
||||||
srs_score: Number,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const KanjiModel = mongoose.model('Kanji', KanjiSchema)
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import mongoose from 'mongoose'
|
|
||||||
import type { VocabularyItem } from '../types/wanikani.ts'
|
|
||||||
|
|
||||||
const VocabSchema = new mongoose.Schema<VocabularyItem>({
|
|
||||||
userId: { type: String, required: true },
|
|
||||||
characters: String,
|
|
||||||
meanings: Array,
|
|
||||||
readings: Array,
|
|
||||||
auxiliary_meanings: Array,
|
|
||||||
pronunciation_audios: Array,
|
|
||||||
level: Number,
|
|
||||||
slug: String,
|
|
||||||
srs_score: Number,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const VocabularyModel = mongoose.model('Vocabulary', VocabSchema)
|
|
||||||
Reference in New Issue
Block a user