finish srs system

This commit is contained in:
Rene Kievits
2025-10-27 05:45:38 +01:00
parent 882328c28e
commit 150667f781
19 changed files with 652 additions and 1011 deletions

View File

@@ -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,23 +95,18 @@ 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,
subject: subjectText, subject: subjectText,
@@ -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: {

View File

@@ -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
}
}

View File

@@ -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
| Youve 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()

View File

@@ -17,19 +17,21 @@ v-container.fill-height.d-flex.justify-center.align-center.position-relative
v-card-text v-card-text
v-row v-row
v-col(cols="12" sm="3") v-col(cols="12" sm="3")
v-btn.py-6( v-btn.py-6(
color="var(--color-all)" color="var(--color-all)"
variant="flat" variant="flat"
block block
@click="_startTraining('all')" :disabled="!hasSelectedOptions"
) @click="_startTraining('all')"
span.font-weight-bold All )
span.font-weight-bold All
v-col(cols="12" sm="3") v-col(cols="12" sm="3")
v-btn.py-6( v-btn.py-6(
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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -1,80 +1,106 @@
<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
v-card-title.text-center.text-h2.font-weight-bold.mb-4 template(v-if="!allDone")
span.text-primary Handwriting Trainer v-card-title.text-center.text-h2.font-weight-bold.mb-4
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(
rounded="xl" rounded="xl"
width="280px" width="280px"
height="280px" height="280px"
) )
canvas#kanjiCanvas( canvas#kanjiCanvas(
width="280" width="280"
height="280" height="280"
:style="{borderRadius: '12px', backgroundColor: '#313131'}" :style="{borderRadius: '12px', backgroundColor: '#313131'}"
) )
v-row.justify-center.my-6 v-row.justify-center.my-6
v-col(cols="12" class="text-center") v-col(cols="12" class="text-center")
.text-h5.text-white Select the kanji you drew: .text-h5.text-white Select the kanji you drew:
v-btn-group v-btn-group
v-btn(
v-for="kanji in candidates"
:key="kanji"
color="var(--color-writing)"
class="mx-1 my-1"
@click="selectCandidate(kanji)"
) {{ kanji || '-' }}
transition(name="alert-grow")
v-alert.mb-4(
v-if="resultMessage"
:type="isCorrect ? 'success' : 'error'"
border="top"
variant="outlined"
density="compact"
) {{ resultMessage }}
v-row.justify-center
v-col(cols="auto" class="mx-1")
v-btn(color="error" variant="flat" block @click="quitSession") Quit
v-col(cols="auto" class="mx-1")
v-btn(color="secondary" variant="flat" block :disabled="isDisabled" @click="skipItem") Skip
v-col(cols="auto" class="mx-1")
v-btn(color="primary" variant="flat" block :disabled="isDisabled" @click="recognizeKanji") Recognize
v-col(cols="auto" class="mx-1")
v-btn(color="success" variant="flat" block :disabled="isDisabled" @click="clearCanvas") Clear
v-col(cols="auto" class="mx-1")
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( v-btn(
v-for="kanji in candidates" color="primary"
:key="kanji" block
color="var(--color-writing)" @click="quitSession"
class="mx-1 my-1" ) Back to Home
@click="selectCandidate(kanji)"
) {{ kanji || '-' }}
transition(name="alert-grow")
v-alert.mb-4(
v-if="resultMessage"
:type="isCorrect ? 'success' : 'error'"
border="top"
variant="outlined"
density="compact"
) {{ resultMessage }}
v-row.justify-center
v-col(cols="auto" class="mx-1")
v-btn(color="error" variant="flat" block @click="quitSession") Quit
v-col(cols="auto" class="mx-1")
v-btn(color="secondary" variant="flat" block :disabled="isDisabled" @click="skipItem") Skip
v-col(cols="auto" class="mx-1")
v-btn(color="primary" variant="flat" block :disabled="isDisabled" @click="recognizeKanji") Recognize
v-col(cols="auto" class="mx-1")
v-btn(color="success" variant="flat" block :disabled="isDisabled" @click="clearCanvas") Clear
v-col(cols="auto" class="mx-1")
v-btn(color="info" variant="flat" block :disabled="!isDisabled" @click="nextItem") Next
</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(() => {

View File

@@ -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

View File

@@ -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
} }
} }

View File

@@ -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': {

View File

@@ -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 })
}) })

View File

@@ -27,15 +27,33 @@ 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]
if (subjectOptions.direction === 'en->jp') { for (const dir of directions) {
if (subjectOptions.mode === 'writing' || subjectOptions.writingMode) { if (dir === 'jp->en') {
possibleModes.push({ key: 'en_jp_writing', type: 'en_jp_writing' }) 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 (dir === 'en->jp') {
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' })
}
} }
} }
@@ -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' } } },

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)