add new lesson mode and started code refraction

This commit is contained in:
Rene Kievits
2025-12-20 04:31:15 +01:00
parent 6438660b03
commit 4428a2b7be
101 changed files with 12255 additions and 8172 deletions

View File

@@ -1,2 +1,2 @@
VITE_API_URL=http://10.0.2.2:3000
CAP_ENV=dev
VITE_API_URL=https://zenkanji-api.crylia.de

1
client/.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules
.env
.env.android

View File

@@ -1,7 +1,10 @@
FROM node:24-alpine AS dev-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
@@ -10,8 +13,3 @@ FROM dev-stage AS build-stage
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
FROM nginx:stable-alpine AS production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

101
client/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

2
client/android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep

View File

@@ -0,0 +1,64 @@
apply plugin: 'com.android.application'
android {
namespace "com.zenkanji.app"
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.zenkanji.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
configurations.all {
resolutionStrategy {
// Force the project to use the newer 1.8.22 versions
// The 1.8+ versions of jdk7/jdk8 are empty placeholders, fixing the duplication.
force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.22'
force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.22'
force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22'
}
}

View File

@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
client/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,5 @@
package com.zenkanji.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Zen Kanji</string>
<string name="title_activity_main">Zen Kanji</string>
<string name="package_name">com.zenkanji.app</string>
<string name="custom_url_scheme">com.zenkanji.app</string>
</resources>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

View File

@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.7.2'
classpath 'com.google.gms:google-services:4.4.2'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@@ -0,0 +1,3 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')

View File

@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
client/android/gradlew vendored Executable file
View File

@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
client/android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,5 @@
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
apply from: 'capacitor.settings.gradle'

View File

@@ -0,0 +1,16 @@
ext {
minSdkVersion = 23
compileSdkVersion = 35
targetSdkVersion = 35
androidxActivityVersion = '1.9.2'
androidxAppCompatVersion = '1.7.0'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.15.0'
androidxFragmentVersion = '1.8.4'
coreSplashScreenVersion = '1.0.1'
androidxWebkitVersion = '1.12.1'
junitVersion = '4.13.2'
androidxJunitVersion = '1.2.1'
androidxEspressoCoreVersion = '3.6.1'
cordovaAndroidVersion = '10.1.1'
}

View File

@@ -1,30 +0,0 @@
/// <reference types="@capacitor/cli" />
require('dotenv').config();
const isDev = process.env.CAP_ENV === 'dev';
const config = {
appId: "com.zenkanji.app",
appName: "Zen Kanji",
webDir: "dist",
server: {
androidScheme: isDev ? "http" : "https",
cleartext: isDev
},
plugins: {
Keyboard: {
resize: "body",
style: "dark",
resizeOnFullScreen: true
},
SplashScreen: {
launchShowDuration: 2000,
backgroundColor: "#1e1e24",
showSpinner: false
}
}
};
console.log(`⚡️ Capacitor Config loaded for: ${isDev ? 'DEVELOPMENT (http)' : 'PRODUCTION (https)'}`);
module.exports = config;

View File

@@ -0,0 +1,9 @@
{
"appId": "com.zenkanji.app",
"appName": "Zen Kanji",
"webDir": "dist",
"server": {
"androidScheme": "http",
"cleartext": true
}
}

View File

@@ -1,8 +1,17 @@
import js from '@eslint/js';
import globals from 'globals';
import pluginVue from 'eslint-plugin-vue';
import pluginVuePug from 'eslint-plugin-vue-pug';
import pluginJsonc from 'eslint-plugin-jsonc';
import { FlatCompat } from '@eslint/eslintrc';
import path from 'path';
import { fileURLToPath } from 'url';
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
const compat = new FlatCompat({
baseDirectory: dirname,
});
export default [
{
@@ -11,11 +20,14 @@ export default [
'**/dist/**',
'**/android/**',
'**/coverage/**',
'**/*.min.js'
]
'**/*.min.js',
],
},
...compat.extends('airbnb-base'),
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 'latest',
@@ -23,40 +35,39 @@ export default [
globals: {
...globals.browser,
...globals.node,
...globals.es2021
}
...globals.es2021,
},
},
rules: {
'no-unused-vars': 'warn',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
}
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'import/extensions': 'off',
'import/no-unresolved': 'off',
'import/prefer-default-export': 'off',
},
},
...pluginVue.configs['flat/recommended'],
{
files: ['*.vue', '**/*.vue'],
plugins: {
'vue-pug': pluginVuePug
},
languageOptions: {
parser: pluginVue.parser,
parserOptions: {
parser: js.configs.recommended.parser,
ecmaVersion: 'latest',
sourceType: 'module',
}
},
},
rules: {
'vue/multi-word-component-names': 'off',
'vue/require-default-prop': 'off',
'vue/html-indent': ['error', 2],
'no-unused-vars': 'off',
'vue/html-indent': 'off',
'vue/no-parsing-error': 'off',
'vue/component-name-in-template-casing': ['error', 'PascalCase', {
registeredComponentsOnly: true,
ignores: []
}]
}
ignores: [],
}],
},
},
...pluginJsonc.configs['flat/recommended-with-jsonc'],
@@ -64,6 +75,14 @@ export default [
files: ['*.json', '**/*.json'],
rules: {
'jsonc/sort-keys': 'off',
}
}
},
},
{
files: ['vite.config.js', 'vite.config.ts'],
rules: {
'import/no-unresolved': 'off',
'import/no-extraneous-dependencies': 'off',
},
},
];

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Zen Kanji</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
</head>

3432
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,12 +15,19 @@
"dependencies": {
"@capacitor/android": "^8.0.0",
"@capacitor/core": "^8.0.0",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@mdi/font": "^7.3.67",
"capacitor": "^0.5.6",
"eslint-plugin-jsonc": "^2.21.0",
"eslint-plugin-vue": "^9.33.0",
"globals": "^16.5.0",
"i18n": "^0.15.3",
"perfect-freehand": "^1.2.2",
"pinia": "^3.0.4",
"pug": "^3.0.3",
"vue": "^3.3.4",
"vite": "^7.3.0",
"vue": "^3.5.26",
"vue-i18n": "^11.2.2",
"vue-pug": "^1.0.2",
"vue-pug-plugin": "^2.0.4",
@@ -29,19 +36,18 @@
},
"devDependencies": {
"@capacitor/cli": "^7.4.4",
"@eslint/js": "^9.39.2",
"@vitejs/plugin-vue": "^6.0.3",
"dotenv": "^17.2.3",
"eslint": "^9.39.2",
"eslint-plugin-jsonc": "^2.21.0",
"eslint-plugin-vue": "^9.33.0",
"eslint-plugin-vue-pug": "^0.6.2",
"globals": "^16.5.0",
"sass": "^1.97.0",
"eslint": "^8.57.1",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"sass": "^1.97.1",
"stylelint": "^16.26.1",
"stylelint-config-recommended-vue": "^1.6.1",
"stylelint-config-standard-scss": "^16.0.0",
"vite": "^7.3.0",
"vue-eslint-parser": "^9.4.3"
"vue-eslint-parser": "^10.2.0"
}
}

View File

