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 CAP_ENV=dev
VITE_API_URL=https://zenkanji-api.crylia.de

1
client/.gitignore vendored
View File

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

View File

@@ -1,7 +1,10 @@
FROM node:24-alpine AS dev-stage FROM node:24-alpine AS dev-stage
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install
RUN npm ci
COPY . . COPY . .
EXPOSE 5173 EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"] CMD ["npm", "run", "dev", "--", "--host"]
@@ -10,8 +13,3 @@ FROM dev-stage AS build-stage
ARG VITE_API_URL ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
RUN npm run build 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,69 +1,88 @@
import js from '@eslint/js'; import js from '@eslint/js';
import globals from 'globals'; import globals from 'globals';
import pluginVue from 'eslint-plugin-vue'; import pluginVue from 'eslint-plugin-vue';
import pluginVuePug from 'eslint-plugin-vue-pug';
import pluginJsonc from 'eslint-plugin-jsonc'; 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 [ export default [
{ {
ignores: [ ignores: [
'**/node_modules/**', '**/node_modules/**',
'**/dist/**', '**/dist/**',
'**/android/**', '**/android/**',
'**/coverage/**', '**/coverage/**',
'**/*.min.js' '**/*.min.js',
] ],
}, },
js.configs.recommended, ...compat.extends('airbnb-base'),
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
...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'
}
},
...pluginVue.configs['flat/recommended'], js.configs.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/component-name-in-template-casing': ['error', 'PascalCase', {
registeredComponentsOnly: true,
ignores: []
}]
}
},
...pluginJsonc.configs['flat/recommended-with-jsonc'], {
{ languageOptions: {
files: ['*.json', '**/*.json'], ecmaVersion: 'latest',
rules: { sourceType: 'module',
'jsonc/sort-keys': 'off', globals: {
} ...globals.browser,
} ...globals.node,
...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',
'import/extensions': 'off',
'import/no-unresolved': 'off',
'import/prefer-default-export': 'off',
},
},
...pluginVue.configs['flat/recommended'],
{
files: ['*.vue', '**/*.vue'],
languageOptions: {
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': 'off',
'vue/no-parsing-error': 'off',
'vue/component-name-in-template-casing': ['error', 'PascalCase', {
registeredComponentsOnly: true,
ignores: [],
}],
},
},
...pluginJsonc.configs['flat/recommended-with-jsonc'],
{
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> <head>
<meta charset="UTF-8" /> <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> <title>Zen Kanji</title>
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico"> <link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
</head> </head>

15296
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,53 @@
{ {
"name": "zen-kanji-client", "name": "zen-kanji-client",
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"lint:style": "stylelint \"**/*.{css,scss,vue}\"", "lint:style": "stylelint \"**/*.{css,scss,vue}\"",
"lint:style:fix": "stylelint \"**/*.{css,scss,vue}\" --fix", "lint:style:fix": "stylelint \"**/*.{css,scss,vue}\" --fix",
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"build:android": "vite build --mode android", "build:android": "vite build --mode android",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@capacitor/android": "^8.0.0", "@capacitor/android": "^8.0.0",
"@capacitor/core": "^8.0.0", "@capacitor/core": "^8.0.0",
"@mdi/font": "^7.3.67", "@eslint/eslintrc": "^3.3.3",
"capacitor": "^0.5.6", "@eslint/js": "^9.39.2",
"i18n": "^0.15.3", "@mdi/font": "^7.3.67",
"pinia": "^3.0.4", "capacitor": "^0.5.6",
"pug": "^3.0.3", "eslint-plugin-jsonc": "^2.21.0",
"vue": "^3.3.4", "eslint-plugin-vue": "^9.33.0",
"vue-i18n": "^11.2.2", "globals": "^16.5.0",
"vue-pug": "^1.0.2", "i18n": "^0.15.3",
"vue-pug-plugin": "^2.0.4", "perfect-freehand": "^1.2.2",
"vue-router": "^4.2.5", "pinia": "^3.0.4",
"vuetify": "^3.4.0" "pug": "^3.0.3",
}, "vite": "^7.3.0",
"devDependencies": { "vue": "^3.5.26",
"@capacitor/cli": "^7.4.4", "vue-i18n": "^11.2.2",
"@eslint/js": "^9.39.2", "vue-pug": "^1.0.2",
"@vitejs/plugin-vue": "^6.0.3", "vue-pug-plugin": "^2.0.4",
"dotenv": "^17.2.3", "vue-router": "^4.2.5",
"eslint": "^9.39.2", "vuetify": "^3.4.0"
"eslint-plugin-jsonc": "^2.21.0", },
"eslint-plugin-vue": "^9.33.0", "devDependencies": {
"eslint-plugin-vue-pug": "^0.6.2", "@capacitor/cli": "^7.4.4",
"globals": "^16.5.0", "@vitejs/plugin-vue": "^6.0.3",
"sass": "^1.97.0", "dotenv": "^17.2.3",
"stylelint": "^16.26.1", "eslint": "^8.57.1",
"stylelint-config-recommended-vue": "^1.6.1", "eslint-config-airbnb": "^19.0.4",
"stylelint-config-standard-scss": "^16.0.0", "eslint-plugin-import": "^2.32.0",
"vite": "^7.3.0", "eslint-plugin-jsx-a11y": "^6.10.2",
"vue-eslint-parser": "^9.4.3" "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",
"vue-eslint-parser": "^10.2.0"
}
} }

View File

@@ -62,7 +62,7 @@
v-icon(start icon="mdi-logout") v-icon(start icon="mdi-logout")
| {{ $t('nav.logout') }} | {{ $t('nav.logout') }}
v-app-bar.px-2.app-bar-blur( v-app-bar.px-2.app-bar-blur.safe-area-header(
flat flat
color="rgba(30, 30, 36, 0.8)" color="rgba(30, 30, 36, 0.8)"
border="b" border="b"
@@ -189,10 +189,10 @@
.text-center.text-h5.font-weight-bold.text-teal-accent-3.mb-6 .text-center.text-h5.font-weight-bold.text-teal-accent-3.mb-6
| {{ tempBatchSize }} {{ $t('settings.items') }} | {{ tempBatchSize }} {{ $t('settings.items') }}
.text-caption.text-grey.mb-2 Drawing Tolerance .text-caption.text-grey.mb-2 {{ $t('settings.drawingTolerance') }}
v-slider( v-slider(
v-model="tempDrawingAccuracy" v-model="tempDrawingAccuracy"
:min="5" :min="1"
:max="20" :max="20"
:step="1" :step="1"
thumb-label thumb-label
@@ -200,9 +200,9 @@
track-color="grey-darken-3" track-color="grey-darken-3"
) )
.d-flex.justify-space-between.text-caption.text-grey-lighten-1.mb-6.px-1 .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.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') }} .text-caption.text-grey.mb-2 {{ $t('settings.language') }}
v-btn-toggle.d-flex.w-100.border-subtle( v-btn-toggle.d-flex.w-100.border-subtle(
@@ -255,9 +255,10 @@
</template> </template>
<script setup> <script setup>
/* eslint-disable no-unused-vars */
import { ref, watch, onMounted } from 'vue'; import { ref, watch, onMounted } from 'vue';
import { useAppStore } from '@/stores/appStore';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAppStore } from '@/stores/appStore';
import logo from '@/assets/icon.svg'; import logo from '@/assets/icon.svg';
const drawer = ref(false); const drawer = ref(false);
@@ -276,73 +277,86 @@ const tempBatchSize = ref(store.batchSize);
const tempDrawingAccuracy = ref(store.drawingAccuracy); const tempDrawingAccuracy = ref(store.drawingAccuracy);
onMounted(() => { onMounted(() => {
if (store.token) { if (store.token) {
store.fetchStats(); store.fetchStats();
} }
}); });
watch(showSettings, (isOpen) => { watch(showSettings, (isOpen) => {
if (isOpen) { if (isOpen) {
tempBatchSize.value = store.batchSize; tempBatchSize.value = store.batchSize;
tempDrawingAccuracy.value = store.drawingAccuracy; tempDrawingAccuracy.value = store.drawingAccuracy;
} }
}); });
async function handleLogin() { async function manualSync() {
if (!inputKey.value) return; syncing.value = true;
loggingIn.value = true; try {
errorMsg.value = ''; const result = await store.sync();
snackbar.value = { show: true, text: t('alerts.syncSuccess', { count: result.count }), color: 'success' };
try { await store.fetchQueue();
const result = await store.login(inputKey.value.trim()); await store.fetchStats();
if (result.user && !result.user.lastSync) { await store.fetchCollection();
manualSync(); } catch (e) {
} console.error(e);
} catch (e) { snackbar.value = { show: true, text: t('alerts.syncFailed'), color: 'error' };
console.error(e); } finally {
errorMsg.value = e.message || t('login.failed'); syncing.value = false;
} finally { }
loggingIn.value = false;
}
} }
async function manualSync() { async function handleLogin() {
syncing.value = true; if (!inputKey.value) return;
try { loggingIn.value = true;
const result = await store.sync(); errorMsg.value = '';
snackbar.value = { show: true, text: t('alerts.syncSuccess', { count: result.count }), color: 'success' };
await store.fetchQueue(); try {
await store.fetchStats(); const result = await store.login(inputKey.value.trim());
await store.fetchCollection(); if (result.user && !result.user.lastSync) {
} catch (e) { manualSync();
console.error(e); }
snackbar.value = { show: true, text: t('alerts.syncFailed'), color: 'error' }; } catch (e) {
} finally { console.error(e);
syncing.value = false; errorMsg.value = e.message || t('login.failed');
} } finally {
loggingIn.value = false;
}
} }
function saveSettings() { function saveSettings() {
store.saveSettings({ store.saveSettings({
batchSize: tempBatchSize.value, batchSize: tempBatchSize.value,
drawingAccuracy: tempDrawingAccuracy.value drawingAccuracy: tempDrawingAccuracy.value,
}); });
localStorage.setItem('zen_locale', locale.value); localStorage.setItem('zen_locale', locale.value);
showSettings.value = false; showSettings.value = false;
store.fetchQueue(); store.fetchQueue();
} }
function handleLogout() { function handleLogout() {
showLogoutDialog.value = true; showLogoutDialog.value = true;
drawer.value = false; drawer.value = false;
} }
function confirmLogout() { function confirmLogout() {
store.logout(); store.logout();
inputKey.value = ''; inputKey.value = '';
showLogoutDialog.value = false; showLogoutDialog.value = false;
} }
</script> </script>
<style lang="scss" src="@/styles/pages/_app.scss"></style> <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,37 +1,38 @@
<template lang="pug"> <template lang="pug">
v-card.pa-4.rounded-xl.border-subtle.d-flex.align-center.justify-space-between(color="#1e1e24") DashboardWidget(
div :title="$t('stats.accuracy')"
.text-subtitle-2.text-grey {{ $t('stats.accuracy') }} icon="mdi-bullseye-arrow"
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}% )
.text-caption.text-grey .d-flex.align-center.justify-space-between.flex-grow-1
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }} / {{ accuracy?.total || 0 }} {{ $t('stats.total') }} div
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
.text-caption.text-grey.mt-1
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }}
span.mx-1 /
| {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
v-progress-circular( v-progress-circular(
:model-value="accuracyPercent" :model-value="accuracyPercent"
color="#00cec9" color="primary"
size="100" size="80"
width="10" width="8"
bg-color="grey-darken-3" bg-color="grey-darken-3"
) )
v-icon(color="#00cec9" size="large") mdi-bullseye-arrow span.text-caption.font-weight-bold {{ accuracyPercent }}%
</template> </template>
<script setup> <script setup>
/* eslint-disable no-unused-vars */
import { computed } from 'vue'; import { computed } from 'vue';
import DashboardWidget from './DashboardWidget.vue';
const props = defineProps({ const props = defineProps({
accuracy: { accuracy: { type: Object, default: () => ({ correct: 0, total: 0 }) },
type: Object,
default: () => ({
correct: 0,
total: 0
})
}
}); });
const accuracyPercent = computed(() => { const accuracyPercent = computed(() => {
if (!props.accuracy || !props.accuracy.total) return 100; if (!props.accuracy || !props.accuracy.total) return 100;
return Math.round((props.accuracy.correct / props.accuracy.total) * 100); return Math.round((props.accuracy.correct / props.accuracy.total) * 100);
}); });
</script> </script>

View File

@@ -1,8 +1,8 @@
<template lang="pug"> <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 .d-flex.align-center.justify-space-between.mb-4
.text-subtitle-1.font-weight-bold.d-flex.align-center .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') }} | {{ $t('stats.srsDistribution') }}
v-chip.font-weight-bold( v-chip.font-weight-bold(
size="x-small" size="x-small"
@@ -10,7 +10,7 @@
variant="tonal" variant="tonal"
) {{ totalItems }} ) {{ 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( .d-flex.flex-column.align-center.flex-grow-1.srs-column(
v-for="lvl in 10" v-for="lvl in 10"
:key="lvl" :key="lvl"
@@ -20,11 +20,13 @@
) {{ getCount(lvl) }} ) {{ getCount(lvl) }}
.srs-track .srs-track
.srs-fill(:style="{\ .srs-fill(
height: getBarHeight(getCount(lvl)) + '%',\ :class="'bg-srs-' + lvl"
background: getSRSColor(lvl),\ :style="{\
boxShadow: getCount(lvl) > 0 ? `0 0 20px ${getSRSColor(lvl)}30` : 'none'\ height: getBarHeight(getCount(lvl)) + '%',\
}") '--shadow-color': 'var(--srs-' + lvl + ')'\
}"
)
.text-caption.text-grey-darken-1.font-weight-bold.mt-3( .text-caption.text-grey-darken-1.font-weight-bold.mt-3(
style="font-size: 10px !important;" style="font-size: 10px !important;"
@@ -32,41 +34,29 @@
</template> </template>
<script setup> <script setup>
/* eslint-disable no-unused-vars */
import { computed } from 'vue'; import { computed } from 'vue';
const props = defineProps({ const props = defineProps({
distribution: { distribution: { type: Object, default: () => ({}) },
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 getCount = (lvl) => props.distribution?.[lvl] || 0;
const getBarHeight = (count) => { const getBarHeight = (count) => {
const max = Math.max(...Object.values(props.distribution || {}), 1); const max = Math.max(...Object.values(props.distribution || {}), 1);
if (count > 0 && (count / max) * 100 < 4) return 4; if (count > 0 && (count / max) * 100 < 4) return 4;
return (count / max) * 100; 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 toRoman = (num) => {
const lookup = { const lookup = {
1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V', 1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V', 6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X',
6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X' };
}; return lookup[num] || num;
return lookup[num] || num;
}; };
</script> </script>

View File

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

View File

@@ -1,31 +1,38 @@
<template lang="pug"> <template lang="pug">
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24") DashboardWidget(
.d-flex.justify-space-between.align-center.mb-3 :title="$t('stats.ghostTitle')"
.d-flex.align-center :subtitle="$t('stats.ghostSubtitle')"
v-icon(color="#ff7675" start size="small") mdi-ghost icon="mdi-ghost"
div icon-color="var(--srs-1)"
.text-subtitle-2.text-white {{ $t('stats.ghostTitle') }} )
.text-caption.text-grey {{ $t('stats.ghostSubtitle') }} .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 }}
.d-flex.justify-space-between.gap-2(v-if="ghosts && ghosts.length > 0") v-chip.font-weight-bold.w-100.justify-center(
.ghost-card.flex-grow-1(v-for="ghost in ghosts" :key="ghost._id") size="x-small"
.text-h6.font-weight-bold.text-white.mb-1 {{ ghost.char }} class="bg-srs-1 text-black"
v-chip.font-weight-bold.text-black.w-100.justify-center( variant="flat"
size="x-small" ) {{ ghost.accuracy }}%
color="red-accent-2"
variant="flat"
) {{ ghost.accuracy }}%
.text-center.py-2.text-caption.text-grey(v-else) .text-center.py-2.text-caption.text-grey(v-else)
| {{ $t('stats.noGhosts') }} | {{ $t('stats.noGhosts') }}
</template> </template>
<script setup> <script setup>
/* eslint-disable no-unused-vars */
import DashboardWidget from './DashboardWidget.vue';
const props = defineProps({ const props = defineProps({
ghosts: { ghosts: { type: Array, default: () => [] },
type: Array,
default: () => []
}
}); });
</script> </script>

View File

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

View File

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

View File

@@ -1,58 +1,61 @@
<template lang="pug"> <template lang="pug">
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24") DashboardWidget(
.d-flex.justify-space-between.align-start.mb-2 :title="$t('stats.streakTitle')"
div icon="mdi-fire"
.text-subtitle-2.text-grey {{ $t('stats.streakTitle') }} icon-color="var(--srs-1)"
.d-flex.align-center )
.text-h3.font-weight-bold.text-white.mr-2 {{ streak?.current || 0 }} template(#header-right)
.text-h6.text-grey {{ $t('stats.days') }} v-tooltip(location="start" :text="shieldTooltip")
template(v-slot:activator="{ props }")
v-avatar.streak-shield-avatar(
v-bind="props"
size="32"
:class="streak?.shield?.ready ? 'text-primary' : 'text-grey'"
)
v-icon(size="small")
| {{ streak?.shield?.ready ? 'mdi-shield-check' : 'mdi-shield-off-outline' }}
.text-center .d-flex.flex-column.justify-space-between.flex-grow-1
v-tooltip( .d-flex.align-end.mb-3
location="start" .text-h3.font-weight-bold.text-white.mr-2(style="line-height: 1")
:text="streak?.shield?.ready ? $t('stats.shieldActive') : $t('stats.shieldCooldown', { n: streak?.shield?.cooldown })" | {{ 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"
) )
template(v-slot:activator="{ props }") .streak-dot.mb-1(:class="{ 'active': day.active }")
v-avatar( v-icon(v-if="day.active" size="12" color="black") mdi-check
v-bind="props" .text-grey.text-uppercase.streak-day-label
size="48" | {{ getDayLabel(day.date) }}
: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' }"
)
v-icon(:color="streak?.shield?.ready ? '#00cec9' : 'grey'")
| {{ 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.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;")
| {{ getDayLabel(day.date) }}
</template> </template>
<script setup> <script setup>
/* eslint-disable no-unused-vars */
import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import DashboardWidget from './DashboardWidget.vue';
const props = defineProps({ const props = defineProps({
streak: { streak: {
type: Object, 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) => { const getDayLabel = (dateStr) => {
if (!dateStr) return ''; if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString(locale.value, { weekday: 'short' }); return new Date(dateStr).toLocaleDateString(locale.value, { weekday: 'short' });
}; };
</script> </script>

View File

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

View File

@@ -1,247 +1,140 @@
<template lang="pug"> <template lang="pug">
.canvas-container .canvas-container(
.loading-text(v-if="loading") {{ $t('review.loading') }} style="touch-action: none; user-select: none; -webkit-user-select: none; overscroll-behavior: none;"
)
.canvas-wrapper(ref="wrapper") .canvas-wrapper(
canvas( ref="wrapper"
ref="bgCanvas" :class="{ 'shake': isShaking }"
:width="CANVAS_SIZE" :style="{ width: size + 'px', height: size + 'px', touchAction: 'none', userSelect: 'none', overscrollBehavior: 'none' }"
:height="CANVAS_SIZE" )
) canvas(ref="bgCanvas")
canvas( canvas(ref="hintCanvas")
ref="snapCanvas"
:width="CANVAS_SIZE"
:height="CANVAS_SIZE"
)
canvas( canvas(
ref="drawCanvas" ref="drawCanvas"
:width="CANVAS_SIZE" style="touch-action: none; user-select: none; -webkit-user-select: none; overscroll-behavior: none;"
:height="CANVAS_SIZE" @pointerdown="handlePointerDown"
@mousedown="startDraw" @pointermove="handlePointerMove"
@mousemove="draw" @pointerup="handlePointerUp"
@mouseup="endDraw" @pointerleave="handlePointerUp"
@mouseleave="endDraw" @pointercancel="handlePointerUp"
@touchstart.prevent="startDraw" @touchstart.prevent.stop
@touchmove.prevent="draw" @touchmove.prevent.stop
@touchend.prevent="endDraw" @touchend.prevent.stop
@touchcancel.prevent.stop
@contextmenu.prevent
) )
</template> </template>
<script setup> <script setup>
import { ref, onMounted, watch } from 'vue'; /* eslint-disable no-unused-vars */
import { useAppStore } from '@/stores/appStore';
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 store = useAppStore();
const KANJI_SIZE = 109; const props = defineProps({
const CANVAS_SIZE = 300; char: String,
const SCALE = CANVAS_SIZE / KANJI_SIZE; autoHint: Boolean,
size: { type: Number, default: 300 },
});
const emit = defineEmits(['complete', 'mistake']);
const wrapper = ref(null);
const bgCanvas = ref(null); const bgCanvas = ref(null);
const snapCanvas = ref(null); const hintCanvas = ref(null);
const drawCanvas = ref(null); const drawCanvas = ref(null);
let ctxBg, ctxSnap, ctxDraw; const isShaking = ref(false);
const kanjiPaths = ref([]); let controller = null;
const currentStrokeIndex = ref(0);
const failureCount = ref(0); function getPoint(e) {
const loading = ref(false); if (!drawCanvas.value) return { x: 0, y: 0 };
let isDrawing = false; const rect = drawCanvas.value.getBoundingClientRect();
let userPath = []; return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
function handlePointerDown(e) {
if (!controller) return;
if (e.cancelable) e.preventDefault();
e.target.setPointerCapture(e.pointerId);
controller.startStroke(getPoint(e));
}
function handlePointerMove(e) {
if (!controller) return;
if (e.cancelable) e.preventDefault();
controller.moveStroke(getPoint(e));
}
function handlePointerUp(e) {
if (!controller) return;
if (e.cancelable) e.preventDefault();
e.target.releasePointerCapture(e.pointerId);
controller.endStroke();
}
onMounted(() => { onMounted(() => {
initContexts(); controller = new KanjiController({
if (props.char) loadKanji(props.char); size: props.size,
accuracy: store.drawingAccuracy,
onComplete: () => emit('complete'),
onMistake: (needsHint) => {
isShaking.value = true;
setTimeout(() => { isShaking.value = false; }, 400);
emit('mistake', needsHint);
},
});
if (bgCanvas.value && hintCanvas.value && drawCanvas.value) {
controller.mount({
bg: bgCanvas.value,
hint: hintCanvas.value,
draw: drawCanvas.value,
});
}
if (props.char) {
controller.loadChar(props.char, props.autoHint);
}
});
onBeforeUnmount(() => {
controller = null;
}); });
watch(() => props.char, (newChar) => { watch(() => props.char, (newChar) => {
if (newChar) loadKanji(newChar); if (controller && newChar) {
controller.loadChar(newChar, props.autoHint);
}
}); });
function initContexts() { watch(() => props.autoHint, (shouldHint) => {
ctxBg = bgCanvas.value.getContext('2d'); if (!controller) return;
ctxSnap = snapCanvas.value.getContext('2d'); if (shouldHint) controller.showHint();
ctxDraw = drawCanvas.value.getContext('2d'); });
[ctxBg, ctxSnap, ctxDraw].forEach(ctx => { watch(() => props.size, (newSize) => {
ctx.setTransform(1, 0, 0, 1, 0, 0); if (controller) controller.resize(newSize);
ctx.scale(SCALE, SCALE); });
ctx.lineCap = "round";
ctx.lineJoin = "round";
});
}
async function loadKanji(char) { watch(() => store.drawingAccuracy, (newVal) => {
reset(); if (controller) controller.setAccuracy(newVal);
loading.value = true; });
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
try { defineExpose({
const res = await fetch(`https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${hex}.svg`); reset: () => controller?.reset(),
const txt = await res.text(); showHint: () => controller?.showHint(),
const parser = new DOMParser(); drawGuide: () => controller?.showHint(),
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) {
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
};
}
function startDraw(e) {
if (currentStrokeIndex.value >= kanjiPaths.value.length) return;
isDrawing = true;
userPath = [];
const p = getCoords(e);
userPath.push(p);
ctxDraw.beginPath();
ctxDraw.moveTo(p.x, p.y);
ctxDraw.strokeStyle = '#ff7675';
ctxDraw.lineWidth = 4;
}
function draw(e) {
if (!isDrawing) return;
const p = getCoords(e);
userPath.push(p);
ctxDraw.lineTo(p.x, p.y);
ctxDraw.stroke();
}
function endDraw() {
if (!isDrawing) return;
isDrawing = false;
const targetD = kanjiPaths.value[currentStrokeIndex.value];
if (checkMatch(userPath, targetD)) {
ctxSnap.strokeStyle = '#00cec9';
ctxSnap.lineWidth = 4;
ctxSnap.stroke(new Path2D(targetD));
currentStrokeIndex.value++;
failureCount.value = 0;
ctxDraw.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
if (currentStrokeIndex.value >= kanjiPaths.value.length) {
emit('complete');
} else {
drawGuide();
}
} else {
failureCount.value++;
ctxDraw.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
if (failureCount.value >= 3) {
drawGuide(true);
emit('mistake', true);
} else {
emit('mistake', false);
}
}
}
function checkMatch(userPts, targetD) {
if (userPts.length < 5) return false;
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];
const threshold = store.drawingAccuracy || 10;
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 });
</script> </script>
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>

View File

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

View File

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

View File

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

View File

@@ -3,134 +3,200 @@ import { defineStore } from 'pinia';
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
export const useAppStore = defineStore('app', { export const useAppStore = defineStore('app', {
state: () => ({ state: () => ({
token: localStorage.getItem('zen_token') || '', token: localStorage.getItem('zen_token') || '',
user: null, user: null,
queue: [], queue: [],
collection: [], lessonQueue: [],
stats: { collection: [],
distribution: {}, stats: {
forecast: [], distribution: {},
queueLength: 0, forecast: [],
streak: {}, queueLength: 0,
accuracy: {}, lessonCount: 0,
ghosts: [] streak: {},
}, accuracy: {},
batchSize: 20, ghosts: [],
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: { actions: {
async login(apiKey) { async login(apiKey) {
const res = await fetch(`${BASE_URL}/api/auth/login`, { const res = await fetch(`${BASE_URL}/api/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey }) body: JSON.stringify({ apiKey }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed'); if (!res.ok) throw new Error(data.error || 'Login failed');
this.token = data.token; this.token = data.token;
this.user = data.user; this.user = data.user;
if (data.user.settings) { if (data.user.settings) {
this.batchSize = data.user.settings.batchSize || 20; this.batchSize = data.user.settings.batchSize || 20;
this.drawingAccuracy = data.user.settings.drawingAccuracy || 10; this.drawingAccuracy = data.user.settings.drawingAccuracy || 10;
}
localStorage.setItem('zen_token', data.token); // Persist settings to local storage on login
localStorage.setItem('zen_batch_size', this.batchSize);
localStorage.setItem('zen_drawing_accuracy', this.drawingAccuracy);
}
await this.fetchStats(); localStorage.setItem('zen_token', data.token);
return data;
},
async logout() { await this.fetchStats();
try { return data;
if (this.token) { },
await fetch(`${BASE_URL}/api/auth/logout`, {
method: 'POST',
headers: this.getHeaders()
});
}
} catch (e) {
console.error("Logout error:", e);
} finally {
this.clearData();
}
},
clearData() { async logout() {
this.token = ''; try {
this.user = null; if (this.token) {
this.queue = []; await fetch(`${BASE_URL}/api/auth/logout`, {
this.stats = {}; method: 'POST',
localStorage.removeItem('zen_token'); headers: this.getHeaders(),
}, });
}
} catch (e) {
console.error('Logout error:', e);
} finally {
this.clearData();
}
},
getHeaders() { clearData() {
return { this.token = '';
'Authorization': `Bearer ${this.token}`, this.user = null;
'Content-Type': 'application/json' this.queue = [];
}; this.stats = {};
}, localStorage.removeItem('zen_token');
localStorage.removeItem('zen_batch_size');
localStorage.removeItem('zen_drawing_accuracy');
},
async sync() { getHeaders() {
const res = await fetch(`${BASE_URL}/api/sync`, { return {
method: 'POST', Authorization: `Bearer ${this.token}`,
headers: this.getHeaders(), 'Content-Type': 'application/json',
body: JSON.stringify({}) };
}); },
const data = await res.json();
if (!res.ok) throw new Error(data.error);
return data;
},
async fetchStats() { async sync() {
if (!this.token) return; const res = await fetch(`${BASE_URL}/api/sync`, {
const res = await fetch(`${BASE_URL}/api/stats`, { headers: this.getHeaders() }); method: 'POST',
if (res.status === 401) return this.logout(); headers: this.getHeaders(),
const data = await res.json(); body: JSON.stringify({}),
this.stats = data; });
return data; const data = await res.json();
}, if (!res.ok) throw new Error(data.error);
return data;
},
async fetchQueue(sortMode = 'shuffled') { async fetchStats() {
if (!this.token) return; if (!this.token) return null;
const res = await fetch(`${BASE_URL}/api/queue?limit=${this.batchSize}&sort=${sortMode}`, {
headers: this.getHeaders()
});
if (res.status === 401) return this.logout();
this.queue = await res.json();
},
async fetchCollection() { const res = await fetch(`${BASE_URL}/api/stats`, { headers: this.getHeaders() });
if (!this.token) return; if (res.status === 401) {
const res = await fetch(`${BASE_URL}/api/collection`, { headers: this.getHeaders() }); await this.logout();
if (res.status === 401) return this.logout(); return null;
this.collection = await res.json(); }
},
async submitReview(subjectId, success) { const data = await res.json();
const res = await fetch(`${BASE_URL}/api/review`, { this.stats = data;
method: 'POST', return data;
headers: this.getHeaders(), },
body: JSON.stringify({ subjectId, success })
});
if (res.status === 401) return this.logout();
return await res.json();
},
async saveSettings(settings) { async fetchQueue(sortMode = 'shuffled') {
if (settings.batchSize) this.batchSize = settings.batchSize; if (!this.token) return;
if (settings.drawingAccuracy) this.drawingAccuracy = settings.drawingAccuracy;
await fetch(`${BASE_URL}/api/settings`, { const res = await fetch(`${BASE_URL}/api/queue?limit=${this.batchSize}&sort=${sortMode}`, {
method: 'POST', headers: this.getHeaders(),
headers: this.getHeaders(), });
body: JSON.stringify(settings)
}); 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) {
await this.logout();
return;
}
this.collection = await res.json();
},
async submitReview(subjectId, success) {
const res = await fetch(`${BASE_URL}/api/review`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ subjectId, success }),
});
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 !== 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),
});
},
},
}); });

View File

@@ -6,13 +6,32 @@ $color-surface-light: hsl(221deg 17% 22%);
$color-text-white: hsl(0deg 0% 100%); $color-text-white: hsl(0deg 0% 100%);
$color-text-grey: hsl(213deg 14% 70%); $color-text-grey: hsl(213deg 14% 70%);
$color-border: hsl(0deg 0% 100% / 8%); $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-1: hsl(0deg 100% 73%);
$color-srs-2: hsl(39deg 98% 71%); $color-srs-2: hsl(39deg 98% 71%);
$color-srs-3: hsl(163deg 85% 64%); $color-srs-3: hsl(163deg 85% 64%);
$color-srs-4: hsl(206deg 92% 46%); $color-srs-4: hsl(206deg 92% 46%);
$color-srs-5: hsl(244deg 100% 82%); $color-srs-5: hsl(244deg 100% 82%);
$color-srs-6: hsl(247deg 72% 63%); $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-danger: hsl(0deg 85% 65%);
$color-success: hsl(160deg 80% 45%); $color-success: hsl(160deg 80% 45%);
$color-stroke-inactive: hsl(0deg 0% 33%); $color-stroke-inactive: hsl(0deg 0% 33%);
$color-dot-base: hsl(0deg 0% 27%); $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-modal: 100;
$z-tooltip: 200; $z-tooltip: 200;
$z-above: 2; $z-above: 2;
// Neue Z-Indizes aus dem Refactoring
$z-play-btn: 10;
$size-canvas: 300px; $size-canvas: 300px;
$size-kanji-preview: 200px; $size-kanji-preview: 200px;
$size-icon-btn: 32px; $size-icon-btn: 32px;
@@ -26,12 +29,6 @@ $stroke-width-main: 3px;
$stroke-width-arrow: 2px; $stroke-width-arrow: 2px;
$font-size-svg-number: 5px; $font-size-svg-number: 5px;
$radius-xs: 2px; $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-legend-box: 12px;
$size-streak-dot: 24px; $size-streak-dot: 24px;
$size-srs-track: 24px; $size-srs-track: 24px;
@@ -44,3 +41,14 @@ $padding-page-x: 24px;
$breakpoint-md: 960px; $breakpoint-md: 960px;
$offset-fab: 20px; $offset-fab: 20px;
$size-heatmap-cell-height: 10px; $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/variables' as *;
@use '../abstracts/mixins' 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 { .canvas-container {
position: relative; position: relative;
@include flex-center; @include flex-center;
margin-bottom: $spacing-xl;
} }
// The main drawing canvas wrapper
.canvas-wrapper { .canvas-wrapper {
position: relative;
width: $size-canvas; width: $size-canvas;
height: $size-canvas; height: $size-canvas;
background: $color-surface;
border-radius: $radius-lg; border-radius: $radius-lg;
background: rgba($color-surface, 0.95); border: $border-subtle;
border: $border-width-md solid $color-surface-light;
position: relative;
cursor: crosshair;
box-shadow: $shadow-inset; box-shadow: $shadow-inset;
overflow: hidden;
cursor: crosshair;
display: flex;
align-items: center;
justify-content: center;
canvas { canvas {
position: absolute; position: absolute;
@@ -26,39 +56,66 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
touch-action: none; touch-action: none;
z-index: $z-above;
} }
} }
.loading-text { // The SVG Viewer wrapper (Review/Lesson/Collection)
position: absolute; .svg-container {
color: $color-text-grey; width: 100%;
z-index: $z-sticky; height: 100%;
font-size: $font-sm; position: relative;
font-weight: $weight-medium; display: flex;
letter-spacing: $tracking-wide; 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 { .kanji-svg {
width: $size-kanji-preview; z-index: $z-above;
height: $size-kanji-preview; display: block;
margin: 0 auto $spacing-lg; padding: 12%;
background: rgba($color-bg-dark, 0.2); width: 100%;
border-radius: $radius-md; height: 100%;
border: $border-subtle;
position: relative;
@include flex-center;
overflow: hidden;
} }
.stroke-path { .stroke-path {
fill: none; fill: none;
stroke: $color-stroke-inactive;
stroke-width: $stroke-width-main;
stroke-linecap: round; stroke-linecap: round;
stroke-linejoin: 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 { &.hidden {
opacity: 0; opacity: 0;
@@ -70,10 +127,75 @@
stroke-dashoffset: var(--len); stroke-dashoffset: var(--len);
animation: draw-stroke var(--duration) linear forwards; animation: draw-stroke var(--duration) linear forwards;
} }
}
&.drawn { .stroke-ghost {
stroke: $color-text-white; fill: none;
opacity: 1; 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 { @keyframes shake-x {
fill: $color-srs-1; 0%,
} 100% {
transform: translateX(0);
.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;
} }
&:active { 20% {
transform: scale(0.95); transform: translateX(-6px);
} }
svg { 40% {
width: $size-icon-small; transform: translateX(6px);
height: $size-icon-small; }
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; @include card-base;
border: $border-subtle; border: $border-subtle;
background-color: $color-surface !important;
border-radius: $radius-xl !important;
} }
.level-0 { .welcome-btn {
background-color: $bg-glass-subtle; height: $size-button-large-height !important;
width: $size-button-large-width !important;
.v-chip {
color: white !important;
background-color: $color-surface !important;
}
} }
.level-1 { .streak-shield-avatar {
background-color: color.mix($color-primary, $color-surface, 25%); border: 1px solid currentcolor;
background: rgb(255 255 255 / 5%);
} }
.level-2 { .streak-day-label {
background-color: color.mix($color-primary, $color-surface, 50%); font-size: 9px !important;
}
.level-3 {
background-color: $color-primary;
}
.level-4 {
background-color: color.scale($color-primary, $lightness: 40%);
} }
.heatmap-container { .heatmap-container {
@@ -72,6 +73,26 @@
box-sizing: border-box; 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 { .legend-container {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -153,22 +174,16 @@
@include scrollbar; @include scrollbar;
} }
.gap-1 { .srs-chart-container {
gap: $spacing-xs; gap: 8px;
@media (max-width: 600px) {
gap: 4px;
}
} }
.gap-2 { .srs-track {
gap: $spacing-sm; @media (max-width: 400px) {
} width: 6px;
}
.border-subtle {
border: $border-subtle;
}
.border-b-subtle {
border-bottom: $border-subtle;
}
.border-t-subtle {
border-top: $border-subtle;
} }

View File

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

View File

@@ -2,6 +2,11 @@
:root { :root {
--v-theme-background: #{$color-bg-dark}; --v-theme-background: #{$color-bg-dark};
--color-surface: #{$color-surface};
@each $level, $color in $srs-colors {
--srs-#{$level}: #{$color};
}
} }
body, body,
@@ -59,3 +64,13 @@ html,
opacity: $opacity-hover; 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/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: $text-shadow; text-shadow: $text-shadow;
} }
.canvas-wrapper { .review-canvas-area {
position: relative; position: relative;
width: $size-canvas; margin-bottom: $spacing-sm;
height: $size-canvas;
border-radius: $radius-lg;
background: $bg-glass-dark;
box-shadow: $shadow-inset;
.next-fab { .next-fab {
position: absolute; position: absolute;
bottom: -#{$offset-fab}; bottom: 20px;
right: -#{$offset-fab}; right: 20px;
z-index: $z-sticky; z-index: $z-sticky;
} }
} }
@@ -45,3 +49,15 @@
opacity: 0; opacity: 0;
transform: translateY(-#{$dist-slide-sm}); 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-h2.font-weight-bold.mb-1 {{ selectedItem?.char }}
.text-subtitle-1.text-grey-lighten-1.text-capitalize.mb-4 {{ selectedItem?.meaning }} .text-subtitle-1.text-grey-lighten-1.text-capitalize.mb-4 {{ selectedItem?.meaning }}
KanjiSvgViewer( KanjiSvgViewer.mb-4(
v-if="showModal && selectedItem" v-if="showModal && selectedItem"
:char="selectedItem.char" :char="selectedItem.char"
) )
@@ -97,21 +97,35 @@
.reading-label {{ $t('collection.nanori') }} .reading-label {{ $t('collection.nanori') }}
.reading-value {{ selectedItem?.nanori.join(', ') }} .reading-value {{ selectedItem?.nanori.join(', ') }}
v-btn.text-white( v-row.px-2.pb-2
block v-col.pr-2(cols="6")
color="#2f3542" v-btn.text-white(
@click="showModal = false" block
) {{ $t('collection.close') }} 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> </template>
<script setup> <script setup>
/* eslint-disable no-unused-vars */
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useAppStore } from '@/stores/appStore';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAppStore } from '@/stores/appStore';
import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue'; import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue';
const { t } = useI18n(); const { t } = useI18n();
const store = useAppStore(); const store = useAppStore();
const router = useRouter();
const loading = ref(true); const loading = ref(true);
const showModal = ref(false); const showModal = ref(false);
@@ -119,62 +133,77 @@ const selectedItem = ref(null);
const searchQuery = ref(''); const searchQuery = ref('');
onMounted(async () => { onMounted(async () => {
await store.fetchCollection(); await store.fetchCollection();
loading.value = false; loading.value = false;
}); });
const filteredCollection = computed(() => { const filteredCollection = computed(() => {
if (!searchQuery.value) return store.collection; if (!searchQuery.value) return store.collection;
const q = searchQuery.value.toLowerCase().trim(); 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.meaning && item.meaning.toLowerCase().includes(q)) return true;
if (item.char && item.char.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.onyomi && item.onyomi.some((r) => r.includes(q))) return true;
if (item.kunyomi && item.kunyomi.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.nanori && item.nanori.some((r) => r.includes(q))) return true;
return false; return false;
}); });
}); });
const groupedItems = computed(() => { const groupedItems = computed(() => {
const groups = {}; const groups = {};
filteredCollection.value.forEach(i => { filteredCollection.value.forEach((i) => {
if (!groups[i.level]) groups[i.level] = []; if (!groups[i.level]) groups[i.level] = [];
groups[i.level].push(i); groups[i.level].push(i);
}); });
return groups; return groups;
}); });
const SRS_COLORS = { const SRS_COLORS = {
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 1: '#ff7675',
4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7', 2: '#fdcb6e',
7: '#00cec9', 8: '#fd79a8', 9: '#e84393', 3: '#55efc4',
10: '#ffd700' 4: '#0984e3',
5: '#a29bfe',
6: '#6c5ce7',
7: '#00cec9',
8: '#fd79a8',
9: '#e84393',
10: '#ffd700',
}; };
const getSRSColor = (lvl) => SRS_COLORS[lvl] || '#444'; const getSRSColor = (lvl) => SRS_COLORS[lvl] || '#444';
const openDetail = (item) => { const openDetail = (item) => {
selectedItem.value = item; selectedItem.value = item;
showModal.value = true; 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 hasReading = (arr) => arr && arr.length > 0;
const getNextReviewText = (dateStr, srsLvl) => { const getNextReviewText = (dateStr, srsLvl) => {
if (srsLvl >= 10) return 'COMPLETE'; if (srsLvl >= 10) return 'COMPLETE';
const date = new Date(dateStr); const date = new Date(dateStr);
const now = new Date(); const now = new Date();
if (date <= now) return t('stats.availableNow'); if (date <= now) return t('stats.availableNow');
const diffMs = date - now; const diffMs = date - now;
const diffHrs = Math.floor(diffMs / 3600000); const diffHrs = Math.floor(diffMs / 3600000);
if (diffHrs < 24) return t('stats.inHours', { n: diffHrs }, diffHrs); if (diffHrs < 24) return t('stats.inHours', { n: diffHrs }, diffHrs);
const diffDays = Math.floor(diffHrs / 24); const diffDays = Math.floor(diffHrs / 24);
return `Next: ${diffDays}d`; return `Next: ${diffDays}d`;
}; };
</script> </script>

View File

@@ -2,6 +2,7 @@
.dashboard-layout.fade-in .dashboard-layout.fade-in
WidgetWelcome( WidgetWelcome(
:queue-length="stats.queueLength" :queue-length="stats.queueLength"
:lesson-count="stats.lessonCount"
:has-lower-levels="stats.hasLowerLevels" :has-lower-levels="stats.hasLowerLevels"
:lower-level-count="stats.lowerLevelCount" :lower-level-count="stats.lowerLevelCount"
:forecast="stats.forecast" :forecast="stats.forecast"
@@ -31,9 +32,10 @@
</template> </template>
<script setup> <script setup>
/* eslint-disable no-unused-vars */
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useAppStore } from '@/stores/appStore';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useAppStore } from '@/stores/appStore';
import WidgetWelcome from '@/components/dashboard/WidgetWelcome.vue'; import WidgetWelcome from '@/components/dashboard/WidgetWelcome.vue';
import WidgetHeatmap from '@/components/dashboard/WidgetHeatmap.vue'; import WidgetHeatmap from '@/components/dashboard/WidgetHeatmap.vue';
@@ -49,32 +51,33 @@ const router = useRouter();
const loading = ref(true); const loading = ref(true);
const stats = ref({ const stats = ref({
queueLength: 0, queueLength: 0,
hasLowerLevels: false, lessonCount: 0,
lowerLevelCount: 0, hasLowerLevels: false,
distribution: {}, lowerLevelCount: 0,
forecast: [], distribution: {},
ghosts: [], forecast: [],
accuracy: { total: 0, correct: 0 }, ghosts: [],
heatmap: {}, accuracy: { total: 0, correct: 0 },
streak: { current: 0, history: [], shield: { ready: false } } heatmap: {},
streak: { current: 0, history: [], shield: { ready: false } },
}); });
onMounted(async () => { onMounted(async () => {
try { try {
const data = await store.fetchStats(); const data = await store.fetchStats();
if (data) { if (data) {
stats.value = { ...stats.value, ...data }; stats.value = { ...stats.value, ...data };
} }
} catch (e) { } catch (e) {
console.error("Dashboard Load Error:", e); console.error('Dashboard Load Error:', e);
} finally { } finally {
loading.value = false; loading.value = false;
} }
}); });
function handleStart(mode) { function handleStart(mode) {
router.push({ path: '/review', query: { mode: mode } }); router.push({ path: '/review', query: { mode } });
} }
</script> </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"> <template lang="pug">
v-container.fill-height.justify-center.pa-4 v-container.fill-height.justify-center.pa-4
v-fade-transition(mode="out-in") 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" v-if="currentItem"
color="#1e1e24"
width="100%"
max-width="420"
) )
.d-flex.align-center.w-100.mb-6 .d-flex.align-center.w-100.mb-6
v-btn.mr-2( v-btn.mr-2(
@@ -30,24 +27,25 @@
.text-h3.font-weight-bold.text-white.text-shadow .text-h3.font-weight-bold.text-white.text-shadow
| {{ currentItem.meaning }} | {{ currentItem.meaning }}
.canvas-wrapper.mb-2 .review-canvas-area
KanjiCanvas( KanjiCanvas(
ref="kanjiCanvasRef" ref="kanjiCanvasRef"
:char="currentItem.char" :char="currentItem.char"
@complete="handleComplete" @complete="handleComplete"
@mistake="handleMistake" @mistake="handleMistake"
) )
transition(name="scale") transition(name="scale")
v-btn.next-fab.glow-btn.text-black( v-btn.next-fab.glow-btn.text-black(
v-if="showNext" v-if="showNext"
@click="next" @click="next"
color="#00cec9" color="primary"
icon="mdi-arrow-right" icon="mdi-arrow-right"
size="large" size="large"
elevation="8" elevation="8"
) )
.mb-4 .mb-4.d-flex.gap-2
v-btn.text-caption.font-weight-bold.opacity-80( v-btn.text-caption.font-weight-bold.opacity-80(
variant="text" variant="text"
color="amber-lighten-1" color="amber-lighten-1"
@@ -55,7 +53,16 @@
size="small" size="small"
:disabled="showNext || statusCode === 'hint'" :disabled="showNext || statusCode === 'hint'"
@click="triggerHint" @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( v-sheet.d-flex.align-center.justify-center(
width="100%" width="100%"
@@ -68,29 +75,25 @@
:class="getStatusClass(statusCode)" :class="getStatusClass(statusCode)"
) {{ $t('review.' + statusCode) }} ) {{ $t('review.' + statusCode) }}
v-progress-linear.mt-4( v-progress-linear.mt-4.progress-bar(
v-model="progressPercent" v-model="progressPercent"
color="#00cec9" color="primary"
height="4" height="4"
rounded 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" v-else-if="sessionTotal > 0 && store.queue.length === 0"
color="#1e1e24"
width="100%"
max-width="400"
) )
.mb-6 .mb-6
v-avatar(color="rgba(0, 206, 201, 0.1)" size="80") 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-h4.font-weight-bold.mb-2 {{ $t('review.sessionComplete') }}
.text-body-1.text-grey.mb-8 {{ $t('review.levelup') }} .text-body-1.text-grey.mb-8 {{ $t('review.levelup') }}
v-row.mb-6 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-h3.font-weight-bold.text-white {{ accuracy }}%
.text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.accuracy') }} .text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.accuracy') }}
v-col(cols="6") v-col(cols="6")
@@ -100,7 +103,7 @@
v-btn.text-black.font-weight-bold.glow-btn( v-btn.text-black.font-weight-bold.glow-btn(
to="/dashboard" to="/dashboard"
block block
color="#00cec9" color="primary"
height="50" height="50"
rounded="lg" rounded="lg"
) {{ $t('review.back') }} ) {{ $t('review.back') }}
@@ -111,19 +114,23 @@
.text-grey.mb-6 {{ $t('review.noReviews') }} .text-grey.mb-6 {{ $t('review.noReviews') }}
v-btn.font-weight-bold( v-btn.font-weight-bold(
to="/dashboard" to="/dashboard"
color="#00cec9" color="primary"
variant="tonal" variant="tonal"
) {{ $t('review.viewCollection') }} ) {{ $t('review.viewCollection') }}
</template> </template>
<script setup> <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 { useAppStore } from '@/stores/appStore';
import { useRoute } from 'vue-router';
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue'; import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
const store = useAppStore(); const store = useAppStore();
const route = useRoute(); const route = useRoute();
const router = useRouter();
const currentItem = ref(null); const currentItem = ref(null);
const statusCode = ref('draw'); const statusCode = ref('draw');
@@ -136,103 +143,113 @@ const sessionDone = ref(0);
const sessionCorrect = ref(0); const sessionCorrect = ref(0);
const accuracy = computed(() => { const accuracy = computed(() => {
if (sessionDone.value === 0) return 100; if (sessionDone.value === 0) return 100;
return Math.round((sessionCorrect.value / sessionDone.value) * 100); return Math.round((sessionCorrect.value / sessionDone.value) * 100);
}); });
const progressPercent = computed(() => { const progressPercent = computed(() => {
if (sessionTotal.value === 0) return 0; if (sessionTotal.value === 0) return 0;
return (sessionDone.value / sessionTotal.value) * 100; 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 () => { onMounted(async () => {
const mode = route.query.mode || 'shuffled'; const mode = route.query.mode || 'shuffled';
await store.fetchQueue(mode); await store.fetchQueue(mode);
if (sessionTotal.value === 0 && store.queue.length > 0) { if (sessionTotal.value === 0 && store.queue.length > 0) {
sessionTotal.value = store.queue.length; sessionTotal.value = store.queue.length;
} }
loadNext(); loadNext();
});
watch(() => store.batchSize, async () => {
resetSession();
}); });
async function resetSession() { async function resetSession() {
sessionDone.value = 0; sessionDone.value = 0;
sessionCorrect.value = 0; sessionCorrect.value = 0;
sessionTotal.value = 0; sessionTotal.value = 0;
currentItem.value = null; currentItem.value = null;
const mode = route.query.mode || 'shuffled'; const mode = route.query.mode || 'shuffled';
await store.fetchQueue(mode); await store.fetchQueue(mode);
if (store.queue.length > 0) sessionTotal.value = store.queue.length; if (store.queue.length > 0) sessionTotal.value = store.queue.length;
loadNext(); loadNext();
} }
function loadNext() { watch(() => store.batchSize, async () => {
if (store.queue.length === 0) { resetSession();
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;
}
function triggerHint() { function triggerHint() {
if (!kanjiCanvasRef.value) return; if (!kanjiCanvasRef.value) return;
isFailure.value = true; isFailure.value = true;
statusCode.value = "hint"; statusCode.value = 'hint';
kanjiCanvasRef.value.drawGuide(true); kanjiCanvasRef.value.drawGuide(true);
} }
function handleMistake(isHint) { function handleMistake(isHint) {
if (isHint) { if (isHint) {
isFailure.value = true; isFailure.value = true;
statusCode.value = "hint"; statusCode.value = 'hint';
} else { } else {
statusCode.value = "tryAgain"; statusCode.value = 'tryAgain';
} }
} }
function handleComplete() { function handleComplete() {
statusCode.value = "correct"; statusCode.value = 'correct';
showNext.value = true; showNext.value = true;
} }
async function next() { async function next() {
if (!currentItem.value) return; if (!currentItem.value) return;
await store.submitReview(currentItem.value.wkSubjectId, !isFailure.value); await store.submitReview(currentItem.value.wkSubjectId, !isFailure.value);
sessionDone.value++; sessionDone.value += 1;
if (!isFailure.value) sessionCorrect.value++; if (!isFailure.value) sessionCorrect.value += 1;
const index = store.queue.findIndex(i => i._id === currentItem.value._id); // eslint-disable-next-line no-underscore-dangle
if (index !== -1) { const index = store.queue.findIndex((i) => i._id === currentItem.value._id);
store.queue.splice(index, 1); if (index !== -1) {
} store.queue.splice(index, 1);
}
loadNext(); 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 getSRSColor = (lvl) => {
const colors = { 1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7' }; const colors = {
return colors[lvl] || 'grey'; 1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
};
return colors[lvl] || 'grey';
}; };
const getStatusClass = (status) => { const getStatusClass = (status) => {
switch (status) { switch (status) {
case 'hint': return 'text-red-lighten-1'; case 'hint': return 'text-red-lighten-1';
case 'correct': return 'text-teal-accent-3'; case 'correct': return 'text-teal-accent-3';
default: return 'text-grey-lighten-1'; default: return 'text-grey-lighten-1';
} }
}; };
</script> </script>
<style lang="scss" src="@/styles/pages/_review.scss" scoped></style>

View File

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

View File

@@ -1,19 +1,32 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue';
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url)),
} },
}, },
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { 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(); await connectDB();
const allowedOrigins = [ const allowedOrigins = [
'http://192.168.0.26:5169',
'http://192.168.0.26:5173',
'http://localhost:5173', 'http://localhost:5173',
'http://localhost', 'http://localhost',
'https://localhost',
'capacitor://localhost', 'capacitor://localhost',
'https://10.0.2.2:5173', 'https://10.0.2.2:5173',
'https://zenkanji.crylia.de' 'https://zenkanji.crylia.de'

View File

@@ -1,4 +1,4 @@
export const PORT = process.env.PORT || 3000; 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 SRS_TIMINGS_HOURS = [0, 0, 4, 8, 23, 47];
export const JWT_SECRET = process.env.JWT_SECRET; export const JWT_SECRET = process.env.JWT_SECRET;

View File

@@ -14,6 +14,12 @@ export const getQueue = async (req, reply) => {
return reply.send(queue); 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) => { export const getStats = async (req, reply) => {
const stats = await StatsService.getUserStats(req.user); const stats = await StatsService.getUserStats(req.user);
return reply.send(stats); return reply.send(stats);

View File

@@ -9,3 +9,13 @@ export const submitReview = async (req, reply) => {
return reply.code(404).send({ error: err.message }); 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: [] }, onyomi: { type: [String], default: [] },
kunyomi: { type: [String], default: [] }, kunyomi: { type: [String], default: [] },
nanori: { type: [String], default: [] }, nanori: { type: [String], default: [] },
radicals: [{
meaning: String,
char: String,
image: String
}],
stats: { stats: {
correct: { type: Number, default: 0 }, correct: { type: Number, default: 0 },
total: { type: Number, default: 0 } total: { type: Number, default: 0 }

View File

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

View File

@@ -83,3 +83,22 @@ export const getQueue = async (user, limit = 100, sortMode) => {
} }
return dueItems; 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'); }).select('srsLevel');
const queueCount = queueItems.length; const queueCount = queueItems.length;
const lessonCount = await StudyItem.countDocuments({
userId: userId,
srsLevel: 0
});
let hasLowerLevels = false; let hasLowerLevels = false;
let lowerLevelCount = 0; let lowerLevelCount = 0;
if (queueCount > 0) { if (queueCount > 0) {
@@ -94,6 +99,7 @@ export const getUserStats = async (user) => {
distribution: dist, distribution: dist,
forecast: forecast, forecast: forecast,
queueLength: queueCount, queueLength: queueCount,
lessonCount: lessonCount,
hasLowerLevels, hasLowerLevels,
lowerLevelCount, lowerLevelCount,
heatmap: heatmap, heatmap: heatmap,

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