v1
This commit is contained in:
5
client/src/App.vue
Normal file
5
client/src/App.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<router-view />
|
||||
</v-app>
|
||||
</template>
|
||||
BIN
client/src/assets/logo.png
Normal file
BIN
client/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
6
client/src/assets/logo.svg
Normal file
6
client/src/assets/logo.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M261.126 140.65L164.624 307.732L256.001 466L377.028 256.5L498.001 47H315.192L261.126 140.65Z" fill="#1697F6"/>
|
||||
<path d="M135.027 256.5L141.365 267.518L231.64 111.178L268.731 47H256H14L135.027 256.5Z" fill="#AEDDFF"/>
|
||||
<path d="M315.191 47C360.935 197.446 256 466 256 466L164.624 307.732L315.191 47Z" fill="#1867C0"/>
|
||||
<path d="M268.731 47C76.0026 47 141.366 267.518 141.366 267.518L268.731 47Z" fill="#7BC6FF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
151
client/src/auto-imports.d.ts
vendored
Normal file
151
client/src/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,151 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const getCurrentWatcher: typeof import('vue')['getCurrentWatcher']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const isShallow: typeof import('vue')['isShallow']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
|
||||
// for vue template auto import
|
||||
import { UnwrapRef } from 'vue'
|
||||
declare module 'vue' {
|
||||
interface GlobalComponents {}
|
||||
interface ComponentCustomProperties {
|
||||
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly getCurrentWatcher: UnwrapRef<typeof import('vue')['getCurrentWatcher']>
|
||||
readonly h: UnwrapRef<typeof import('vue')['h']>
|
||||
readonly inject: UnwrapRef<typeof import('vue')['inject']>
|
||||
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
|
||||
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
|
||||
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
|
||||
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
|
||||
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
|
||||
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
||||
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
||||
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
||||
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
|
||||
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router')['onBeforeRouteLeave']>
|
||||
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router')['onBeforeRouteUpdate']>
|
||||
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
|
||||
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
|
||||
readonly onDeactivated: UnwrapRef<typeof import('vue')['onDeactivated']>
|
||||
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
|
||||
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
|
||||
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
|
||||
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
|
||||
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
|
||||
readonly onServerPrefetch: UnwrapRef<typeof import('vue')['onServerPrefetch']>
|
||||
readonly onUnmounted: UnwrapRef<typeof import('vue')['onUnmounted']>
|
||||
readonly onUpdated: UnwrapRef<typeof import('vue')['onUpdated']>
|
||||
readonly onWatcherCleanup: UnwrapRef<typeof import('vue')['onWatcherCleanup']>
|
||||
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||
readonly readonly: UnwrapRef<typeof import('vue')['readonly']>
|
||||
readonly ref: UnwrapRef<typeof import('vue')['ref']>
|
||||
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
|
||||
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
|
||||
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
|
||||
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
|
||||
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
|
||||
readonly toValue: UnwrapRef<typeof import('vue')['toValue']>
|
||||
readonly triggerRef: UnwrapRef<typeof import('vue')['triggerRef']>
|
||||
readonly unref: UnwrapRef<typeof import('vue')['unref']>
|
||||
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||
readonly useCssModule: UnwrapRef<typeof import('vue')['useCssModule']>
|
||||
readonly useCssVars: UnwrapRef<typeof import('vue')['useCssVars']>
|
||||
readonly useId: UnwrapRef<typeof import('vue')['useId']>
|
||||
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
||||
readonly useRoute: UnwrapRef<typeof import('vue-router')['useRoute']>
|
||||
readonly useRouter: UnwrapRef<typeof import('vue-router')['useRouter']>
|
||||
readonly useSlots: UnwrapRef<typeof import('vue')['useSlots']>
|
||||
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
|
||||
readonly watch: UnwrapRef<typeof import('vue')['watch']>
|
||||
readonly watchEffect: UnwrapRef<typeof import('vue')['watchEffect']>
|
||||
readonly watchPostEffect: UnwrapRef<typeof import('vue')['watchPostEffect']>
|
||||
readonly watchSyncEffect: UnwrapRef<typeof import('vue')['watchSyncEffect']>
|
||||
}
|
||||
}
|
||||
14
client/src/components.d.ts
vendored
Normal file
14
client/src/components.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
143
client/src/composables/subject.ts
Normal file
143
client/src/composables/subject.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
7
client/src/global.d.ts
vendored
Normal file
7
client/src/global.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export { }
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
KanjiCanvas: any
|
||||
}
|
||||
}
|
||||
5
client/src/layouts/default.vue
Normal file
5
client/src/layouts/default.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<v-main>
|
||||
<router-view />
|
||||
</v-main>
|
||||
</template>
|
||||
23
client/src/main.ts
Normal file
23
client/src/main.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* main.ts
|
||||
*
|
||||
* Bootstraps Vuetify and other plugins then mounts the App`
|
||||
*/
|
||||
|
||||
// Composables
|
||||
import { createApp } from 'vue'
|
||||
|
||||
// Plugins
|
||||
import { registerPlugins } from '@/plugins'
|
||||
|
||||
// Components
|
||||
import App from './App.vue'
|
||||
|
||||
// Styles
|
||||
import 'unfonts.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
registerPlugins(app)
|
||||
|
||||
app.mount('#app')
|
||||
297
client/src/pages/all.vue
Normal file
297
client/src/pages/all.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<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 Full Trainer
|
||||
|
||||
v-sheet.d-flex.justify-center.align-center.rounded-lg.py-8.px-4.elevation-12(
|
||||
:style="{backgroundColor: 'var(--color-all)', 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-all)'}"
|
||||
)
|
||||
| {{ 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.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
||||
: subject.value.characters.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: 'both', 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>
|
||||
222
client/src/pages/index.vue
Normal file
222
client/src/pages/index.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template lang="pug">
|
||||
v-container.fill-height.d-flex.justify-center.align-center
|
||||
v-card.pa-8.pb-6(
|
||||
elevation="12"
|
||||
rounded="xl"
|
||||
max-width="650px"
|
||||
width="100%"
|
||||
)
|
||||
v-card-title.text-center.text-h4.font-weight-bold.mb-4
|
||||
span.text-primary SRS Trainer
|
||||
|
||||
v-card-text
|
||||
v-row
|
||||
v-col(cols="12" sm="3")
|
||||
v-btn.py-6(
|
||||
color="var(--color-all)"
|
||||
variant="flat"
|
||||
block
|
||||
@click="_startTraining('all')"
|
||||
)
|
||||
span.font-weight-bold All
|
||||
|
||||
v-col(cols="12" sm="3")
|
||||
v-btn.py-6(
|
||||
color="var(--color-kanji)"
|
||||
variant="flat"
|
||||
block
|
||||
@click="_startTraining('kanji')"
|
||||
)
|
||||
span.font-weight-bold Kanji
|
||||
|
||||
v-col(cols="12" sm="3")
|
||||
v-btn.py-6(
|
||||
color="var(--color-vocab)"
|
||||
variant="flat"
|
||||
block
|
||||
@click="_startTraining('vocab')"
|
||||
)
|
||||
span.font-weight-bold Vocabulary
|
||||
v-col(cols="12" sm="3")
|
||||
v-btn.py-6(
|
||||
color="var(--color-writing)"
|
||||
variant="flat"
|
||||
block
|
||||
@click="_startTraining('writing')"
|
||||
)
|
||||
span.font-weight-bold Writing
|
||||
|
||||
v-row.d-flex.align-center.mb-6
|
||||
v-col(cols="12" sm="6")
|
||||
.text-subtitle-1.text-secondary.mb-2 Choose Direction
|
||||
v-radio-group(v-model="direction" row hide-details)
|
||||
v-radio(label="Japanese → English" value="jp->en" color="primary")
|
||||
v-radio(label="English → Japanese" value="en->jp" color="primary")
|
||||
v-radio(label="Both at random" value="both" color="primary")
|
||||
|
||||
v-col(cols="12" sm="6")
|
||||
v-row.g-3
|
||||
v-col(cols="6")
|
||||
v-card.elevation-3.rounded-lg.text-center.pa-4.d-flex.flex-column.align-center.justify-center(
|
||||
color="var(--color-kanji)"
|
||||
variant="outlined"
|
||||
height="120px"
|
||||
)
|
||||
v-icon.size-36.mb-2 mdi-book-open-variant
|
||||
.text-subtitle-2 Kanji
|
||||
.text-h6.font-weight-bold {{stats.kanjiCount}}
|
||||
v-col(cols="6")
|
||||
v-card.elevation-3.rounded-lg.text-center.pa-4.d-flex.flex-column.align-center.justify-center(
|
||||
color="var(--color-vocab)"
|
||||
variant="outlined"
|
||||
height="120px"
|
||||
)
|
||||
v-icon.size-36.mb-2 mdi-translate
|
||||
.text-subtitle-3 Vocabulary
|
||||
.text-h6.font-weight-bold {{stats.vocabCount}}
|
||||
|
||||
v-row
|
||||
v-col(cols="12")
|
||||
.text-subtitle-1.text-secondary.mb-2 Training Options
|
||||
v-row(row wrap)
|
||||
v-col(cols="12" sm="4")
|
||||
v-tooltip(text="Asked for writing. JP->EN only (本: ほん)")
|
||||
template(#activator="{ props }")
|
||||
v-checkbox(
|
||||
v-bind="props"
|
||||
v-model="options.writing"
|
||||
label="Writing"
|
||||
color="primary"
|
||||
hide-details
|
||||
)
|
||||
v-col(cols="12" sm="4")
|
||||
v-tooltip(text="Asked for english meaning. JP->EN only (本: Book)")
|
||||
template(#activator="{ props }")
|
||||
v-checkbox(
|
||||
v-bind="props"
|
||||
v-model="options.meaning"
|
||||
label="Meaning"
|
||||
color="primary"
|
||||
hide-details
|
||||
)
|
||||
v-col(cols="12" sm="4")
|
||||
v-tooltip(text="If Kanji should be written instead of kana. EN->JP only (Book: 本)")
|
||||
template(#activator="{ props }")
|
||||
v-checkbox(
|
||||
v-bind="props"
|
||||
v-model="options.kanji"
|
||||
label="Write Kanji"
|
||||
color="primary"
|
||||
hide-details
|
||||
)
|
||||
|
||||
v-row.d-flex.ga-4.align-center
|
||||
v-col.flex-grow-1.py-0
|
||||
v-text-field(
|
||||
v-model="apiKey"
|
||||
variant="outlined"
|
||||
label="API Key"
|
||||
type="password"
|
||||
@keyup.enter="saveApiKey"
|
||||
@click:clear="deleteApiKey"
|
||||
hide-details
|
||||
clearable
|
||||
)
|
||||
v-col.flex-shrink-0.py-0(cols="auto")
|
||||
v-btn(
|
||||
color="primary"
|
||||
class="align-self-stretch"
|
||||
variant="flat"
|
||||
@click="syncWanikani"
|
||||
) Sync
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { Reviews } from '../composables/subject.ts'
|
||||
|
||||
const reviews = ref<Reviews | null>()
|
||||
|
||||
const router = useRouter()
|
||||
const apiKey = ref<string>('')
|
||||
const direction = ref<'en->jp' | 'jp->en' | 'both'>('jp->en')
|
||||
const stats = ref<{ kanjiCount: number, vocabCount: number }>({
|
||||
kanjiCount: 0,
|
||||
vocabCount: 0,
|
||||
})
|
||||
|
||||
const options = ref({
|
||||
meaning: true,
|
||||
writing: true,
|
||||
kanji: false,
|
||||
})
|
||||
|
||||
function _startTraining(type: 'kanji' | 'vocab' | 'all' | 'writing') {
|
||||
router.push({
|
||||
path: '/' + type,
|
||||
query: {
|
||||
direction: direction.value,
|
||||
options: JSON.stringify(options.value),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function getApiKey(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/key')
|
||||
if (!res.ok) throw new Error('Failed to fetch API key')
|
||||
const data: { apiKey?: string } = await res.json()
|
||||
if (data.apiKey) apiKey.value = data.apiKey
|
||||
} catch (error) {
|
||||
console.error('Error fetching API key:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveApiKey(): Promise<void> {
|
||||
if (!apiKey.value.trim()) return
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/key', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey: apiKey.value }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update API key')
|
||||
} catch (error) {
|
||||
console.error('Error saving API key:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApiKey() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/key', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to delete API key')
|
||||
apiKey.value = ''
|
||||
} catch (error) {
|
||||
console.error('Error deleting API key:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function syncWanikani() {
|
||||
try {
|
||||
await fetch('/api/v1/wanikani/sync')
|
||||
} catch (error) {
|
||||
console.error('Error syncing Wanikani data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
if (!reviews.value) return
|
||||
stats.value = reviews.value.getStats()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
reviews.value = await Reviews.getInstance()
|
||||
getApiKey()
|
||||
updateStats()
|
||||
})
|
||||
</script>
|
||||
297
client/src/pages/kanji.vue
Normal file
297
client/src/pages/kanji.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<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 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>
|
||||
|
||||
<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.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
||||
: subject.value.characters.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: 'kanji', 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>
|
||||
297
client/src/pages/vocab.vue
Normal file
297
client/src/pages/vocab.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<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.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
||||
: subject.value.characters.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>
|
||||
158
client/src/pages/writing.vue
Normal file
158
client/src/pages/writing.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<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 Handwriting Trainer
|
||||
|
||||
v-row.justify-center.mb-6
|
||||
v-col(cols="12" class="text-center")
|
||||
.text-h4.text-white.font-weight-bold
|
||||
| Draw the kanji for: {{ subject?.meanings?.find(m => m.primary)?.meaning || subject?.readings?.[0]?.reading || '?' }}
|
||||
|
||||
v-row.justify-center
|
||||
v-sheet.elevation-8.justify-center(
|
||||
rounded="xl"
|
||||
width="280px"
|
||||
height="280px"
|
||||
)
|
||||
canvas#kanjiCanvas(
|
||||
width="280"
|
||||
height="280"
|
||||
:style="{borderRadius: '12px', backgroundColor: '#313131'}"
|
||||
)
|
||||
|
||||
v-row.justify-center.my-6
|
||||
v-col(cols="12" class="text-center")
|
||||
.text-h5.text-white Select the kanji you drew:
|
||||
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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Reviews } from '../composables/subject.ts'
|
||||
|
||||
const router = useRouter()
|
||||
const canvasId = 'kanjiCanvas'
|
||||
const reviews = ref<Reviews | null>(null)
|
||||
const subject = ref<any>(null)
|
||||
const candidates = ref<string[]>([])
|
||||
const recognizedKanji = ref('')
|
||||
const isCorrect = ref(false)
|
||||
const resultMessage = ref('')
|
||||
const isDisabled = ref(false)
|
||||
|
||||
async function createReview() {
|
||||
if (!reviews.value) return
|
||||
const next = reviews.value.nextSubject()
|
||||
if (!next) return
|
||||
subject.value = next.subject
|
||||
candidates.value = []
|
||||
recognizedKanji.value = ''
|
||||
isDisabled.value = false
|
||||
resultMessage.value = ''
|
||||
clearCanvas()
|
||||
}
|
||||
|
||||
function initCanvas() {
|
||||
if (window.KanjiCanvas) {
|
||||
window.KanjiCanvas.init(canvasId, { strokeColor: 'white' })
|
||||
} else console.error('KanjiCanvas library is not loaded.')
|
||||
}
|
||||
|
||||
function recognizeKanji() {
|
||||
if (!window.KanjiCanvas || !subject.value) return
|
||||
const result = window.KanjiCanvas.recognize(canvasId).trim().split(/\s+/)
|
||||
candidates.value = result.slice(0, 5)
|
||||
recognizedKanji.value = candidates.value.join(', ')
|
||||
}
|
||||
|
||||
function selectCandidate(kanji: string) {
|
||||
if (!subject.value) return
|
||||
|
||||
isCorrect.value = kanji === subject.value.characters
|
||||
resultMessage.value = isCorrect.value ? 'Correct!' : `Wrong! Answer: ${subject.value.characters}`
|
||||
isDisabled.value = true
|
||||
}
|
||||
|
||||
function clearCanvas() {
|
||||
if (window.KanjiCanvas) {
|
||||
window.KanjiCanvas.erase(canvasId)
|
||||
recognizedKanji.value = ''
|
||||
candidates.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function quitSession() { router.push('/') }
|
||||
function skipItem() { createReview() }
|
||||
|
||||
function nextItem() {
|
||||
createReview()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
reviews.value = await Reviews.getInstance()
|
||||
await reviews.value.setOptions({ type: 'kanji', mode: 'writing' })
|
||||
createReview()
|
||||
initCanvas()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (window.KanjiCanvas) window.KanjiCanvas.erase(canvasId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.text-white {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
20
client/src/plugins/index.ts
Normal file
20
client/src/plugins/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* plugins/index.ts
|
||||
*
|
||||
* Automatically included in `./src/main.ts`
|
||||
*/
|
||||
|
||||
// Plugins
|
||||
import vuetify from './vuetify'
|
||||
import pinia from '../stores'
|
||||
import router from '../router'
|
||||
|
||||
// Types
|
||||
import type { App } from 'vue'
|
||||
|
||||
export function registerPlugins (app: App) {
|
||||
app
|
||||
.use(vuetify)
|
||||
.use(router)
|
||||
.use(pinia)
|
||||
}
|
||||
19
client/src/plugins/vuetify.ts
Normal file
19
client/src/plugins/vuetify.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* plugins/vuetify.ts
|
||||
*
|
||||
* Framework documentation: https://vuetifyjs.com`
|
||||
*/
|
||||
|
||||
// Styles
|
||||
import '@mdi/font/css/materialdesignicons.css'
|
||||
import 'vuetify/styles'
|
||||
|
||||
// Composables
|
||||
import { createVuetify } from 'vuetify'
|
||||
|
||||
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
|
||||
export default createVuetify({
|
||||
theme: {
|
||||
defaultTheme: 'system',
|
||||
},
|
||||
})
|
||||
36
client/src/router/index.ts
Normal file
36
client/src/router/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* router/index.ts
|
||||
*
|
||||
* Automatic routes for `./src/pages/*.vue`
|
||||
*/
|
||||
|
||||
// Composables
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { setupLayouts } from 'virtual:generated-layouts'
|
||||
import { routes } from 'vue-router/auto-routes'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: setupLayouts(routes),
|
||||
})
|
||||
|
||||
// Workaround for https://github.com/vitejs/vite/issues/11804
|
||||
router.onError((err, to) => {
|
||||
if (err?.message?.includes?.('Failed to fetch dynamically imported module')) {
|
||||
if (localStorage.getItem('vuetify:dynamic-reload')) {
|
||||
console.error('Dynamic import error, reloading page did not fix it', err)
|
||||
} else {
|
||||
console.log('Reloading page to fix dynamic import error')
|
||||
localStorage.setItem('vuetify:dynamic-reload', 'true')
|
||||
location.assign(to.fullPath)
|
||||
}
|
||||
} else {
|
||||
console.error(err)
|
||||
}
|
||||
})
|
||||
|
||||
router.isReady().then(() => {
|
||||
localStorage.removeItem('vuetify:dynamic-reload')
|
||||
})
|
||||
|
||||
export default router
|
||||
8
client/src/stores/app.ts
Normal file
8
client/src/stores/app.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Utilities
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
//
|
||||
}),
|
||||
})
|
||||
4
client/src/stores/index.ts
Normal file
4
client/src/stores/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Utilities
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export default createPinia()
|
||||
16
client/src/styles/settings.scss
Normal file
16
client/src/styles/settings.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* src/styles/settings.scss
|
||||
*
|
||||
* Configures SASS variables and Vuetify overwrites
|
||||
*/
|
||||
|
||||
@use 'vuetify/settings' with (
|
||||
$color-pack: false
|
||||
);
|
||||
|
||||
:root {
|
||||
--color-all: hsl(360, 100%, 50%);
|
||||
--color-kanji: hsl(320, 100%, 50%);
|
||||
--color-vocab: hsl(280, 100%, 50%);
|
||||
--color-writing: hsl(220, 100%, 50%);
|
||||
}
|
||||
71
client/src/typed-router.d.ts
vendored
Normal file
71
client/src/typed-router.d.ts
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
|
||||
// It's recommended to commit this file.
|
||||
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
|
||||
|
||||
declare module 'vue-router/auto-routes' {
|
||||
import type {
|
||||
RouteRecordInfo,
|
||||
ParamValue,
|
||||
ParamValueOneOrMore,
|
||||
ParamValueZeroOrMore,
|
||||
ParamValueZeroOrOne,
|
||||
} from 'vue-router'
|
||||
|
||||
/**
|
||||
* Route name map generated by unplugin-vue-router
|
||||
*/
|
||||
export interface RouteNamedMap {
|
||||
'/': RouteRecordInfo<'/', '/', 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>>,
|
||||
'/vocab': RouteRecordInfo<'/vocab', '/vocab', Record<never, never>, Record<never, never>>,
|
||||
'/writing': RouteRecordInfo<'/writing', '/writing', Record<never, never>, Record<never, never>>,
|
||||
}
|
||||
|
||||
/**
|
||||
* Route file to route info map by unplugin-vue-router.
|
||||
* Used by the volar plugin to automatically type useRoute()
|
||||
*
|
||||
* Each key is a file path relative to the project root with 2 properties:
|
||||
* - routes: union of route names of the possible routes when in this page (passed to useRoute<...>())
|
||||
* - views: names of nested views (can be passed to <RouterView name="...">)
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface _RouteFileInfoMap {
|
||||
'src/pages/index.vue': {
|
||||
routes: '/'
|
||||
views: never
|
||||
}
|
||||
'src/pages/all.vue': {
|
||||
routes: '/all'
|
||||
views: never
|
||||
}
|
||||
'src/pages/kanji.vue': {
|
||||
routes: '/kanji'
|
||||
views: never
|
||||
}
|
||||
'src/pages/vocab.vue': {
|
||||
routes: '/vocab'
|
||||
views: never
|
||||
}
|
||||
'src/pages/writing.vue': {
|
||||
routes: '/writing'
|
||||
views: never
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a union of possible route names in a certain route component file.
|
||||
* Used by the volar plugin to automatically type useRoute()
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type _RouteNamesForFilePath<FilePath extends string> =
|
||||
_RouteFileInfoMap extends Record<FilePath, infer Info>
|
||||
? Info['routes']
|
||||
: keyof RouteNamedMap
|
||||
}
|
||||
Reference in New Issue
Block a user