@@ -62,7 +62,7 @@
v-icon(start icon="mdi-logout")
| {{ $t('nav.logout') }}
v-app-bar.px-2.app-bar-blur(
v-app-bar.px-2.app-bar-blur.safe-area-header(
flat
color="rgba(30, 30, 36, 0.8)"
border="b"
@@ -189,10 +189,10 @@
.text-center.text-h5.font-weight-bold.text-teal-accent-3.mb-6
| {{ tempBatchSize }} {{ $t('settings.items') }}
.text-caption.text-grey.mb-2 Drawing Tolerance
.text-caption.text-grey.mb-2 {{ $t('settings.drawingTolerance') }}
v-slider(
v-model="tempDrawingAccuracy"
:min="5"
:min="1"
:max="20"
:step="1"
thumb-label
@@ -200,9 +200,9 @@
track-color="grey-darken-3"
)
.d-flex.justify-space-between.text-caption.text-grey-lighten-1.mb-6.px-1
span Strict (5)
span {{ $t('settings.strict') }} (5)
span.font-weight-bold.text-body-1(color="#00cec9") {{ tempDrawingAccuracy }}
span Loose (20)
span {{ $t('settings.loose') }} (20)
.text-caption.text-grey.mb-2 {{ $t('settings.language') }}
v-btn-toggle.d-flex.w-100.border-subtle(
@@ -255,9 +255,10 @@
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { ref, watch, onMounted } from 'vue';
import { useAppStore } from '@/stores/appStore';
import { useI18n } from 'vue-i18n';
import { useAppStore } from '@/stores/appStore';
import logo from '@/assets/icon.svg';
const drawer = ref(false);
@@ -288,6 +289,22 @@ watch(showSettings, (isOpen) => {
}
});
async function manualSync() {
syncing.value = true;
try {
const result = await store.sync();
snackbar.value = { show: true, text: t('alerts.syncSuccess', { count: result.count }), color: 'success' };
await store.fetchQueue();
await store.fetchStats();
await store.fetchCollection();
} catch (e) {
console.error(e);
snackbar.value = { show: true, text: t('alerts.syncFailed'), color: 'error' };
} finally {
syncing.value = false;
}
}
async function handleLogin() {
if (!inputKey.value) return;
loggingIn.value = true;
@@ -306,26 +323,10 @@ async function handleLogin() {
}
}
async function manualSync() {
syncing.value = true;
try {
const result = await store.sync();
snackbar.value = { show: true, text: t('alerts.syncSuccess', { count: result.count }), color: 'success' };
await store.fetchQueue();
await store.fetchStats();
await store.fetchCollection();
} catch (e) {
console.error(e);
snackbar.value = { show: true, text: t('alerts.syncFailed'), color: 'error' };
} finally {
syncing.value = false;
}
}
function saveSettings() {
store.saveSettings({
batchSize: tempBatchSize.value,
drawingAccuracy: tempDrawingAccuracy.value
drawingAccuracy: tempDrawingAccuracy.value,
});
localStorage.setItem('zen_locale', locale.value);
@@ -346,3 +347,16 @@ function confirmLogout() {
</script>
<style lang="scss" src="@/styles/pages/_app.scss"></style>
<style lang="scss" scoped>
.safe-area-header {
padding-top: env(safe-area-inset-top);
height: auto !important;
:deep(.v-toolbar__content) {
min-height: 64px;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template lang="pug">
v-card.pa-4.widget-card.rounded-xl.border-subtle.d-flex.flex-column(
color="#1e1e24"
flat
)
.d-flex.justify-space-between.align-center.mb-3(v-if="hasHeader")
.d-flex.align-center
v-icon.mr-2(
v-if="icon"
:color="iconColor"
size="small"
) {{ icon }}
div
.text-subtitle-1.font-weight-bold.d-flex.align-center.text-white(
style="line-height: 1.2"
) {{ title }}
.text-caption.text-grey(v-if="subtitle") {{ subtitle }}
div.d-flex.align-center
slot(name="header-right")
.widget-content.flex-grow-1.d-flex.flex-column
slot
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
title: { type: String, default: '' },
subtitle: { type: String, default: '' },
icon: { type: String, default: '' },
iconColor: { type: String, default: 'secondary' },
});
// eslint-disable-next-line no-unused-vars
const hasHeader = computed(() => !!props.title || !!props.icon || !!props.subtitle);
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>

View File

@@ -1,32 +1,33 @@
<template lang="pug">
v-card.pa-4.rounded-xl.border-subtle.d-flex.align-center.justify-space-between(color="#1e1e24")
DashboardWidget(
:title="$t('stats.accuracy')"
icon="mdi-bullseye-arrow"
)
.d-flex.align-center.justify-space-between.flex-grow-1
div
.text-subtitle-2.text-grey {{ $t('stats.accuracy') }}
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
.text-caption.text-grey
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }} / {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
.text-caption.text-grey.mt-1
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }}
span.mx-1 /
| {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
v-progress-circular(
:model-value="accuracyPercent"
color="#00cec9"
size="100"
width="10"
color="primary"
size="80"
width="8"
bg-color="grey-darken-3"
)
v-icon(color="#00cec9" size="large") mdi-bullseye-arrow
span.text-caption.font-weight-bold {{ accuracyPercent }}%
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { computed } from 'vue';
import DashboardWidget from './DashboardWidget.vue';
const props = defineProps({
accuracy: {
type: Object,
default: () => ({
correct: 0,
total: 0
})
}
accuracy: { type: Object, default: () => ({ correct: 0, total: 0 }) },
});
const accuracyPercent = computed(() => {

View File

@@ -1,8 +1,8 @@
<template lang="pug">
v-card.pa-5.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
v-card.widget-card.pa-5.d-flex.flex-column.flex-grow-1(flat)
.d-flex.align-center.justify-space-between.mb-4
.text-subtitle-1.font-weight-bold.d-flex.align-center
v-icon(color="#ffeaa7" start size="small") mdi-chart-bar
v-icon(color="secondary" start size="small") mdi-chart-bar
| {{ $t('stats.srsDistribution') }}
v-chip.font-weight-bold(
size="x-small"
@@ -10,7 +10,7 @@
variant="tonal"
) {{ totalItems }}
.srs-chart-container.d-flex.justify-space-between.align-end.px-2.gap-2.flex-grow-1
.srs-chart-container.d-flex.justify-space-between.align-end.px-2.flex-grow-1
.d-flex.flex-column.align-center.flex-grow-1.srs-column(
v-for="lvl in 10"
:key="lvl"
@@ -20,11 +20,13 @@
) {{ getCount(lvl) }}
.srs-track
.srs-fill(:style="{\
.srs-fill(
:class="'bg-srs-' + lvl"
:style="{\
height: getBarHeight(getCount(lvl)) + '%',\
background: getSRSColor(lvl),\
boxShadow: getCount(lvl) > 0 ? `0 0 20px ${getSRSColor(lvl)}30` : 'none'\
}")
'--shadow-color': 'var(--srs-' + lvl + ')'\
}"
)
.text-caption.text-grey-darken-1.font-weight-bold.mt-3(
style="font-size: 10px !important;"
@@ -32,17 +34,16 @@
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { computed } from 'vue';
const props = defineProps({
distribution: {
type: Object,
default: () => ({})
}
distribution: { type: Object, default: () => ({}) },
});
const totalItems = computed(() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0));
const totalItems = computed(
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
);
const getCount = (lvl) => props.distribution?.[lvl] || 0;
const getBarHeight = (count) => {
@@ -51,20 +52,9 @@ const getBarHeight = (count) => {
return (count / max) * 100;
};
const getSRSColor = (lvl) => {
const colors = {
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4',
4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
7: '#00cec9', 8: '#fd79a8', 9: '#e84393',
10: '#ffd700'
};
return colors[lvl] || '#444';
};
const toRoman = (num) => {
const lookup = {
1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V',
6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X'
1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V', 6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X',
};
return lookup[num] || num;
};

View File

@@ -1,18 +1,17 @@
<template lang="pug">
v-card.pa-4.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
.text-subtitle-1.font-weight-bold.mb-3.d-flex.align-center
v-icon(color="#ffeaa7" start) mdi-clock-outline
| {{ $t('stats.next24') }}
DashboardWidget(
:title="$t('stats.next24')"
icon="mdi-clock-outline"
)
.forecast-list.flex-grow-1(v-if="hasUpcoming")
div(v-for="(count, hour) in forecast" :key="hour")
.d-flex.justify-space-between.align-center.mb-2.py-2.border-b-subtle(v-if="count > 0")
span.text-body-2.text-grey-lighten-1
| {{ hour === 0 ? $t('stats.availableNow') : $t('stats.inHours', { n: hour }, hour) }}
v-chip.font-weight-bold(
v-chip.font-weight-bold.text-primary(
size="small"
color="#2f3542"
style="color: #00cec9 !important;"
color="surface-light"
) {{ count }}
.fill-height.d-flex.align-center.justify-center.text-grey.text-center.pa-4(v-else)
@@ -20,22 +19,15 @@
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { computed } from 'vue';
import DashboardWidget from './DashboardWidget.vue';
const props = defineProps({
forecast: {
type: Object,
default: () => ({})
}
forecast: { type: Object, default: () => ({}) },
});
const hasUpcoming = computed(() => {
return props.forecast && props.forecast.some(c => c > 0);
});
const hasUpcoming = computed(() => props.forecast && props.forecast.some((c) => c > 0));
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped>
.border-b-subtle {
border-bottom: 1px solid rgb(255 255 255 / 5%);
}
</style>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>

View File

@@ -1,18 +1,25 @@
<template lang="pug">
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24")
.d-flex.justify-space-between.align-center.mb-3
.d-flex.align-center
v-icon(color="#ff7675" start size="small") mdi-ghost
div
.text-subtitle-2.text-white {{ $t('stats.ghostTitle') }}
.text-caption.text-grey {{ $t('stats.ghostSubtitle') }}
.d-flex.justify-space-between.gap-2(v-if="ghosts && ghosts.length > 0")
.ghost-card.flex-grow-1(v-for="ghost in ghosts" :key="ghost._id")
DashboardWidget(
:title="$t('stats.ghostTitle')"
:subtitle="$t('stats.ghostSubtitle')"
icon="mdi-ghost"
icon-color="var(--srs-1)"
)
.grid-wrapper(style="display: grid; grid-template-columns: minmax(0, 1fr); width: 100%;")
.d-flex.gap-2.overflow-x-auto.pb-2(
v-if="ghosts && ghosts.length > 0"
style="scrollbar-width: thin; max-width: 100%;"
)
.ghost-card(
v-for="ghost in ghosts"
:key="ghost._id"
style="min-width: 80px; flex-shrink: 0;"
)
.text-h6.font-weight-bold.text-white.mb-1 {{ ghost.char }}
v-chip.font-weight-bold.text-black.w-100.justify-center(
v-chip.font-weight-bold.w-100.justify-center(
size="x-small"
color="red-accent-2"
class="bg-srs-1 text-black"
variant="flat"
) {{ ghost.accuracy }}%
@@ -21,11 +28,11 @@
</template>
<script setup>
/* eslint-disable no-unused-vars */
import DashboardWidget from './DashboardWidget.vue';
const props = defineProps({
ghosts: {
type: Array,
default: () => []
}
ghosts: { type: Array, default: () => [] },
});
</script>

View File

@@ -1,11 +1,12 @@
<template lang="pug">
v-card.widget-card.pa-5.rounded-xl.d-flex.flex-column.justify-center(color="#1e1e24" flat)
.d-flex.justify-space-between.align-center.mb-2
.d-flex.align-center
v-icon(color="secondary" start size="small") mdi-trophy-outline
span.text-subtitle-2.font-weight-bold {{ $t('stats.mastery') }}
DashboardWidget(
:title="$t('stats.mastery')"
icon="mdi-trophy-outline"
)
template(#header-right)
.text-subtitle-2.text-white.font-weight-bold {{ masteryPercent }}%
.d-flex.flex-column.justify-center.flex-grow-1
v-progress-linear(
:model-value="masteryPercent"
color="primary"
@@ -20,26 +21,23 @@
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { computed } from 'vue';
import DashboardWidget from './DashboardWidget.vue';
const props = defineProps({
distribution: {
type: Object,
default: () => ({})
}
distribution: { type: Object, default: () => ({}) },
});
const totalItems = computed(() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0));
const masteredCount = computed(() => {
const dist = props.distribution || {};
return (dist[6] || 0);
});
const masteryPercent = computed(() => {
if (totalItems.value === 0) return 0;
return Math.round((masteredCount.value / totalItems.value) * 100);
});
const totalItems = computed(
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
);
const masteredCount = computed(
() => (props.distribution || {})[6] || 0,
);
const masteryPercent = computed(
() => (totalItems.value === 0 ? 0 : Math.round((masteredCount.value / totalItems.value) * 100)),
);
</script>
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>

View File

@@ -1,10 +1,9 @@
<template lang="pug">
v-card.widget-card.pa-4.rounded-xl(color="#1e1e24" flat)
.d-flex.flex-wrap.justify-space-between.align-center.mb-4.gap-2
.text-subtitle-1.font-weight-bold.d-flex.align-center
v-icon(color="secondary" start) mdi-calendar-check
| {{ $t('stats.consistency') }}
DashboardWidget(
:title="$t('stats.consistency')"
icon="mdi-calendar-check"
)
template(#header-right)
.legend-container
span.text-caption.text-medium-emphasis.mr-1 {{ $t('stats.less') }}
.legend-box.level-0
@@ -22,10 +21,7 @@
template(v-slot:activator="{ props }")
.heatmap-cell(
v-bind="props"
:class="[\
isToday(day.date) ? 'today-cell' : '',\
getHeatmapClass(day.count)\
]"
:class="[isToday(day.date) ? 'today-cell' : '', getHeatmapClass(day.count)]"
)
.text-center
.font-weight-bold {{ formatDate(day.date) }}
@@ -33,16 +29,14 @@
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { locale } = useI18n();
import DashboardWidget from './DashboardWidget.vue';
const { locale } = useI18n();
const props = defineProps({
heatmapData: {
type: Object,
default: () => ({})
}
heatmapData: { type: Object, default: () => ({}) },
});
const weeks = computed(() => {
@@ -52,18 +46,17 @@ const weeks = computed(() => {
const startDate = new Date(today);
startDate.setDate(today.getDate() - (52 * 7));
const dayOfWeek = startDate.getDay();
startDate.setDate(startDate.getDate() - dayOfWeek);
startDate.setDate(startDate.getDate() - startDate.getDay());
let currentWeek = [];
for (let i = 0; i < 371; i++) {
for (let i = 0; i < 371; i += 1) {
const d = new Date(startDate);
d.setDate(startDate.getDate() + i);
const dateStr = d.toISOString().split('T')[0];
currentWeek.push({
date: dateStr,
count: data[dateStr] || 0
count: data[dateStr] || 0,
});
if (currentWeek.length === 7) {

View File

@@ -1,54 +1,57 @@
<template lang="pug">
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24")
.d-flex.justify-space-between.align-start.mb-2
div
.text-subtitle-2.text-grey {{ $t('stats.streakTitle') }}
.d-flex.align-center
.text-h3.font-weight-bold.text-white.mr-2 {{ streak?.current || 0 }}
.text-h6.text-grey {{ $t('stats.days') }}
.text-center
v-tooltip(
location="start"
:text="streak?.shield?.ready ? $t('stats.shieldActive') : $t('stats.shieldCooldown', { n: streak?.shield?.cooldown })"
DashboardWidget(
:title="$t('stats.streakTitle')"
icon="mdi-fire"
icon-color="var(--srs-1)"
)
template(#header-right)
v-tooltip(location="start" :text="shieldTooltip")
template(v-slot:activator="{ props }")
v-avatar(
v-avatar.streak-shield-avatar(
v-bind="props"
size="48"
:color="streak?.shield?.ready ? 'rgba(0, 206, 201, 0.1)' : 'rgba(255, 255, 255, 0.05)'"
style="border: 1px solid;"
:style="{ borderColor: streak?.shield?.ready ? '#00cec9' : '#555' }"
size="32"
:class="streak?.shield?.ready ? 'text-primary' : 'text-grey'"
)
v-icon(:color="streak?.shield?.ready ? '#00cec9' : 'grey'")
v-icon(size="small")
| {{ streak?.shield?.ready ? 'mdi-shield-check' : 'mdi-shield-off-outline' }}
.d-flex.justify-space-between.align-center.mt-2.px-1
.d-flex.flex-column.justify-space-between.flex-grow-1
.d-flex.align-end.mb-3
.text-h3.font-weight-bold.text-white.mr-2(style="line-height: 1")
| {{ streak?.current || 0 }}
.text-body-1.text-grey.mb-1 {{ $t('stats.days') }}
.d-flex.justify-space-between.align-center.px-1
.d-flex.flex-column.align-center(
v-for="(day, idx) in (streak?.history || [])"
:key="idx"
)
.streak-dot.mb-1(:class="{ 'active': day.active }")
v-icon(v-if="day.active" size="12" color="black") mdi-check
.text-grey.text-uppercase(style="font-size: 10px;")
.text-grey.text-uppercase.streak-day-label
| {{ getDayLabel(day.date) }}
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import DashboardWidget from './DashboardWidget.vue';
const props = defineProps({
streak: {
type: Object,
required: true,
default: () => ({
current: 0,
history: [],
shield: { ready: false, cooldown: 0 }
})
}
default: () => ({ current: 0, history: [], shield: { ready: false, cooldown: 0 } }),
},
});
const { t, locale } = useI18n();
const shieldTooltip = computed(() => {
const shield = props.streak?.shield;
if (shield?.ready) return t('stats.shieldActive');
return t('stats.shieldCooldown', { n: shield?.cooldown || 0 });
});
const { locale } = useI18n();
const getDayLabel = (dateStr) => {
if (!dateStr) return '';

View File

@@ -3,11 +3,24 @@
.text-h2.font-weight-bold.mb-2 {{ $t('hero.welcome') }}
.text-h5.text-grey.mb-8 {{ $t('hero.subtitle') }}
.d-flex.justify-center.align-center.flex-column
v-btn.text-h5.font-weight-bold.text-black.glow-btn(
.d-flex.justify-center.align-center.flex-column.gap-4
v-btn.text-h5.font-weight-bold.text-black.glow-btn.welcome-btn(
v-if="lessonCount > 0"
to="/lesson"
rounded="xl"
color="purple-accent-2"
class="mb-3"
)
v-icon(size="32" start) mdi-school
| {{ $t('hero.lessons') }}
v-chip.ml-3.font-weight-bold(
color="#1e1e24"
variant="flat"
size="default"
style="color: white !important;"
) {{ lessonCount }}
v-btn.text-h5.font-weight-bold.text-black.glow-btn.welcome-btn(
@click="$emit('start', 'shuffled')"
height="80"
width="280"
rounded="xl"
color="#00cec9"
:disabled="queueLength === 0"
@@ -38,39 +51,25 @@
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
queueLength: {
type: Number,
required: true
},
hasLowerLevels: {
type: Boolean,
default: false
},
lowerLevelCount: {
type: Number,
default: 0
},
forecast: {
type: Object,
default: () => ({})
}
queueLength: { type: Number, required: true },
lessonCount: { type: Number, default: 0 },
hasLowerLevels: { type: Boolean, default: false },
lowerLevelCount: { type: Number, default: 0 },
forecast: { type: Object, default: () => ({}) },
});
defineEmits(['start']);
const { t } = useI18n();
const nextReviewTime = computed(() => {
if (!props.forecast) return "a while";
const idx = props.forecast.findIndex(c => c > 0);
if (idx === -1) return "a while";
if (!props.forecast) return 'a while';
const idx = props.forecast.findIndex((c) => c > 0);
if (idx === -1) return 'a while';
return idx === 0 ? t('hero.now') : t('stats.inHours', { n: idx }, idx);
});
</script>
<style lang="scss" src="@/styles/components/_buttons.scss" scoped></style>

View File

@@ -1,247 +1,140 @@
<template lang="pug">
.canvas-container
.loading-text(v-if="loading") {{ $t('review.loading') }}
.canvas-wrapper(ref="wrapper")
canvas(
ref="bgCanvas"
:width="CANVAS_SIZE"
:height="CANVAS_SIZE"
.canvas-container(
style="touch-action: none; user-select: none; -webkit-user-select: none; overscroll-behavior: none;"
)
canvas(
ref="snapCanvas"
:width="CANVAS_SIZE"
:height="CANVAS_SIZE"
.canvas-wrapper(
ref="wrapper"
:class="{ 'shake': isShaking }"
:style="{ width: size + 'px', height: size + 'px', touchAction: 'none', userSelect: 'none', overscrollBehavior: 'none' }"
)
canvas(ref="bgCanvas")
canvas(ref="hintCanvas")
canvas(
ref="drawCanvas"
:width="CANVAS_SIZE"
:height="CANVAS_SIZE"
@mousedown="startDraw"
@mousemove="draw"
@mouseup="endDraw"
@mouseleave="endDraw"
@touchstart.prevent="startDraw"
@touchmove.prevent="draw"
@touchend.prevent="endDraw"
style="touch-action: none; user-select: none; -webkit-user-select: none; overscroll-behavior: none;"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
@pointerleave="handlePointerUp"
@pointercancel="handlePointerUp"
@touchstart.prevent.stop
@touchmove.prevent.stop
@touchend.prevent.stop
@touchcancel.prevent.stop
@contextmenu.prevent
)
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { useAppStore } from '@/stores/appStore';
/* eslint-disable no-unused-vars */
import {
ref, onMounted, watch, onBeforeUnmount,
} from 'vue';
import { useAppStore } from '@/stores/appStore';
import { KanjiController } from '@/utils/KanjiController';
const props = defineProps({
char: {
type: String,
required: true
}
});
const emit = defineEmits(['complete', 'mistake']);
const store = useAppStore();
const KANJI_SIZE = 109;
const CANVAS_SIZE = 300;
const SCALE = CANVAS_SIZE / KANJI_SIZE;
const props = defineProps({
char: String,
autoHint: Boolean,
size: { type: Number, default: 300 },
});
const emit = defineEmits(['complete', 'mistake']);
const wrapper = ref(null);
const bgCanvas = ref(null);
const snapCanvas = ref(null);
const hintCanvas = ref(null);
const drawCanvas = ref(null);
let ctxBg, ctxSnap, ctxDraw;
const isShaking = ref(false);
const kanjiPaths = ref([]);
const currentStrokeIndex = ref(0);
const failureCount = ref(0);
const loading = ref(false);
let isDrawing = false;
let userPath = [];
let controller = null;
onMounted(() => {
initContexts();
if (props.char) loadKanji(props.char);
});
watch(() => props.char, (newChar) => {
if (newChar) loadKanji(newChar);
});
function initContexts() {
ctxBg = bgCanvas.value.getContext('2d');
ctxSnap = snapCanvas.value.getContext('2d');
ctxDraw = drawCanvas.value.getContext('2d');
[ctxBg, ctxSnap, ctxDraw].forEach(ctx => {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(SCALE, SCALE);
ctx.lineCap = "round";
ctx.lineJoin = "round";
});
}
async function loadKanji(char) {
reset();
loading.value = true;
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
try {
const res = await fetch(`https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${hex}.svg`);
const txt = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(txt, "image/svg+xml");
kanjiPaths.value = Array.from(doc.getElementsByTagName("path")).map(p => p.getAttribute("d"));
drawGuide();
} catch (e) {
console.error("Failed to load KanjiVG data", e);
} finally {
loading.value = false;
}
}
function reset() {
currentStrokeIndex.value = 0;
failureCount.value = 0;
kanjiPaths.value = [];
[ctxBg, ctxSnap, ctxDraw].forEach(ctx => {
ctx.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
});
}
function getCoords(e) {
function getPoint(e) {
if (!drawCanvas.value) return { x: 0, y: 0 };
const rect = drawCanvas.value.getBoundingClientRect();
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
return {
x: (cx - rect.left) / SCALE,
y: (cy - rect.top) / SCALE
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
function startDraw(e) {
if (currentStrokeIndex.value >= kanjiPaths.value.length) return;
isDrawing = true;
userPath = [];
function handlePointerDown(e) {
if (!controller) return;
if (e.cancelable) e.preventDefault();
const p = getCoords(e);
userPath.push(p);
e.target.setPointerCapture(e.pointerId);
ctxDraw.beginPath();
ctxDraw.moveTo(p.x, p.y);
ctxDraw.strokeStyle = '#ff7675';
ctxDraw.lineWidth = 4;
controller.startStroke(getPoint(e));
}
function draw(e) {
if (!isDrawing) return;
const p = getCoords(e);
userPath.push(p);
ctxDraw.lineTo(p.x, p.y);
ctxDraw.stroke();
function handlePointerMove(e) {
if (!controller) return;
if (e.cancelable) e.preventDefault();
controller.moveStroke(getPoint(e));
}
function endDraw() {
if (!isDrawing) return;
isDrawing = false;
function handlePointerUp(e) {
if (!controller) return;
if (e.cancelable) e.preventDefault();
const targetD = kanjiPaths.value[currentStrokeIndex.value];
e.target.releasePointerCapture(e.pointerId);
if (checkMatch(userPath, targetD)) {
ctxSnap.strokeStyle = '#00cec9';
ctxSnap.lineWidth = 4;
ctxSnap.stroke(new Path2D(targetD));
controller.endStroke();
}
currentStrokeIndex.value++;
failureCount.value = 0;
ctxDraw.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
onMounted(() => {
controller = new KanjiController({
size: props.size,
accuracy: store.drawingAccuracy,
onComplete: () => emit('complete'),
onMistake: (needsHint) => {
isShaking.value = true;
setTimeout(() => { isShaking.value = false; }, 400);
emit('mistake', needsHint);
},
});
if (currentStrokeIndex.value >= kanjiPaths.value.length) {
emit('complete');
} else {
drawGuide();
if (bgCanvas.value && hintCanvas.value && drawCanvas.value) {
controller.mount({
bg: bgCanvas.value,
hint: hintCanvas.value,
draw: drawCanvas.value,
});
}
} else {
failureCount.value++;
ctxDraw.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
if (failureCount.value >= 3) {
drawGuide(true);
emit('mistake', true);
} else {
emit('mistake', false);
if (props.char) {
controller.loadChar(props.char, props.autoHint);
}
});
onBeforeUnmount(() => {
controller = null;
});
watch(() => props.char, (newChar) => {
if (controller && newChar) {
controller.loadChar(newChar, props.autoHint);
}
}
});
function checkMatch(userPts, targetD) {
if (userPts.length < 5) return false;
watch(() => props.autoHint, (shouldHint) => {
if (!controller) return;
if (shouldHint) controller.showHint();
});
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
tempPath.setAttribute("d", targetD);
const len = tempPath.getTotalLength();
const targetEnd = tempPath.getPointAtLength(len);
const userEnd = userPts[userPts.length - 1];
watch(() => props.size, (newSize) => {
if (controller) controller.resize(newSize);
});
const threshold = store.drawingAccuracy || 10;
watch(() => store.drawingAccuracy, (newVal) => {
if (controller) controller.setAccuracy(newVal);
});
const dist = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y);
if (dist(userEnd, targetEnd) > threshold * 3.0) return false;
let totalError = 0;
const samples = 10;
for (let i = 0; i <= samples; i++) {
const pt = tempPath.getPointAtLength((i / samples) * len);
let min = Infinity;
for (let p of userPts) min = Math.min(min, dist(pt, p));
totalError += min;
}
return (totalError / (samples + 1)) < threshold;
}
function drawGuide(showHint = false) {
ctxBg.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
if (!showHint) return;
const d = kanjiPaths.value[currentStrokeIndex.value];
if (!d) return;
ctxBg.strokeStyle = '#57606f';
ctxBg.lineWidth = 3;
ctxBg.setLineDash([5, 5]);
ctxBg.stroke(new Path2D(d));
ctxBg.setLineDash([]);
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
tempPath.setAttribute("d", d);
const len = tempPath.getTotalLength();
const mid = tempPath.getPointAtLength(len / 2);
const prev = tempPath.getPointAtLength(Math.max(0, (len / 2) - 1));
const angle = Math.atan2(mid.y - prev.y, mid.x - prev.x);
ctxBg.save();
ctxBg.translate(mid.x, mid.y);
ctxBg.rotate(angle);
ctxBg.strokeStyle = 'rgba(255, 234, 167, 0.7)';
ctxBg.lineWidth = 2;
ctxBg.lineCap = 'round';
ctxBg.lineJoin = 'round';
ctxBg.beginPath();
ctxBg.moveTo(-7, 0);
ctxBg.lineTo(2, 0);
ctxBg.moveTo(-1, -3);
ctxBg.lineTo(2, 0);
ctxBg.lineTo(-1, 3);
ctxBg.stroke();
ctxBg.restore();
}
defineExpose({ drawGuide });
defineExpose({
reset: () => controller?.reset(),
showHint: () => controller?.showHint(),
drawGuide: () => controller?.showHint(),
});
</script>
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>

View File

@@ -1,120 +1,127 @@
<template lang="pug">
.svg-container(:class="{ loading: loading }")
svg.kanji-svg(
v-if="!loading"
viewBox="0 0 109 109"
width="100%"
height="100%"
.svg-container(
:class="{ 'canvas-mode': mode === 'animate', 'hero-mode': mode === 'hero' }"
)
svg.kanji-svg(
v-show="!loading"
viewBox="0 0 109 109"
)
g(v-if="mode === 'animate'")
path.stroke-ghost(
v-for="(stroke, i) in strokes"
:key="'ghost-'+i"
:d="stroke.d"
)
g(v-for="(stroke, i) in strokes" :key="i")
path.stroke-path(
:d="stroke.d"
:class="{\
'animating': isPlaying && currentStrokeIdx === i,\
'hidden': isPlaying && currentStrokeIdx < i,\
'drawn': isPlaying && currentStrokeIdx > i\
}"
:style="{\
'--len': stroke.len,\
'--duration': (stroke.len * 0.02) + 's'\
}"
:class="getStrokeClass(i)"
:style="getStrokeStyle(stroke)"
)
g(v-show="!isPlaying || currentStrokeIdx > i")
circle.stroke-start-circle(
v-if="stroke.start"
:cx="stroke.start.x"
:cy="stroke.start.y"
r="3.5"
g(v-if="mode === 'animate' && (!isPlaying || currentStrokeIdx > -1)")
g(v-for="(stroke, i) in strokes" :key="'anno-'+i")
g(v-show="!isPlaying || currentStrokeIdx >= i")
path.stroke-arrow-line(
v-if="isPlaying && currentStrokeIdx === i && stroke.arrow"
d="M -7 0 L 2 0 M -1 -3 L 2 0 L -1 3"
:transform="getArrowTransform(stroke.arrow)"
)
text.stroke-number(
g.stroke-badge-group(
v-if="stroke.start"
:x="stroke.start.x"
:y="stroke.start.y + 0.5"
:transform="`translate(${stroke.start.x}, ${stroke.start.y})`"
)
circle.stroke-badge-bg(r="4")
text.stroke-badge-text(
dy="0.5"
) {{ i + 1 }}
path.stroke-arrow-line(
v-if="stroke.arrow"
d="M -7 0 L 2 0 M -1 -3 L 2 0 L -1 3"
:transform="`translate(${stroke.arrow.x}, ${stroke.arrow.y}) rotate(${stroke.arrow.angle})`"
)
.loading-spinner(v-else) {{ $t('review.loading') }}
button.play-btn(
v-if="!loading && !isPlaying"
@click="playAnimation"
v-if="mode === 'animate' && !loading && !isPlaying"
@click.stop="playAnimation"
)
svg(viewBox="0 0 24 24" fill="currentColor")
path(d="M8 5v14l11-7z")
path(d="M8,5.14V19.14L19,12.14L8,5.14Z")
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { ref, watch, onMounted } from 'vue';
const props = defineProps({
char: {
char: { type: String, required: true },
mode: {
type: String,
required: true
}
default: 'animate',
validator: (v) => ['hero', 'animate'].includes(v),
},
});
const strokes = ref([]);
const loading = ref(true);
const isPlaying = ref(false);
const currentStrokeIdx = ref(-1);
onMounted(() => {
if (props.char) loadData(props.char);
});
watch(() => props.char, (newChar) => {
if (newChar) loadData(newChar);
});
async function loadData(char) {
loading.value = true;
isPlaying.value = false;
strokes.value = [];
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
try {
const res = await fetch(`https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${hex}.svg`);
const baseUrl = 'https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji';
const res = await fetch(`${baseUrl}/${hex}.svg`);
const txt = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(txt, "image/svg+xml");
const doc = parser.parseFromString(txt, 'image/svg+xml');
const rawPaths = Array.from(doc.getElementsByTagName('path')).map((p) => p.getAttribute('d'));
const rawPaths = Array.from(doc.getElementsByTagName("path")).map(p => p.getAttribute("d"));
strokes.value = rawPaths.map(d => {
const data = { d, start: null, arrow: null, len: 0 };
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
tempPath.setAttribute("d", d);
try {
data.len = tempPath.getTotalLength();
} catch (e) { data.len = 100; }
strokes.value = rawPaths.map((d) => {
const data = {
d, start: null, arrow: null, len: 0, duration: 0,
};
const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
tempPath.setAttribute('d', d);
try { data.len = tempPath.getTotalLength(); } catch (e) { data.len = 100; }
data.duration = Math.floor(data.len * 20);
const startMatch = d.match(/[Mm]\s*([\d.]+)[,\s]([\d.]+)/);
if (startMatch) {
data.start = { x: parseFloat(startMatch[1]), y: parseFloat(startMatch[2]) };
}
if (startMatch) data.start = { x: parseFloat(startMatch[1]), y: parseFloat(startMatch[2]) };
try {
const mid = tempPath.getPointAtLength(data.len / 2);
const prev = tempPath.getPointAtLength(Math.max(0, (data.len / 2) - 1));
const angle = Math.atan2(mid.y - prev.y, mid.x - prev.x) * (180 / Math.PI);
data.arrow = { x: mid.x, y: mid.y, angle };
} catch (e) { console.error(e) }
} catch (e) { console.error(e); }
return data;
});
} catch (e) { console.error(e); } finally { loading.value = false; }
}
} catch (e) {
console.error("SVG Load Failed", e);
} finally {
loading.value = false;
function getArrowTransform(arrow) {
if (!arrow) return '';
return `translate(${arrow.x}, ${arrow.y}) rotate(${arrow.angle})`;
}
function getStrokeClass(i) {
if (props.mode === 'hero') return 'drawn';
if (isPlaying.value) {
if (currentStrokeIdx.value === i) return 'animating';
if (currentStrokeIdx.value > i) return 'drawn';
return 'hidden';
}
return 'drawn';
}
function getStrokeStyle(stroke) {
if (props.mode === 'hero') return {};
return { '--len': stroke.len, '--duration': `${stroke.duration}ms` };
}
async function playAnimation() {
@@ -122,17 +129,23 @@ async function playAnimation() {
isPlaying.value = true;
currentStrokeIdx.value = -1;
await new Promise(r => setTimeout(r, 200));
await new Promise((r) => { setTimeout(r, 200); });
for (let i = 0; i < strokes.value.length; i++) {
for (let i = 0; i < strokes.value.length; i += 1) {
currentStrokeIdx.value = i;
const duration = strokes.value[i].len * 20;
await new Promise(r => setTimeout(r, duration + 100));
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => { setTimeout(r, strokes.value[i].duration); });
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => { setTimeout(r, 100); });
}
await new Promise(r => setTimeout(r, 500));
await new Promise((r) => { setTimeout(r, 500); });
isPlaying.value = false;
}
defineExpose({ playAnimation });
onMounted(() => { if (props.char) loadData(props.char); });
watch(() => props.char, (n) => { if (n) loadData(n); });
</script>
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>

View File

@@ -1,24 +1,25 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import i18n from '@/plugins/i18n'
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import { createRouter, createWebHistory } from 'vue-router';
import '@/styles/main.scss'
import '@/styles/main.scss';
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import { aliases, mdi } from 'vuetify/iconsets/mdi';
import i18n from '@/plugins/i18n';
import '@mdi/font/css/materialdesignicons.css';
import App from './App.vue'
import Dashboard from './views/Dashboard.vue'
import Collection from './views/Collection.vue'
import Review from './views/Review.vue'
import App from './App.vue';
import Dashboard from './views/Dashboard.vue';
import Collection from './views/Collection.vue';
import Review from './views/Review.vue';
import Lesson from './views/Lesson.vue';
const app = createApp(App)
const pinia = createPinia()
const app = createApp(App);
const pinia = createPinia();
const router = createRouter({
history: createWebHistory(),
@@ -26,9 +27,10 @@ const router = createRouter({
{ path: '/', component: Dashboard },
{ path: '/dashboard', component: Dashboard },
{ path: '/collection', component: Collection },
{ path: '/review', component: Review }
]
})
{ path: '/review', component: Review },
{ path: '/lesson', component: Lesson },
],
});
const vuetify = createVuetify({
components,
@@ -36,14 +38,21 @@ const vuetify = createVuetify({
theme: {
defaultTheme: 'dark',
themes: {
dark: { colors: { primary: '#00cec9', secondary: '#ffeaa7' } }
}
dark: {
colors: {
primary: '#00cec9',
secondary: '#ffeaa7',
surface: '#1e1e24',
background: '#121212',
},
icons: { defaultSet: 'mdi', aliases, sets: { mdi } }
})
},
},
},
icons: { defaultSet: 'mdi', aliases, sets: { mdi } },
});
app.use(pinia)
app.use(router)
app.use(vuetify)
app.use(i18n)
app.mount('#app')
app.use(pinia);
app.use(router);
app.use(vuetify);
app.use(i18n);
app.mount('#app');

View File

@@ -1,306 +1,396 @@
import { createI18n } from 'vue-i18n'
import { createI18n } from 'vue-i18n';
const messages = {
en: {
common: {
close: "Close",
cancel: "Cancel"
close: 'Close',
cancel: 'Cancel',
},
nav: {
dashboard: "Dashboard",
review: "Review",
collection: "Collection",
settings: "Settings",
sync: "Sync",
logout: "Logout",
menu: "Menu"
dashboard: 'Dashboard',
review: 'Review',
collection: 'Collection',
settings: 'Settings',
sync: 'Sync',
logout: 'Logout',
menu: 'Menu',
},
login: {
instruction: "Enter your WaniKani V2 API Key to login",
placeholder: "Paste key here...",
button: "Login",
failed: "Login failed. Is server running?"
instruction: 'Enter your WaniKani V2 API Key to login',
placeholder: 'Paste key here...',
button: 'Login',
failed: 'Login failed. Is server running?',
},
alerts: {
syncSuccess: "Sync complete! Collection: {count}",
syncFailed: "Sync failed.",
logoutConfirm: "Are you sure you want to log out? This will end your session.",
syncSuccess: 'Sync complete! Collection: {count}',
syncFailed: 'Sync failed.',
logoutConfirm: 'Are you sure you want to log out? This will end your session.',
},
lesson: {
phasePrimer: 'Study',
phaseDemo: 'Observation',
phaseGuided: 'Guided',
phasePractice: 'Recall',
understand: 'I Understand',
ready: 'Ready to Draw',
startPractice: 'Start Practice',
continue: 'Continue',
streak: 'Correct: {n} / {total}',
hint: 'Show Hint (Resets Streak)',
hintAction: 'Hint',
watchAgain: 'Watch Again',
completeTitle: 'Lesson Complete!',
completeBody: 'You have unlocked new Kanji for review.',
learned: "You've learned {n} new kanji.",
components: 'Components',
observe: 'Observe Stroke Order',
trace: 'Trace',
drawStep: 'Draw ({n}/{total})',
backToDashboard: 'Back to Dashboard',
},
hero: {
welcome: "Welcome Back",
subtitle: "Your mind is ready.",
start: "Start Review",
noReviews: "No Reviews",
nextIn: "Next review in",
now: "now",
prioritize: "Prioritize Lower Levels ({count})"
lessons: 'Lessons',
welcome: 'Welcome Back',
subtitle: 'Your mind is ready.',
start: 'Start Review',
noReviews: 'No Reviews',
nextIn: 'Next review in',
now: 'now',
prioritize: 'Prioritize Lower Levels ({count})',
},
stats: {
mastery: "Mastery (Guru+)",
srsDistribution: "SRS Levels",
accuracy: "Global Accuracy",
correct: "Correct",
total: "Total",
next24: "Next 24h",
availableNow: "Available Now",
inHours: "In {n} hour | In {n} hours",
noIncoming: "No reviews incoming for 24 hours.",
items: "items",
reviewsCount: "{count} reviews",
mastery: 'Mastery (Guru+)',
srsDistribution: 'SRS Levels',
accuracy: 'Global Accuracy',
correct: 'Correct',
total: 'Total',
next24: 'Next 24h',
availableNow: 'Available Now',
inHours: 'In {n} hour | In {n} hours',
noIncoming: 'No reviews incoming for 24 hours.',
items: 'items',
reviewsCount: '{count} reviews',
consistency: "Study Consistency",
less: "Less",
more: "More",
consistency: 'Study Consistency',
less: 'Less',
more: 'More',
streakTitle: "Study Streak",
days: "days",
shieldActive: "Zen Shield Active: Protects streak if you miss 1 day.",
shieldCooldown: "Regenerating: {n} days left",
streakTitle: 'Study Streak',
days: 'days',
shieldActive: 'Zen Shield Active: Protects streak if you miss 1 day.',
shieldCooldown: 'Regenerating: {n} days left',
ghostTitle: "Ghost Items",
ghostSubtitle: "Lowest Accuracy",
noGhosts: "No ghosts found! Keep it up."
ghostTitle: 'Ghost Items',
ghostSubtitle: 'Lowest Accuracy',
noGhosts: 'No ghosts found! Keep it up.',
},
settings: {
title: "Settings",
batchSize: "Review Batch Size",
items: "Items",
language: "Language",
save: "Save & Close"
title: 'Settings',
batchSize: 'Review Batch Size',
items: 'Items',
language: 'Language',
drawingTolerance: 'Drawing Tolerance',
strict: 'Strict',
loose: 'Loose',
save: 'Save & Close',
},
review: {
meaning: "Meaning",
level: "Level",
draw: "Draw correctly",
hint: "Hint Shown",
tryAgain: "Try again",
correct: "Correct!",
next: "NEXT",
sessionComplete: "Session Complete!",
levelup: "You leveled up your Kanji skills.",
back: "Back to Collection",
caughtUp: "All Caught Up!",
noReviews: "No reviews available right now.",
viewCollection: "View Collection",
queue: "Session queue:",
loading: "Loading Kanji...",
meaning: 'Meaning',
level: 'Level',
draw: 'Draw correctly',
hint: 'Hint Shown',
showHint: 'Show Hint',
redoLesson: 'Redo Lesson',
tryAgain: 'Try again',
correct: 'Correct!',
next: 'NEXT',
sessionComplete: 'Session Complete!',
levelup: 'You leveled up your Kanji skills.',
back: 'Back to Collection',
caughtUp: 'All Caught Up!',
noReviews: 'No reviews available right now.',
viewCollection: 'View Collection',
queue: 'Session queue:',
loading: 'Loading Kanji...',
},
collection: {
searchLabel: "Search Kanji, Meaning, or Reading...",
searchLabel: 'Search Kanji, Meaning, or Reading...',
placeholder: "e.g. 'water', 'mizu', '水'",
loading: "Loading Collection...",
noMatches: "No matches found",
tryDifferent: "Try searching for a different meaning or reading.",
levelHeader: "LEVEL",
loading: 'Loading Collection...',
noMatches: 'No matches found',
tryDifferent: 'Try searching for a different meaning or reading.',
levelHeader: 'LEVEL',
onyomi: "On'yomi",
kunyomi: "Kun'yomi",
nanori: "Nanori",
close: "Close"
}
nanori: 'Nanori',
close: 'Close',
startLesson: 'Start Lesson',
redoLesson: 'Redo Lesson',
},
},
de: {
common: {
close: "Schließen",
cancel: "Abbrechen"
close: 'Schließen',
cancel: 'Abbrechen',
},
nav: {
dashboard: "Übersicht",
review: "Lernen",
collection: "Sammlung",
settings: "Einstellungen",
sync: "Sync",
logout: "Abmelden",
menu: "Menü"
dashboard: 'Übersicht',
review: 'Lernen',
collection: 'Sammlung',
settings: 'Einstellungen',
sync: 'Sync',
logout: 'Abmelden',
menu: 'Menü',
},
login: {
instruction: "Gib deinen WaniKani V2 API Key ein",
placeholder: "Key hier einfügen...",
button: "Anmelden",
failed: "Login fehlgeschlagen. Läuft der Server?"
instruction: 'Gib deinen WaniKani V2 API Key ein',
placeholder: 'Key hier einfügen...',
button: 'Anmelden',
failed: 'Login fehlgeschlagen. Läuft der Server?',
},
alerts: {
syncSuccess: "Sync fertig! Sammlung: {count}",
syncFailed: "Sync fehlgeschlagen.",
logoutConfirm: "Möchtest du dich wirklich abmelden? Deine Sitzung wird beendet.",
syncSuccess: 'Sync fertig! Sammlung: {count}',
syncFailed: 'Sync fehlgeschlagen.',
logoutConfirm: 'Möchtest du dich wirklich abmelden? Deine Sitzung wird beendet.',
},
lesson: {
phasePrimer: 'Lernen',
phaseDemo: 'Beobachtung',
phaseGuided: 'Geführt',
phasePractice: 'Abruf',
understand: 'Verstanden',
ready: 'Bereit zum Zeichnen',
startPractice: 'Üben Starten',
continue: 'Weiter',
streak: 'Richtig: {n} / {total}',
hint: 'Hinweis (Setzt Serie zurück)',
hintAction: 'Hinweis',
watchAgain: 'Nochmal ansehen',
completeTitle: 'Lektion Fertig!',
completeBody: 'Neue Kanji für Reviews freigeschaltet.',
learned: 'Du hast {n} neue Kanji gelernt.',
components: 'Komponenten',
observe: 'Strichfolge beobachten',
trace: 'Nachzeichnen',
drawStep: 'Zeichnen ({n}/{total})',
backToDashboard: 'Zurück zur Übersicht',
},
hero: {
welcome: "Willkommen zurück",
subtitle: "Dein Geist ist bereit.",
start: "Starten",
noReviews: "Alles erledigt",
nextIn: "Nächste Review in",
now: "jetzt",
prioritize: "Niedrige Stufen zuerst ({count})"
lessons: 'Lektionen',
welcome: 'Willkommen zurück',
subtitle: 'Dein Geist ist bereit.',
start: 'Starten',
noReviews: 'Alles erledigt',
nextIn: 'Nächste Review in',
now: 'jetzt',
prioritize: 'Niedrige Stufen zuerst ({count})',
},
stats: {
mastery: "Meisterschaft (Guru+)",
srsDistribution: "SRS Verteilung",
accuracy: "Genauigkeit",
correct: "Richtig",
total: "Gesamt",
next24: "Nächste 24h",
availableNow: "Jetzt verfügbar",
inHours: "In {n} Stunde | In {n} Stunden",
noIncoming: "Keine Reviews in den nächsten 24h.",
items: "Einträge",
reviewsCount: "{count} Reviews",
mastery: 'Meisterschaft (Guru+)',
srsDistribution: 'SRS Verteilung',
accuracy: 'Genauigkeit',
correct: 'Richtig',
total: 'Gesamt',
next24: 'Nächste 24h',
availableNow: 'Jetzt verfügbar',
inHours: 'In {n} Stunde | In {n} Stunden',
noIncoming: 'Keine Reviews in den nächsten 24h.',
items: 'Einträge',
reviewsCount: '{count} Reviews',
consistency: "Lern-Konstanz",
less: "Weniger",
more: "Mehr",
consistency: 'Lern-Konstanz',
less: 'Weniger',
more: 'Mehr',
streakTitle: "Lern-Serie",
days: "Tage",
shieldActive: "Zen-Schild Aktiv: Schützt dich bei einem verpassten Tag.",
shieldCooldown: "Regeneriert: noch {n} Tage",
streakTitle: 'Lern-Serie',
days: 'Tage',
shieldActive: 'Zen-Schild Aktiv: Schützt dich bei einem verpassten Tag.',
shieldCooldown: 'Regeneriert: noch {n} Tage',
ghostTitle: "Geister-Items",
ghostSubtitle: "Niedrigste Genauigkeit",
noGhosts: "Keine Geister gefunden! Weiter so."
ghostTitle: 'Geister-Items',
ghostSubtitle: 'Niedrigste Genauigkeit',
noGhosts: 'Keine Geister gefunden! Weiter so.',
},
settings: {
title: "Einstellungen",
batchSize: "Anzahl pro Sitzung",
items: "Einträge",
language: "Sprache",
save: "Speichern & Schließen"
title: 'Einstellungen',
batchSize: 'Anzahl pro Sitzung',
items: 'Einträge',
language: 'Sprache',
drawingTolerance: 'Zeichentoleranz',
strict: 'Strikt',
loose: 'Locker',
save: 'Speichern & Schließen',
},
review: {
meaning: "Bedeutung",
level: "Stufe",
draw: "Zeichne das Kanji",
hint: "Hinweis angezeigt",
tryAgain: "Nochmal versuchen",
correct: "Richtig!",
next: "WEITER",
sessionComplete: "Sitzung beendet!",
levelup: "Du hast deine Kanji-Skills verbessert.",
back: "Zurück zur Sammlung",
caughtUp: "Alles erledigt!",
noReviews: "Gerade keine Reviews verfügbar.",
viewCollection: "Zur Sammlung",
queue: "Verbleibend:",
loading: "Lade Kanji...",
meaning: 'Bedeutung',
level: 'Stufe',
draw: 'Zeichne das Kanji',
hint: 'Hinweis angezeigt',
showHint: 'Hinweis zeigen',
redoLesson: 'Lektion wiederholen',
tryAgain: 'Nochmal versuchen',
correct: 'Richtig!',
next: 'WEITER',
sessionComplete: 'Sitzung beendet!',
levelup: 'Du hast deine Kanji-Skills verbessert.',
back: 'Zurück zur Sammlung',
caughtUp: 'Alles erledigt!',
noReviews: 'Gerade keine Reviews verfügbar.',
viewCollection: 'Zur Sammlung',
queue: 'Verbleibend:',
loading: 'Lade Kanji...',
},
collection: {
searchLabel: "Suche Kanji, Bedeutung oder Lesung...",
searchLabel: 'Suche Kanji, Bedeutung oder Lesung...',
placeholder: "z.B. 'Wasser', 'mizu'",
loading: "Lade Sammlung...",
noMatches: "Keine Treffer",
tryDifferent: "Versuche einen anderen Suchbegriff.",
levelHeader: "STUFE",
loading: 'Lade Sammlung...',
noMatches: 'Keine Treffer',
tryDifferent: 'Versuche einen anderen Suchbegriff.',
levelHeader: 'STUFE',
onyomi: "On'yomi",
kunyomi: "Kun'yomi",
nanori: "Nanori",
close: "Schließen"
}
nanori: 'Nanori',
close: 'Schließen',
startLesson: 'Lektion starten',
redoLesson: 'Lektion wiederholen',
},
},
ja: {
common: {
close: "閉じる",
cancel: "キャンセル"
close: '閉じる',
cancel: 'キャンセル',
},
nav: {
dashboard: "ダッシュボード",
review: "復習",
collection: "コレクション",
settings: "設定",
sync: "同期",
logout: "ログアウト",
menu: "メニュー"
dashboard: 'ダッシュボード',
review: '復習',
collection: 'コレクション',
settings: '設定',
sync: '同期',
logout: 'ログアウト',
menu: 'メニュー',
},
login: {
instruction: "WaniKani V2 APIキーを入力してください",
placeholder: "キーを貼り付け...",
button: "ログイン",
failed: "ログイン失敗。サーバーは起動していますか?"
instruction: 'WaniKani V2 APIキーを入力してください',
placeholder: 'キーを貼り付け...',
button: 'ログイン',
failed: 'ログイン失敗。サーバーは起動していますか?',
},
alerts: {
syncSuccess: "同期完了! コレクション: {count}",
syncFailed: "同期に失敗しました。",
logoutConfirm: "ログアウトしてもよろしいですか?セッションが終了します。",
syncSuccess: '同期完了! コレクション: {count}',
syncFailed: '同期に失敗しました。',
logoutConfirm: 'ログアウトしてもよろしいですか?セッションが終了します。',
},
lesson: {
phasePrimer: '学習',
phaseDemo: '観察',
phaseGuided: 'ガイド',
phasePractice: '想起',
understand: '理解した',
ready: '描いてみる',
startPractice: '練習開始',
continue: '次へ',
streak: '正解: {n} / {total}',
hint: 'ヒント (連勝リセット)',
hintAction: 'ヒント',
watchAgain: 'もう一度見る',
completeTitle: 'レッスン完了!',
completeBody: '新しい漢字がレビューに追加されました。',
learned: '{n}個の新しい漢字を覚えました。',
components: '構成要素',
observe: '書き順を見る',
trace: 'なぞる',
drawStep: '書く ({n}/{total})',
backToDashboard: 'ダッシュボードに戻る',
},
hero: {
welcome: "お帰りなさい",
subtitle: "準備は完了です。",
start: "復習開始",
noReviews: "レビューなし",
nextIn: "次の復習まで",
now: "今",
prioritize: "低レベルを優先 ({count})"
lessons: 'レッスン',
welcome: 'お帰りなさい',
subtitle: '準備は完了です。',
start: '復習開始',
noReviews: 'レビューなし',
nextIn: '次の復習まで',
now: '今',
prioritize: '低レベルを優先 ({count})',
},
stats: {
mastery: "習得度 (Guru+)",
srsDistribution: "SRS分布",
accuracy: "正解率",
correct: "正解",
total: "合計",
next24: "今後24時間",
availableNow: "今すぐ可能",
inHours: "{n}時間後",
noIncoming: "24時間以内のレビューはありません。",
items: "個",
reviewsCount: "{count} レビュー",
mastery: '習得度 (Guru+)',
srsDistribution: 'SRS分布',
accuracy: '正解率',
correct: '正解',
total: '合計',
next24: '今後24時間',
availableNow: '今すぐ可能',
inHours: '{n}時間後',
noIncoming: '24時間以内のレビューはありません。',
items: '個',
reviewsCount: '{count} レビュー',
consistency: "学習の一貫性",
less: "少",
more: "多",
consistency: '学習の一貫性',
less: '少',
more: '多',
streakTitle: "連続学習日数",
days: "日",
shieldActive: "Zenシールド有効: 1日休んでもストリークを守ります。",
shieldCooldown: "再チャージ中: 残り{n}日",
streakTitle: '連続学習日数',
days: '日',
shieldActive: 'Zenシールド有効: 1日休んでもストリークを守ります。',
shieldCooldown: '再チャージ中: 残り{n}日',
ghostTitle: "苦手なアイテム",
ghostSubtitle: "正解率が低い",
noGhosts: "苦手なアイテムはありません!"
ghostTitle: '苦手なアイテム',
ghostSubtitle: '正解率が低い',
noGhosts: '苦手なアイテムはありません!',
},
settings: {
title: "設定",
batchSize: "1回の復習数",
items: "個",
language: "言語 (Language)",
save: "保存して閉じる"
title: '設定',
batchSize: '1回の復習数',
items: '個',
language: '言語 (Language)',
drawingTolerance: '描画許容範囲',
strict: '厳しい',
loose: '甘い',
save: '保存して閉じる',
},
review: {
meaning: "意味",
level: "レベル",
draw: "正しく描いてください",
hint: "ヒント表示",
tryAgain: "もう一度",
correct: "正解!",
next: "次へ",
sessionComplete: "セッション完了!",
levelup: "漢字力がアップしました。",
back: "コレクションに戻る",
caughtUp: "完了しました",
noReviews: "現在レビューするものはありません。",
viewCollection: "コレクションを見る",
queue: "残り:",
loading: "漢字を読み込み中...",
meaning: '意味',
level: 'レベル',
draw: '正しく描いてください',
hint: 'ヒント表示',
showHint: 'ヒントを表示',
redoLesson: 'レッスンをやり直す',
tryAgain: 'もう一度',
correct: '正解!',
next: '次へ',
sessionComplete: 'セッション完了!',
levelup: '漢字力がアップしました。',
back: 'コレクションに戻る',
caughtUp: '完了しました!',
noReviews: '現在レビューするものはありません。',
viewCollection: 'コレクションを見る',
queue: '残り:',
loading: '漢字を読み込み中...',
},
collection: {
searchLabel: "漢字、意味、読みで検索...",
searchLabel: '漢字、意味、読みで検索...',
placeholder: "例: '水', 'mizu'",
loading: "読み込み中...",
noMatches: "見つかりませんでした",
tryDifferent: "別のキーワードで検索してください。",
levelHeader: "レベル",
onyomi: "音読み",
kunyomi: "訓読み",
nanori: "名乗り",
close: "閉じる"
}
}
}
loading: '読み込み中...',
noMatches: '見つかりませんでした',
tryDifferent: '別のキーワードで検索してください。',
levelHeader: 'レベル',
onyomi: '音読み',
kunyomi: '訓読み',
nanori: '名乗り',
close: '閉じる',
startLesson: 'レッスン開始',
redoLesson: 'レッスンをやり直す',
},
},
};
const savedLocale = localStorage.getItem('zen_locale') || 'en'
const savedLocale = localStorage.getItem('zen_locale') || 'en';
const i18n = createI18n({
legacy: false,
locale: savedLocale,
fallbackLocale: 'en',
messages
})
messages,
});
export default i18n
export default i18n;

View File

@@ -7,18 +7,20 @@ export const useAppStore = defineStore('app', {
token: localStorage.getItem('zen_token') || '',
user: null,
queue: [],
lessonQueue: [],
collection: [],
stats: {
distribution: {},
forecast: [],
queueLength: 0,
lessonCount: 0,
streak: {},
accuracy: {},
ghosts: []
ghosts: [],
},
batchSize: 20,
drawingAccuracy: 10,
loading: false
batchSize: parseInt(localStorage.getItem('zen_batch_size'), 10) || 20,
drawingAccuracy: parseInt(localStorage.getItem('zen_drawing_accuracy'), 10) || 10,
loading: false,
}),
actions: {
@@ -26,7 +28,7 @@ export const useAppStore = defineStore('app', {
const res = await fetch(`${BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey })
body: JSON.stringify({ apiKey }),
});
const data = await res.json();
@@ -38,6 +40,10 @@ export const useAppStore = defineStore('app', {
if (data.user.settings) {
this.batchSize = data.user.settings.batchSize || 20;
this.drawingAccuracy = data.user.settings.drawingAccuracy || 10;
// Persist settings to local storage on login
localStorage.setItem('zen_batch_size', this.batchSize);
localStorage.setItem('zen_drawing_accuracy', this.drawingAccuracy);
}
localStorage.setItem('zen_token', data.token);
@@ -51,11 +57,11 @@ export const useAppStore = defineStore('app', {
if (this.token) {
await fetch(`${BASE_URL}/api/auth/logout`, {
method: 'POST',
headers: this.getHeaders()
headers: this.getHeaders(),
});
}
} catch (e) {
console.error("Logout error:", e);
console.error('Logout error:', e);
} finally {
this.clearData();
}
@@ -67,12 +73,14 @@ export const useAppStore = defineStore('app', {
this.queue = [];
this.stats = {};
localStorage.removeItem('zen_token');
localStorage.removeItem('zen_batch_size');
localStorage.removeItem('zen_drawing_accuracy');
},
getHeaders() {
return {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json'
Authorization: `Bearer ${this.token}`,
'Content-Type': 'application/json',
};
},
@@ -80,7 +88,7 @@ export const useAppStore = defineStore('app', {
const res = await fetch(`${BASE_URL}/api/sync`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({})
body: JSON.stringify({}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
@@ -88,9 +96,14 @@ export const useAppStore = defineStore('app', {
},
async fetchStats() {
if (!this.token) return;
if (!this.token) return null;
const res = await fetch(`${BASE_URL}/api/stats`, { headers: this.getHeaders() });
if (res.status === 401) return this.logout();
if (res.status === 401) {
await this.logout();
return null;
}
const data = await res.json();
this.stats = data;
return data;
@@ -98,17 +111,44 @@ export const useAppStore = defineStore('app', {
async fetchQueue(sortMode = 'shuffled') {
if (!this.token) return;
const res = await fetch(`${BASE_URL}/api/queue?limit=${this.batchSize}&sort=${sortMode}`, {
headers: this.getHeaders()
headers: this.getHeaders(),
});
if (res.status === 401) return this.logout();
if (res.status === 401) {
await this.logout();
return;
}
this.queue = await res.json();
},
async fetchLessonQueue() {
if (!this.token) return;
const res = await fetch(`${BASE_URL}/api/lessons?limit=${this.batchSize}`, {
headers: this.getHeaders(),
});
if (res.status === 401) {
await this.logout();
return;
}
this.lessonQueue = await res.json();
},
async fetchCollection() {
if (!this.token) return;
const res = await fetch(`${BASE_URL}/api/collection`, { headers: this.getHeaders() });
if (res.status === 401) return this.logout();
if (res.status === 401) {
await this.logout();
return;
}
this.collection = await res.json();
},
@@ -116,21 +156,47 @@ export const useAppStore = defineStore('app', {
const res = await fetch(`${BASE_URL}/api/review`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ subjectId, success })
body: JSON.stringify({ subjectId, success }),
});
if (res.status === 401) return this.logout();
return await res.json();
if (res.status === 401) {
await this.logout();
return null;
}
return res.json();
},
async submitLesson(subjectId) {
const res = await fetch(`${BASE_URL}/api/lesson`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ subjectId }),
});
if (res.status === 401) {
await this.logout();
return null;
}
return res.json();
},
async saveSettings(settings) {
if (settings.batchSize) this.batchSize = settings.batchSize;
if (settings.drawingAccuracy) this.drawingAccuracy = settings.drawingAccuracy;
if (settings.batchSize !== undefined) {
this.batchSize = settings.batchSize;
localStorage.setItem('zen_batch_size', settings.batchSize);
}
if (settings.drawingAccuracy !== undefined) {
this.drawingAccuracy = settings.drawingAccuracy;
localStorage.setItem('zen_drawing_accuracy', settings.drawingAccuracy);
}
await fetch(`${BASE_URL}/api/settings`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify(settings)
body: JSON.stringify(settings),
});
}
}
},
},
});

View File

@@ -6,13 +6,32 @@ $color-surface-light: hsl(221deg 17% 22%);
$color-text-white: hsl(0deg 0% 100%);
$color-text-grey: hsl(213deg 14% 70%);
$color-border: hsl(0deg 0% 100% / 8%);
$color-zinc-900: #18181b;
$color-reading-box: rgb(255 255 255 / 3%);
$color-stroke-drawn: #a1a1aa;
$color-srs-1: hsl(0deg 100% 73%);
$color-srs-2: hsl(39deg 98% 71%);
$color-srs-3: hsl(163deg 85% 64%);
$color-srs-4: hsl(206deg 92% 46%);
$color-srs-5: hsl(244deg 100% 82%);
$color-srs-6: hsl(247deg 72% 63%);
$color-srs-7: $color-primary;
$color-srs-8: hsl(339deg 97% 73%);
$color-srs-9: hsl(331deg 78% 59%);
$color-srs-10: hsl(51deg 100% 50%);
$color-danger: hsl(0deg 85% 65%);
$color-success: hsl(160deg 80% 45%);
$color-stroke-inactive: hsl(0deg 0% 33%);
$color-dot-base: hsl(0deg 0% 27%);
$srs-colors: (
1: $color-srs-1,
2: $color-srs-2,
3: $color-srs-3,
4: $color-srs-4,
5: $color-srs-5,
6: $color-srs-6,
7: $color-srs-7,
8: $color-srs-8,
9: $color-srs-9,
10: $color-srs-10
);

View File

@@ -18,6 +18,9 @@ $z-dropdown: 50;
$z-modal: 100;
$z-tooltip: 200;
$z-above: 2;
// Neue Z-Indizes aus dem Refactoring
$z-play-btn: 10;
$size-canvas: 300px;
$size-kanji-preview: 200px;
$size-icon-btn: 32px;
@@ -26,12 +29,6 @@ $stroke-width-main: 3px;
$stroke-width-arrow: 2px;
$font-size-svg-number: 5px;
$radius-xs: 2px;
$radius-sm: 4px;
$radius-md: 8px;
$radius-lg: 12px;
$radius-xl: 24px;
$radius-pill: 999px;
$radius-circle: 50%;
$size-legend-box: 12px;
$size-streak-dot: 24px;
$size-srs-track: 24px;
@@ -44,3 +41,14 @@ $padding-page-x: 24px;
$breakpoint-md: 960px;
$offset-fab: 20px;
$size-heatmap-cell-height: 10px;
$stroke-width-kanji: 6px;
$opacity-kanji-hint: 0.5;
$dash-kanji-hint: 10px 15px;
// --- NEUE VARIABLEN (Wichtig!) ---
$size-hero-wrapper: 140px;
$size-lesson-card-width: 450px;
$size-lesson-card-min-height: 500px;
$size-avatar-small: 32px;
$size-button-large-height: 70px;
$size-button-large-width: 280px;

View File

@@ -1,23 +1,53 @@
@use '../abstracts/variables' as *;
@use '../abstracts/mixins' as *;
@mixin grid-overlay {
&::before,
&::after {
content: '';
position: absolute;
pointer-events: none;
border-color: $color-border;
border-style: dashed;
border-width: 0;
z-index: $z-normal;
}
&::before {
top: 50%;
left: 10px;
right: 10px;
border-top-width: 1px;
}
&::after {
left: 50%;
top: 10px;
bottom: 10px;
border-left-width: 1px;
}
}
.canvas-container {
position: relative;
@include flex-center;
margin-bottom: $spacing-xl;
}
// The main drawing canvas wrapper
.canvas-wrapper {
position: relative;
width: $size-canvas;
height: $size-canvas;
background: $color-surface;
border-radius: $radius-lg;
background: rgba($color-surface, 0.95);
border: $border-width-md solid $color-surface-light;
position: relative;
cursor: crosshair;
border: $border-subtle;
box-shadow: $shadow-inset;
overflow: hidden;
cursor: crosshair;
display: flex;
align-items: center;
justify-content: center;
canvas {
position: absolute;
@@ -26,39 +56,66 @@
width: 100%;
height: 100%;
touch-action: none;
z-index: $z-above;
}
}
.loading-text {
position: absolute;
color: $color-text-grey;
z-index: $z-sticky;
font-size: $font-sm;
font-weight: $weight-medium;
letter-spacing: $tracking-wide;
// The SVG Viewer wrapper (Review/Lesson/Collection)
.svg-container {
width: 100%;
height: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
// --- Unified Styles to match Canvas ---
background: $color-surface;
border-radius: $radius-lg; // Matches canvas-wrapper
border: $border-subtle; // Matches canvas-wrapper
box-shadow: $shadow-inset; // Matches canvas-wrapper
// -------------------------------------
@include grid-overlay;
&.hero-mode {
background: linear-gradient(145deg, $color-surface, #18181b);
border: 2px solid $color-border;
box-shadow: 0 4px 20px rgb(0 0 0 / 40%);
.stroke-path.drawn {
stroke: $color-text-white;
filter: drop-shadow(0 0 2px rgb(255 255 255 / 25%));
}
&::before,
&::after {
display: none;
}
}
}
.svg-container {
width: $size-kanji-preview;
height: $size-kanji-preview;
margin: 0 auto $spacing-lg;
background: rgba($color-bg-dark, 0.2);
border-radius: $radius-md;
border: $border-subtle;
position: relative;
@include flex-center;
overflow: hidden;
.kanji-svg {
z-index: $z-above;
display: block;
padding: 12%;
width: 100%;
height: 100%;
}
.stroke-path {
fill: none;
stroke: $color-stroke-inactive;
stroke-width: $stroke-width-main;
stroke-linecap: round;
stroke-linejoin: round;
transition: stroke $duration-normal;
stroke-width: $stroke-width-main;
transition:
stroke 0.3s ease,
opacity 0.3s ease;
&.drawn {
stroke: $color-stroke-drawn;
}
&.hidden {
opacity: 0;
@@ -70,10 +127,75 @@
stroke-dashoffset: var(--len);
animation: draw-stroke var(--duration) linear forwards;
}
}
&.drawn {
stroke: $color-text-white;
opacity: 1;
.stroke-ghost {
fill: none;
stroke: $color-stroke-inactive;
stroke-width: $stroke-width-main;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.5;
stroke-dasharray: 10 15;
}
.stroke-badge-group {
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 50%));
pointer-events: none;
}
.stroke-badge-bg {
fill: $color-primary;
opacity: 0.9;
transition: transform 0.2s ease;
}
.stroke-badge-text {
fill: $color-bg-dark;
font-size: $font-size-svg-number;
font-family: sans-serif;
font-weight: $weight-black;
user-select: none;
text-anchor: middle;
dominant-baseline: central;
}
.stroke-arrow-line {
fill: none;
stroke: $color-primary;
stroke-width: 1.5px;
vector-effect: non-scaling-stroke;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
filter: drop-shadow(0 0 2px rgb(0 0 0 / 50%));
}
.play-btn {
position: absolute;
top: 10px;
right: 10px;
width: $size-icon-btn;
height: $size-icon-btn;
border-radius: $radius-circle;
background: rgb(255 255 255 / 10%);
border: 1px solid rgba($color-primary, 0.3);
color: $color-primary;
@include flex-center;
cursor: pointer;
z-index: $z-play-btn;
transition: all 0.2s ease;
&:hover {
transform: scale(1.05);
background: rgba($color-primary, 0.2);
}
svg {
width: 18px;
height: 18px;
}
}
@@ -83,65 +205,30 @@
}
}
.stroke-start-circle {
fill: $color-srs-1;
}
.stroke-number {
fill: $color-text-white;
font-size: $font-size-svg-number;
font-family: $font-family-sans;
font-weight: $weight-bold;
text-anchor: middle;
dominant-baseline: middle;
pointer-events: none;
}
.stroke-arrow-line {
fill: none;
stroke: rgba($color-secondary, 0.7);
stroke-width: $stroke-width-arrow;
stroke-linecap: round;
stroke-linejoin: round;
}
.loading-spinner {
color: $color-text-grey;
font-size: $font-sm;
}
.play-btn {
position: absolute;
top: $spacing-sm;
right: $spacing-sm;
background: rgba($color-bg-dark, 0.3);
border: $border-width-sm solid rgba($color-primary, 0.5);
color: $color-primary;
border-radius: $radius-circle;
width: $size-icon-btn;
height: $size-icon-btn;
@include flex-center;
cursor: pointer;
transition: all $duration-fast ease;
z-index: $z-sticky;
backdrop-filter: $blur-sm;
&:hover {
transform: scale(1.1);
background: $color-primary;
color: $color-bg-dark;
border-color: $color-primary;
box-shadow: $shadow-glow-base;
@keyframes shake-x {
0%,
100% {
transform: translateX(0);
}
&:active {
transform: scale(0.95);
20% {
transform: translateX(-6px);
}
svg {
width: $size-icon-small;
height: $size-icon-small;
40% {
transform: translateX(6px);
}
60% {
transform: translateX(-6px);
}
80% {
transform: translateX(6px);
}
}
.shake {
animation: shake-x 0.4s ease-in-out;
border-color: $color-danger !important;
}

View File

@@ -6,26 +6,27 @@
@include card-base;
border: $border-subtle;
background-color: $color-surface !important;
border-radius: $radius-xl !important;
}
.level-0 {
background-color: $bg-glass-subtle;
.welcome-btn {
height: $size-button-large-height !important;
width: $size-button-large-width !important;
.v-chip {
color: white !important;
background-color: $color-surface !important;
}
}
.level-1 {
background-color: color.mix($color-primary, $color-surface, 25%);
.streak-shield-avatar {
border: 1px solid currentcolor;
background: rgb(255 255 255 / 5%);
}
.level-2 {
background-color: color.mix($color-primary, $color-surface, 50%);
}
.level-3 {
background-color: $color-primary;
}
.level-4 {
background-color: color.scale($color-primary, $lightness: 40%);
.streak-day-label {
font-size: 9px !important;
}
.heatmap-container {
@@ -72,6 +73,26 @@
box-sizing: border-box;
}
.level-0 {
background-color: $bg-glass-subtle;
}
.level-1 {
background-color: color.mix($color-primary, $color-surface, 25%);
}
.level-2 {
background-color: color.mix($color-primary, $color-surface, 50%);
}
.level-3 {
background-color: $color-primary;
}
.level-4 {
background-color: color.scale($color-primary, $lightness: 40%);
}
.legend-container {
display: flex;
align-items: center;
@@ -153,22 +174,16 @@
@include scrollbar;
}
.gap-1 {
gap: $spacing-xs;
.srs-chart-container {
gap: 8px;
@media (max-width: 600px) {
gap: 4px;
}
}
.gap-2 {
gap: $spacing-sm;
}
.border-subtle {
border: $border-subtle;
}
.border-b-subtle {
border-bottom: $border-subtle;
}
.border-t-subtle {
border-top: $border-subtle;
.srs-track {
@media (max-width: 400px) {
width: 6px;
}
}

View File

@@ -8,3 +8,4 @@
@use 'pages/dashboard';
@use 'pages/review';
@use 'pages/collection';
@use 'pages/lesson';

View File

@@ -2,6 +2,11 @@
:root {
--v-theme-background: #{$color-bg-dark};
--color-surface: #{$color-surface};
@each $level, $color in $srs-colors {
--srs-#{$level}: #{$color};
}
}
body,
@@ -59,3 +64,13 @@ html,
opacity: $opacity-hover;
}
}
@each $level, $color in $srs-colors {
.text-srs-#{$level} {
color: var(--srs-#{$level}) !important;
}
.bg-srs-#{$level} {
background-color: var(--srs-#{$level}) !important;
}
}

View File

@@ -0,0 +1,78 @@
@use '../abstracts/variables' as *;
@use '../abstracts/mixins' as *;
.page-container-center {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: $spacing-md;
overflow-y: auto;
}
.lesson-card {
width: 100%;
max-width: $size-lesson-card-width;
border-radius: $radius-xl !important;
border: $border-subtle;
background-color: $color-surface !important;
min-height: $size-lesson-card-min-height;
display: flex;
flex-direction: column;
}
.hero-wrapper {
width: $size-hero-wrapper;
height: $size-hero-wrapper;
}
.radical-section {
width: 100%;
background: $color-zinc-900;
border-radius: $radius-md;
padding: $spacing-md;
margin-bottom: $spacing-lg;
}
.radical-chip {
border-radius: $radius-md;
border: $border-subtle;
background-color: #27272a;
img {
filter: invert(1);
width: 100%;
}
}
.readings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-lg;
width: 100%;
}
.reading-box {
background: $color-reading-box;
padding: $spacing-lg;
border-radius: $radius-lg;
text-align: center;
.label {
font-size: $font-xs;
color: #666;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: $spacing-xs;
}
.val {
font-size: 1.1rem;
font-weight: $weight-bold;
}
}
.lesson-canvas-wrapper {
margin: 0 auto;
}

View File

@@ -1,21 +1,25 @@
@use '../abstracts/variables' as *;
@use '../abstracts/mixins' as *;
.review-card {
border: $border-subtle;
background-color: $color-surface !important;
width: 100%;
max-width: 420px;
}
.text-shadow {
text-shadow: $text-shadow;
}
.canvas-wrapper {
.review-canvas-area {
position: relative;
width: $size-canvas;
height: $size-canvas;
border-radius: $radius-lg;
background: $bg-glass-dark;
box-shadow: $shadow-inset;
margin-bottom: $spacing-sm;
.next-fab {
position: absolute;
bottom: -#{$offset-fab};
right: -#{$offset-fab};
bottom: 20px;
right: 20px;
z-index: $z-sticky;
}
}
@@ -45,3 +49,15 @@
opacity: 0;
transform: translateY(-#{$dist-slide-sm});
}
.opacity-80 {
opacity: 0.8;
}
.progress-bar {
opacity: 0.3;
}
.gap-2 {
gap: $spacing-sm;
}

View File

@@ -0,0 +1,411 @@
export const KANJI_CONSTANTS = {
BASE_SIZE: 109,
SVG_NS: 'http://www.w3.org/2000/svg',
API_URL: 'https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji',
STROKE_WIDTH_BASE: 6,
DASH_ARRAY_GRID: [5, 5],
DASH_ARRAY_HINT: [10, 15],
ANIMATION_DURATION: 300,
SAMPLE_POINTS: 60,
VALIDATION: {
SAMPLES: 10,
},
COLORS: {
USER: { r: 255, g: 118, b: 117 },
FINAL: { r: 0, g: 206, b: 201 },
HINT: '#3f3f46',
GRID: 'rgba(255, 255, 255, 0.08)',
},
};
export class KanjiController {
constructor(options = {}) {
this.size = options.size || 300;
this.accuracy = options.accuracy || 10;
this.onComplete = options.onComplete || (() => {});
this.onMistake = options.onMistake || (() => {});
this.scale = this.size / KANJI_CONSTANTS.BASE_SIZE;
this.paths = [];
this.currentStrokeIdx = 0;
this.mistakes = 0;
this.userPath = [];
this.isDrawing = false;
this.isAnimating = false;
this.ctx = { bg: null, hint: null, draw: null };
}
static createPathElement(d) {
const path = document.createElementNS(KANJI_CONSTANTS.SVG_NS, 'path');
path.setAttribute('d', d);
return path;
}
static setContextDefaults(ctx) {
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
}
static resamplePoints(points, count) {
if (!points || points.length === 0) return [];
let totalLen = 0;
const dists = [0];
points.slice(1).forEach((p, i) => {
const prev = points[i];
const d = Math.hypot(p.x - prev.x, p.y - prev.y);
totalLen += d;
dists.push(totalLen);
});
const step = totalLen / (count - 1);
return Array.from({ length: count }).map((_, i) => {
const targetDist = i * step;
let idx = dists.findIndex((d) => d >= targetDist);
if (idx === -1) idx = dists.length - 1;
if (idx > 0) idx -= 1;
if (idx >= points.length - 1) {
return points[points.length - 1];
}
const dStart = dists[idx];
const dEnd = dists[idx + 1];
const segmentLen = dEnd - dStart;
const t = segmentLen === 0 ? 0 : (targetDist - dStart) / segmentLen;
const p1 = points[idx];
const p2 = points[idx + 1];
return {
x: p1.x + (p2.x - p1.x) * t,
y: p1.y + (p2.y - p1.y) * t,
};
});
}
mount(canvasRefs) {
this.ctx.bg = canvasRefs.bg.getContext('2d');
this.ctx.hint = canvasRefs.hint.getContext('2d');
this.ctx.draw = canvasRefs.draw.getContext('2d');
this.resize(this.size);
}
setAccuracy(val) {
this.accuracy = val;
}
resize(newSize) {
this.size = newSize;
this.scale = this.size / KANJI_CONSTANTS.BASE_SIZE;
Object.values(this.ctx).forEach((ctx) => {
if (ctx && ctx.canvas) {
ctx.canvas.width = this.size;
ctx.canvas.height = this.size;
KanjiController.setContextDefaults(ctx);
}
});
this.drawGrid();
if (this.paths.length) {
this.redrawAllPerfectStrokes();
}
}
async loadChar(char, autoHint = false) {
this.reset();
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
try {
const res = await fetch(`${KANJI_CONSTANTS.API_URL}/${hex}.svg`);
const txt = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(txt, 'image/svg+xml');
this.paths = Array.from(doc.getElementsByTagName('path')).map((p) => p.getAttribute('d'));
this.drawGrid();
if (autoHint) this.showHint();
} catch (e) {
console.error('Failed to load Kanji:', e);
}
}
reset() {
this.currentStrokeIdx = 0;
this.mistakes = 0;
this.isAnimating = false;
this.userPath = [];
this.clearCanvas(this.ctx.draw);
this.clearCanvas(this.ctx.hint);
this.drawGrid();
this.resetDrawStyle();
}
startStroke(point) {
if (this.currentStrokeIdx >= this.paths.length || this.isAnimating) return;
this.isDrawing = true;
this.userPath = [point];
this.ctx.draw.beginPath();
this.ctx.draw.moveTo(point.x, point.y);
this.resetDrawStyle();
}
moveStroke(point) {
if (!this.isDrawing) return;
this.userPath.push(point);
this.ctx.draw.lineTo(point.x, point.y);
this.ctx.draw.stroke();
}
endStroke() {
if (!this.isDrawing) return;
this.isDrawing = false;
this.validateStroke();
}
drawGrid() {
if (!this.ctx.bg) return;
const ctx = this.ctx.bg;
this.clearCanvas(ctx);
ctx.strokeStyle = KANJI_CONSTANTS.COLORS.GRID;
ctx.lineWidth = 1;
ctx.setLineDash(KANJI_CONSTANTS.DASH_ARRAY_GRID);
const s = this.size;
ctx.beginPath();
ctx.moveTo(s / 2, 0); ctx.lineTo(s / 2, s);
ctx.moveTo(0, s / 2); ctx.lineTo(s, s / 2);
ctx.stroke();
}
showHint() {
this.clearCanvas(this.ctx.hint);
if (this.currentStrokeIdx >= this.paths.length) return;
const d = this.paths[this.currentStrokeIdx];
const pathEl = KanjiController.createPathElement(d);
const len = pathEl.getTotalLength();
const ctx = this.ctx.hint;
ctx.beginPath();
ctx.strokeStyle = KANJI_CONSTANTS.COLORS.HINT;
ctx.setLineDash(KANJI_CONSTANTS.DASH_ARRAY_HINT);
const step = 5;
const count = Math.floor(len / step) + 1;
Array.from({ length: count }).forEach((_, i) => {
const dist = Math.min(i * step, len);
const pt = pathEl.getPointAtLength(dist);
if (i === 0) ctx.moveTo(pt.x * this.scale, pt.y * this.scale);
else ctx.lineTo(pt.x * this.scale, pt.y * this.scale);
});
ctx.stroke();
}
redrawAllPerfectStrokes(includeCurrent = false) {
const ctx = this.ctx.draw;
this.clearCanvas(ctx);
ctx.save();
ctx.scale(this.scale, this.scale);
const { r, g, b } = KANJI_CONSTANTS.COLORS.FINAL;
ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`;
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE / this.scale;
ctx.setLineDash([]);
const limit = includeCurrent ? this.currentStrokeIdx + 1 : this.currentStrokeIdx;
this.paths.slice(0, limit).forEach((d) => {
ctx.stroke(new Path2D(d));
});
ctx.restore();
if (!this.isAnimating) this.resetDrawStyle();
}
clearCanvas(ctx) {
if (ctx) ctx.clearRect(0, 0, this.size, this.size);
}
resetDrawStyle() {
const { r, g, b } = KANJI_CONSTANTS.COLORS.USER;
if (this.ctx.draw) {
this.ctx.draw.strokeStyle = `rgb(${r}, ${g}, ${b})`;
this.ctx.draw.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
this.ctx.draw.setLineDash([]);
}
}
validateStroke() {
const targetD = this.paths[this.currentStrokeIdx];
const userNormalized = this.userPath.map((p) => ({
x: p.x / this.scale,
y: p.y / this.scale,
}));
if (this.checkMatch(userNormalized, targetD)) {
this.animateMorph(this.userPath, targetD, () => {
this.currentStrokeIdx += 1;
this.mistakes = 0;
this.redrawAllPerfectStrokes();
if (this.currentStrokeIdx >= this.paths.length) {
this.onComplete();
}
});
} else {
this.mistakes += 1;
this.animateErrorFade(this.userPath, () => {
this.redrawAllPerfectStrokes();
const needsHint = this.mistakes >= 3;
if (needsHint) this.showHint();
this.onMistake(needsHint);
});
}
}
checkMatch(userPts, targetD) {
if (userPts.length < 3) return false;
const pathEl = KanjiController.createPathElement(targetD);
const len = pathEl.getTotalLength();
const { SAMPLES } = KANJI_CONSTANTS.VALIDATION;
const avgDistThreshold = this.accuracy * 0.8;
const startEndThreshold = this.accuracy * 2.5;
const targetStart = pathEl.getPointAtLength(0);
const targetEnd = pathEl.getPointAtLength(len);
const dist = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y);
if (dist(userPts[0], targetStart) > startEndThreshold) return false;
if (dist(userPts[userPts.length - 1], targetEnd) > startEndThreshold) return false;
let totalError = 0;
const sampleCount = SAMPLES + 1;
totalError = Array.from({ length: sampleCount }).reduce((acc, _, i) => {
const targetPt = pathEl.getPointAtLength((i / SAMPLES) * len);
const minD = userPts.reduce((min, up) => Math.min(min, dist(targetPt, up)), Infinity);
return acc + minD;
}, 0);
return (totalError / sampleCount) < avgDistThreshold;
}
animateErrorFade(userPath, onComplete) {
this.isAnimating = true;
const startTime = performance.now();
const DURATION = 300;
const tick = (now) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / DURATION, 1);
const opacity = 1 - progress;
this.redrawAllPerfectStrokes();
if (opacity > 0) {
const ctx = this.ctx.draw;
ctx.save();
ctx.beginPath();
const { r, g, b } = KANJI_CONSTANTS.COLORS.USER;
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${opacity})`;
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (userPath.length > 0) {
ctx.moveTo(userPath[0].x, userPath[0].y);
userPath.slice(1).forEach((p) => ctx.lineTo(p.x, p.y));
}
ctx.stroke();
ctx.restore();
}
if (progress < 1) {
requestAnimationFrame(tick);
} else {
this.isAnimating = false;
this.resetDrawStyle();
onComplete();
}
};
requestAnimationFrame(tick);
}
animateMorph(userPoints, targetD, onComplete) {
this.isAnimating = true;
const targetPoints = this.getSvgPoints(targetD, KANJI_CONSTANTS.SAMPLE_POINTS);
const startPoints = KanjiController.resamplePoints(userPoints, KANJI_CONSTANTS.SAMPLE_POINTS);
const startTime = performance.now();
const { USER, FINAL } = KANJI_CONSTANTS.COLORS;
const tick = (now) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / KANJI_CONSTANTS.ANIMATION_DURATION, 1);
const ease = 1 - (1 - progress) ** 3;
this.redrawAllPerfectStrokes(false);
const r = USER.r + (FINAL.r - USER.r) * ease;
const g = USER.g + (FINAL.g - USER.g) * ease;
const b = USER.b + (FINAL.b - USER.b) * ease;
const ctx = this.ctx.draw;
ctx.strokeStyle = `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
ctx.lineWidth = KANJI_CONSTANTS.STROKE_WIDTH_BASE;
ctx.beginPath();
Array.from({ length: KANJI_CONSTANTS.SAMPLE_POINTS }).forEach((_, i) => {
const sx = startPoints[i].x;
const sy = startPoints[i].y;
const ex = targetPoints[i].x;
const ey = targetPoints[i].y;
const cx = sx + (ex - sx) * ease;
const cy = sy + (ey - sy) * ease;
if (i === 0) ctx.moveTo(cx, cy);
else ctx.lineTo(cx, cy);
});
ctx.stroke();
if (progress < 1) {
requestAnimationFrame(tick);
} else {
this.isAnimating = false;
this.resetDrawStyle();
onComplete();
}
};
requestAnimationFrame(tick);
}
getSvgPoints(d, count) {
const path = KanjiController.createPathElement(d);
const len = path.getTotalLength();
return Array.from({ length: count }).map((_, i) => {
const pt = path.getPointAtLength((i / (count - 1)) * len);
return { x: pt.x * this.scale, y: pt.y * this.scale };
});
}
}

View File

@@ -79,7 +79,7 @@
.text-h2.font-weight-bold.mb-1 {{ selectedItem?.char }}
.text-subtitle-1.text-grey-lighten-1.text-capitalize.mb-4 {{ selectedItem?.meaning }}
KanjiSvgViewer(
KanjiSvgViewer.mb-4(
v-if="showModal && selectedItem"
:char="selectedItem.char"
)
@@ -97,21 +97,35 @@
.reading-label {{ $t('collection.nanori') }}
.reading-value {{ selectedItem?.nanori.join(', ') }}
v-row.px-2.pb-2
v-col.pr-2(cols="6")
v-btn.text-white(
block
color="#2f3542"
@click="showModal = false"
) {{ $t('collection.close') }}
v-col.pl-2(cols="6")
v-btn.text-black(
block
color="purple-accent-2"
@click="redoLesson"
)
| {{ selectedItem?.srsLevel === 0
| ? $t('collection.startLesson')
| : $t('collection.redoLesson') }}
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { computed, onMounted, ref } from 'vue';
import { useAppStore } from '@/stores/appStore';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAppStore } from '@/stores/appStore';
import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue';
const { t } = useI18n();
const store = useAppStore();
const router = useRouter();
const loading = ref(true);
const showModal = ref(false);
@@ -127,19 +141,19 @@ const filteredCollection = computed(() => {
if (!searchQuery.value) return store.collection;
const q = searchQuery.value.toLowerCase().trim();
return store.collection.filter(item => {
return store.collection.filter((item) => {
if (item.meaning && item.meaning.toLowerCase().includes(q)) return true;
if (item.char && item.char.includes(q)) return true;
if (item.onyomi && item.onyomi.some(r => r.includes(q))) return true;
if (item.kunyomi && item.kunyomi.some(r => r.includes(q))) return true;
if (item.nanori && item.nanori.some(r => r.includes(q))) return true;
if (item.onyomi && item.onyomi.some((r) => r.includes(q))) return true;
if (item.kunyomi && item.kunyomi.some((r) => r.includes(q))) return true;
if (item.nanori && item.nanori.some((r) => r.includes(q))) return true;
return false;
});
});
const groupedItems = computed(() => {
const groups = {};
filteredCollection.value.forEach(i => {
filteredCollection.value.forEach((i) => {
if (!groups[i.level]) groups[i.level] = [];
groups[i.level].push(i);
});
@@ -147,10 +161,16 @@ const groupedItems = computed(() => {
});
const SRS_COLORS = {
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4',
4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
7: '#00cec9', 8: '#fd79a8', 9: '#e84393',
10: '#ffd700'
1: '#ff7675',
2: '#fdcb6e',
3: '#55efc4',
4: '#0984e3',
5: '#a29bfe',
6: '#6c5ce7',
7: '#00cec9',
8: '#fd79a8',
9: '#e84393',
10: '#ffd700',
};
const getSRSColor = (lvl) => SRS_COLORS[lvl] || '#444';
@@ -160,6 +180,15 @@ const openDetail = (item) => {
showModal.value = true;
};
const redoLesson = () => {
if (!selectedItem.value) return;
router.push({
path: '/lesson',
query: { subjectId: selectedItem.value.wkSubjectId },
state: { item: JSON.parse(JSON.stringify(selectedItem.value)) },
});
};
const hasReading = (arr) => arr && arr.length > 0;
const getNextReviewText = (dateStr, srsLvl) => {

View File

@@ -2,6 +2,7 @@
.dashboard-layout.fade-in
WidgetWelcome(
:queue-length="stats.queueLength"
:lesson-count="stats.lessonCount"
:has-lower-levels="stats.hasLowerLevels"
:lower-level-count="stats.lowerLevelCount"
:forecast="stats.forecast"
@@ -31,9 +32,10 @@
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { ref, onMounted } from 'vue';
import { useAppStore } from '@/stores/appStore';
import { useRouter } from 'vue-router';
import { useAppStore } from '@/stores/appStore';
import WidgetWelcome from '@/components/dashboard/WidgetWelcome.vue';
import WidgetHeatmap from '@/components/dashboard/WidgetHeatmap.vue';
@@ -50,6 +52,7 @@ const loading = ref(true);
const stats = ref({
queueLength: 0,
lessonCount: 0,
hasLowerLevels: false,
lowerLevelCount: 0,
distribution: {},
@@ -57,7 +60,7 @@ const stats = ref({
ghosts: [],
accuracy: { total: 0, correct: 0 },
heatmap: {},
streak: { current: 0, history: [], shield: { ready: false } }
streak: { current: 0, history: [], shield: { ready: false } },
});
onMounted(async () => {
@@ -67,14 +70,14 @@ onMounted(async () => {
stats.value = { ...stats.value, ...data };
}
} catch (e) {
console.error("Dashboard Load Error:", e);
console.error('Dashboard Load Error:', e);
} finally {
loading.value = false;
}
});
function handleStart(mode) {
router.push({ path: '/review', query: { mode: mode } });
router.push({ path: '/review', query: { mode } });
}
</script>

194
client/src/views/Lesson.vue Normal file
View File

@@ -0,0 +1,194 @@
<template lang="pug">
v-container.page-container-center
v-card.lesson-card(
v-if="currentItem"
elevation="0"
)
.d-flex.align-center.pa-4.border-b-subtle
v-btn(icon="mdi-close" variant="text" to="/dashboard" color="grey")
v-spacer
.text-caption.text-grey-darken-1.font-weight-bold
| {{ sessionDone + 1 }} / {{ sessionTotal }}
v-spacer
v-chip.font-weight-bold(
size="small"
:color="phaseColor"
variant="tonal"
) {{ phaseLabel }}
v-window.flex-grow-1(v-model="phase")
v-window-item(value="primer")
.d-flex.flex-column.align-center.pa-6
.hero-wrapper.mb-6
KanjiSvgViewer(:char="currentItem.char" mode="hero")
.text-h4.font-weight-bold.text-white.mb-2 {{ currentItem.meaning }}
.text-body-1.text-grey.text-center.mb-8(
v-if="currentItem.mnemonic") "{{ currentItem.mnemonic }}"
.radical-section(v-if="currentItem.radicals?.length")
.text-caption.text-grey-darken-1.text-uppercase.mb-3.font-weight-bold
|{{ $t('lesson.components') }}
.d-flex.flex-wrap.gap-3.justify-center
v-sheet.radical-chip(
v-for="rad in currentItem.radicals" :key="rad.meaning")
.d-flex.align-center.gap-2.px-3.py-1
v-avatar(size="24" rounded="0")
img(v-if="rad.image" :src="rad.image")
span.text-h6.font-weight-bold.text-white(v-else) {{ rad.char }}
.text-caption.text-grey-lighten-1 {{ rad.meaning }}
.readings-grid
.reading-box
.label {{ $t('collection.onyomi') }}
.val.text-cyan-lighten-3 {{ currentItem.onyomi[0] || '-' }}
.reading-box
.label {{ $t('collection.kunyomi') }}
.val.text-pink-lighten-3 {{ currentItem.kunyomi[0] || '-' }}
v-window-item(value="demo")
.d-flex.flex-column.align-center.justify-center.h-100.pa-6
.text-subtitle-1.text-grey.mb-6 {{ $t('lesson.observe') }}
.canvas-wrapper.lesson-canvas-wrapper
KanjiSvgViewer(ref="demoViewer" :char="currentItem.char" mode="animate")
v-window-item(value="drawing")
.d-flex.flex-column.align-center.justify-center.h-100.pa-6
.text-subtitle-1.text-grey.mb-6
span(v-if="subPhase === 'guided'") {{ $t('lesson.trace') }}
span(v-else) Draw ({{ practiceCount }}/3)
.transition-opacity(
:style="{ opacity: canvasOpacity }"
style="transition: opacity 0.3s ease;"
)
KanjiCanvas(
ref="canvas"
:char="currentItem.char"
:auto-hint="subPhase === 'guided'"
:size="300"
@complete="handleDrawComplete"
@mistake="handleMistake"
)
.pa-6
v-btn.action-btn(
v-if="phase === 'primer'"
@click="phase = 'demo'"
color="cyan" rounded="pill" size="large" block
) {{ $t('lesson.understand') }}
v-btn.action-btn(
v-if="phase === 'demo'"
@click="startDrawing"
color="cyan" rounded="pill" size="large" block
) {{ $t('lesson.ready') }}
.d-flex.justify-center.gap-4.mt-2(v-if="phase === 'drawing' && subPhase === 'practice'")
v-btn(
variant="text" color="amber" @click="canvas?.showHint()"
) {{ $t('lesson.hintAction') }}
v-btn(variant="text" color="grey" @click="phase='demo'") {{ $t('lesson.watchAgain') }}
v-card.text-center.pa-8.lesson-card(
v-else
)
v-icon.mb-6(size="80" color="purple-accent-2") mdi-check-decagram
.text-h4.font-weight-bold.text-white.mb-2 {{ $t('lesson.completeTitle') }}
.text-body-1.text-grey.mb-8 {{ $t('lesson.learned', { n: sessionDone }) }}
v-btn.glow-btn.text-black.font-weight-bold(
to="/dashboard"
block
color="cyan"
height="50"
rounded="pill"
) {{ $t('lesson.backToDashboard') }}
</template>
<script setup>
/* eslint-disable no-unused-vars */
import {
ref, computed, onMounted, nextTick, watch,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useAppStore } from '@/stores/appStore';
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue';
const { t } = useI18n();
const store = useAppStore();
const currentItem = ref(null);
const phase = ref('primer');
const subPhase = ref('guided');
const practiceCount = ref(0);
const sessionDone = ref(0);
const sessionTotal = ref(0);
const canvasOpacity = ref(1);
const canvas = ref(null);
const demoViewer = ref(null);
const phaseLabel = computed(() => {
if (phase.value === 'primer') return t('lesson.phasePrimer');
if (phase.value === 'demo') return t('lesson.phaseDemo');
return subPhase.value === 'guided' ? t('lesson.phaseGuided') : t('lesson.phasePractice');
});
const phaseColor = computed(() => (phase.value === 'primer' ? 'blue' : 'purple'));
watch(phase, (val) => {
if (val === 'demo') nextTick(() => demoViewer.value?.playAnimation());
});
function loadNext() {
if (store.lessonQueue.length === 0) {
currentItem.value = null;
return;
}
[currentItem.value] = store.lessonQueue;
phase.value = 'primer';
subPhase.value = 'guided';
practiceCount.value = 0;
}
function startDrawing() {
phase.value = 'drawing';
subPhase.value = 'guided';
nextTick(() => canvas.value?.reset());
}
async function handleDrawComplete() {
await new Promise((r) => { setTimeout(r, 400); });
if (subPhase.value === 'guided') {
canvasOpacity.value = 0;
await new Promise((r) => { setTimeout(r, 300); });
subPhase.value = 'practice';
canvas.value?.reset();
canvasOpacity.value = 1;
} else {
practiceCount.value += 1;
if (practiceCount.value < 3) {
canvasOpacity.value = 0;
await new Promise((r) => { setTimeout(r, 300); });
canvas.value?.reset();
canvasOpacity.value = 1;
} else {
await store.submitLesson(currentItem.value.wkSubjectId);
sessionDone.value += 1;
store.lessonQueue.shift();
loadNext();
}
}
}
function handleMistake() {
if (subPhase.value === 'practice') practiceCount.value = 0;
}
onMounted(async () => {
await store.fetchLessonQueue();
sessionTotal.value = store.lessonQueue.length;
loadNext();
});
</script>

View File

@@ -1,11 +1,8 @@
<template lang="pug">
v-container.fill-height.justify-center.pa-4
v-fade-transition(mode="out-in")
v-card.pa-6.rounded-xl.elevation-10.border-subtle.d-flex.flex-column.align-center(
v-card.pa-6.rounded-xl.elevation-10.d-flex.flex-column.align-center.review-card(
v-if="currentItem"
color="#1e1e24"
width="100%"
max-width="420"
)
.d-flex.align-center.w-100.mb-6
v-btn.mr-2(
@@ -30,24 +27,25 @@
.text-h3.font-weight-bold.text-white.text-shadow
| {{ currentItem.meaning }}
.canvas-wrapper.mb-2
.review-canvas-area
KanjiCanvas(
ref="kanjiCanvasRef"
:char="currentItem.char"
@complete="handleComplete"
@mistake="handleMistake"
)
transition(name="scale")
v-btn.next-fab.glow-btn.text-black(
v-if="showNext"
@click="next"
color="#00cec9"
color="primary"
icon="mdi-arrow-right"
size="large"
elevation="8"
)
.mb-4
.mb-4.d-flex.gap-2
v-btn.text-caption.font-weight-bold.opacity-80(
variant="text"
color="amber-lighten-1"
@@ -55,7 +53,16 @@
size="small"
:disabled="showNext || statusCode === 'hint'"
@click="triggerHint"
) Show Hint
) {{ $t('review.showHint') }}
v-btn.text-caption.font-weight-bold.opacity-80(
variant="text"
color="purple-lighten-2"
prepend-icon="mdi-school-outline"
size="small"
:disabled="showNext"
@click="redoLesson"
) {{ $t('review.redoLesson') }}
v-sheet.d-flex.align-center.justify-center(
width="100%"
@@ -68,29 +75,25 @@
:class="getStatusClass(statusCode)"
) {{ $t('review.' + statusCode) }}
v-progress-linear.mt-4(
v-progress-linear.mt-4.progress-bar(
v-model="progressPercent"
color="#00cec9"
color="primary"
height="4"
rounded
style="opacity: 0.3;"
)
v-card.pa-8.rounded-xl.elevation-10.border-subtle.text-center(
v-card.pa-8.rounded-xl.elevation-10.text-center.review-card(
v-else-if="sessionTotal > 0 && store.queue.length === 0"
color="#1e1e24"
width="100%"
max-width="400"
)
.mb-6
v-avatar(color="rgba(0, 206, 201, 0.1)" size="80")
v-icon(size="40" color="#00cec9") mdi-trophy
v-icon(size="40" color="primary") mdi-trophy
.text-h4.font-weight-bold.mb-2 {{ $t('review.sessionComplete') }}
.text-body-1.text-grey.mb-8 {{ $t('review.levelup') }}
v-row.mb-6
v-col.border-r.border-grey-darken-3(cols="6")
v-col.border-r.border-subtle(cols="6")
.text-h3.font-weight-bold.text-white {{ accuracy }}%
.text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.accuracy') }}
v-col(cols="6")
@@ -100,7 +103,7 @@
v-btn.text-black.font-weight-bold.glow-btn(
to="/dashboard"
block
color="#00cec9"
color="primary"
height="50"
rounded="lg"
) {{ $t('review.back') }}
@@ -111,19 +114,23 @@
.text-grey.mb-6 {{ $t('review.noReviews') }}
v-btn.font-weight-bold(
to="/dashboard"
color="#00cec9"
color="primary"
variant="tonal"
) {{ $t('review.viewCollection') }}
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue';
/* eslint-disable no-unused-vars */
import {
ref, onMounted, computed, watch,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAppStore } from '@/stores/appStore';
import { useRoute } from 'vue-router';
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
const store = useAppStore();
const route = useRoute();
const router = useRouter();
const currentItem = ref(null);
const statusCode = ref('draw');
@@ -145,6 +152,19 @@ const progressPercent = computed(() => {
return (sessionDone.value / sessionTotal.value) * 100;
});
function loadNext() {
if (store.queue.length === 0) {
currentItem.value = null;
return;
}
const idx = Math.floor(Math.random() * store.queue.length);
currentItem.value = store.queue[idx];
statusCode.value = 'draw';
showNext.value = false;
isFailure.value = false;
}
onMounted(async () => {
const mode = route.query.mode || 'shuffled';
await store.fetchQueue(mode);
@@ -155,10 +175,6 @@ onMounted(async () => {
loadNext();
});
watch(() => store.batchSize, async () => {
resetSession();
});
async function resetSession() {
sessionDone.value = 0;
sessionCorrect.value = 0;
@@ -170,24 +186,15 @@ async function resetSession() {
loadNext();
}
function loadNext() {
if (store.queue.length === 0) {
currentItem.value = null;
return;
}
const idx = Math.floor(Math.random() * store.queue.length);
currentItem.value = store.queue[idx];
statusCode.value = "draw";
showNext.value = false;
isFailure.value = false;
}
watch(() => store.batchSize, async () => {
resetSession();
});
function triggerHint() {
if (!kanjiCanvasRef.value) return;
isFailure.value = true;
statusCode.value = "hint";
statusCode.value = 'hint';
kanjiCanvasRef.value.drawGuide(true);
}
@@ -195,14 +202,14 @@ function triggerHint() {
function handleMistake(isHint) {
if (isHint) {
isFailure.value = true;
statusCode.value = "hint";
statusCode.value = 'hint';
} else {
statusCode.value = "tryAgain";
statusCode.value = 'tryAgain';
}
}
function handleComplete() {
statusCode.value = "correct";
statusCode.value = 'correct';
showNext.value = true;
}
@@ -211,9 +218,10 @@ async function next() {
await store.submitReview(currentItem.value.wkSubjectId, !isFailure.value);
sessionDone.value++;
if (!isFailure.value) sessionCorrect.value++;
const index = store.queue.findIndex(i => i._id === currentItem.value._id);
sessionDone.value += 1;
if (!isFailure.value) sessionCorrect.value += 1;
// eslint-disable-next-line no-underscore-dangle
const index = store.queue.findIndex((i) => i._id === currentItem.value._id);
if (index !== -1) {
store.queue.splice(index, 1);
}
@@ -221,8 +229,19 @@ async function next() {
loadNext();
}
function redoLesson() {
if (!currentItem.value) return;
router.push({
path: '/lesson',
query: { subjectId: currentItem.value.wkSubjectId },
state: { item: JSON.parse(JSON.stringify(currentItem.value)) },
});
}
const getSRSColor = (lvl) => {
const colors = { 1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7' };
const colors = {
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
};
return colors[lvl] || 'grey';
};
@@ -234,5 +253,3 @@ const getStatusClass = (status) => {
}
};
</script>
<style lang="scss" src="@/styles/pages/_review.scss" scoped></style>

View File

@@ -2,16 +2,16 @@
export default {
extends: [
'stylelint-config-standard-scss',
'stylelint-config-recommended-vue'
'stylelint-config-recommended-vue',
],
ignoreFiles: [
'**/node_modules/**',
'**/dist/**',
'**/android/**'
'**/android/**',
],
rules: {
'at-rule-no-unknown': null,
'scss/at-rule-no-unknown': true,
'no-empty-source': null,
}
},
};

View File

@@ -1,19 +1,32 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/abstracts/_variables.scss" as *; @use "@/styles/abstracts/_mixins.scss" as *;`
}
}
}
})
additionalData: '@use "@/styles/abstracts/_variables.scss" as *; @use "@/styles/abstracts/_mixins.scss" as *;',
},
},
},
server: {
allowedHosts: [
'zenkanji.crylia.de',
],
host: true,
port: 5173,
strictPort: true,
hmr: {
host: 'zenkanji.crylia.de',
protocol: 'wss',
clientPort: 443,
},
},
});

View File

@@ -11,8 +11,11 @@ const fastify = Fastify({ logger: true });
await connectDB();
const allowedOrigins = [
'http://192.168.0.26:5169',
'http://192.168.0.26:5173',
'http://localhost:5173',
'http://localhost',
'https://localhost',
'capacitor://localhost',
'https://10.0.2.2:5173',
'https://zenkanji.crylia.de'

View File

@@ -1,4 +1,4 @@
export const PORT = process.env.PORT || 3000;
export const MONGO_URI = process.env.MONGO_URI || 'mongodb://mongo:27017/zenkanji';
export const MONGO_URI = process.env.MONGO_URI || 'mongodb://mongo:27017/zenkanji' || 'mongodb://192.168.0.26:27017/zenkanji';
export const SRS_TIMINGS_HOURS = [0, 0, 4, 8, 23, 47];
export const JWT_SECRET = process.env.JWT_SECRET;

View File

@@ -14,6 +14,12 @@ export const getQueue = async (req, reply) => {
return reply.send(queue);
};
export const getLessonQueue = async (req, reply) => {
const { limit } = req.query;
const queue = await ReviewService.getLessonQueue(req.user, parseInt(limit) || 10);
return reply.send(queue);
};
export const getStats = async (req, reply) => {
const stats = await StatsService.getUserStats(req.user);
return reply.send(stats);

View File

@@ -9,3 +9,13 @@ export const submitReview = async (req, reply) => {
return reply.code(404).send({ error: err.message });
}
};
export const submitLesson = async (req, reply) => {
const { subjectId } = req.body;
try {
const result = await ReviewService.processLesson(req.user, subjectId);
return reply.send(result);
} catch (err) {
return reply.code(404).send({ error: err.message });
}
};

View File

@@ -11,6 +11,11 @@ const studyItemSchema = new mongoose.Schema({
onyomi: { type: [String], default: [] },
kunyomi: { type: [String], default: [] },
nanori: { type: [String], default: [] },
radicals: [{
meaning: String,
char: String,
image: String
}],
stats: {
correct: { type: Number, default: 0 },
total: { type: Number, default: 0 }

View File

@@ -1,7 +1,7 @@
import { login, logout } from '../controllers/auth.controller.js';
import { sync } from '../controllers/sync.controller.js';
import { submitReview } from '../controllers/review.controller.js';
import { getStats, getQueue, getCollection, updateSettings } from '../controllers/collection.controller.js';
import { submitReview, submitLesson } from '../controllers/review.controller.js';
import { getStats, getQueue, getLessonQueue, getCollection, updateSettings } from '../controllers/collection.controller.js';
async function routes(fastify, options) {
fastify.post('/api/auth/login', login);
@@ -12,8 +12,10 @@ async function routes(fastify, options) {
privateParams.post('/api/auth/logout', logout);
privateParams.post('/api/sync', sync);
privateParams.post('/api/review', submitReview);
privateParams.post('/api/lesson', submitLesson);
privateParams.get('/api/stats', getStats);
privateParams.get('/api/queue', getQueue);
privateParams.get('/api/lessons', getLessonQueue);
privateParams.get('/api/collection', getCollection);
privateParams.post('/api/settings', updateSettings);
});

View File

@@ -83,3 +83,22 @@ export const getQueue = async (user, limit = 100, sortMode) => {
}
return dueItems;
};
export const getLessonQueue = async (user, limit = 100) => {
const query = {
userId: user._id,
srsLevel: 0
};
return await StudyItem.find(query).sort({ level: 1, wkSubjectId: 1 }).limit(limit);
};
export const processLesson = async (user, subjectId) => {
const item = await StudyItem.findOne({ userId: user._id, wkSubjectId: subjectId });
if (!item) throw new Error('Item not found');
item.srsLevel = 1;
item.nextReview = new Date();
await item.save();
return { success: true, item };
};

View File

@@ -34,6 +34,11 @@ export const getUserStats = async (user) => {
}).select('srsLevel');
const queueCount = queueItems.length;
const lessonCount = await StudyItem.countDocuments({
userId: userId,
srsLevel: 0
});
let hasLowerLevels = false;
let lowerLevelCount = 0;
if (queueCount > 0) {
@@ -94,6 +99,7 @@ export const getUserStats = async (user) => {
distribution: dist,
forecast: forecast,
queueLength: queueCount,
lessonCount: lessonCount,
hasLowerLevels,
lowerLevelCount,
heatmap: heatmap,

Some files were not shown because too many files have changed in this diff Show More