add new lesson mode and started code refraction
@@ -1,2 +1,2 @@
|
||||
VITE_API_URL=http://10.0.2.2:3000
|
||||
CAP_ENV=dev
|
||||
VITE_API_URL=https://zenkanji-api.crylia.de
|
||||
|
||||
1
client/.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
node_modules
|
||||
.env
|
||||
.env.android
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
FROM node:24-alpine AS dev-stage
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
@@ -10,8 +13,3 @@ FROM dev-stage AS build-stage
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:stable-alpine AS production-stage
|
||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
101
client/android/.gitignore
vendored
Normal file
@@ -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
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
64
client/android/app/build.gradle
Normal 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'
|
||||
}
|
||||
}
|
||||
19
client/android/app/capacitor.build.gradle
Normal 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
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
42
client/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.zenkanji.app;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
BIN
client/android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
client/android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
client/android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
client/android/app/src/main/res/drawable-land-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
client/android/app/src/main/res/drawable-land-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
client/android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
client/android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
client/android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
client/android/app/src/main/res/drawable-port-xxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
client/android/app/src/main/res/drawable-port-xxxhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
BIN
client/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
client/android/app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
BIN
client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
BIN
client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
BIN
client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
BIN
client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
BIN
client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
7
client/android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
22
client/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
5
client/android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
29
client/android/build.gradle
Normal 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
|
||||
}
|
||||
3
client/android/capacitor.settings.gradle
Normal 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')
|
||||
22
client/android/gradle.properties
Normal 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
|
||||
BIN
client/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
client/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@@ -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
@@ -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
|
||||
5
client/android/settings.gradle
Normal 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'
|
||||
16
client/android/variables.gradle
Normal 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'
|
||||
}
|
||||
@@ -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;
|
||||
9
client/capacitor.config.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"appId": "com.zenkanji.app",
|
||||
"appName": "Zen Kanji",
|
||||
"webDir": "dist",
|
||||
"server": {
|
||||
"androidScheme": "http",
|
||||
"cleartext": true
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,88 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import pluginVue from 'eslint-plugin-vue';
|
||||
import pluginVuePug from 'eslint-plugin-vue-pug';
|
||||
import pluginJsonc from 'eslint-plugin-jsonc';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: dirname,
|
||||
});
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/android/**',
|
||||
'**/coverage/**',
|
||||
'**/*.min.js'
|
||||
]
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/android/**',
|
||||
'**/coverage/**',
|
||||
'**/*.min.js',
|
||||
],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
{
|
||||
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'
|
||||
}
|
||||
},
|
||||
...compat.extends('airbnb-base'),
|
||||
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['*.vue', '**/*.vue'],
|
||||
plugins: {
|
||||
'vue-pug': pluginVuePug
|
||||
},
|
||||
languageOptions: {
|
||||
parser: pluginVue.parser,
|
||||
parserOptions: {
|
||||
parser: js.configs.recommended.parser,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/require-default-prop': 'off',
|
||||
'vue/html-indent': ['error', 2],
|
||||
'no-unused-vars': 'off',
|
||||
'vue/component-name-in-template-casing': ['error', 'PascalCase', {
|
||||
registeredComponentsOnly: true,
|
||||
ignores: []
|
||||
}]
|
||||
}
|
||||
},
|
||||
js.configs.recommended,
|
||||
|
||||
...pluginJsonc.configs['flat/recommended-with-jsonc'],
|
||||
{
|
||||
files: ['*.json', '**/*.json'],
|
||||
rules: {
|
||||
'jsonc/sort-keys': 'off',
|
||||
}
|
||||
}
|
||||
{
|
||||
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',
|
||||
'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',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Zen Kanji</title>
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon.ico">
|
||||
</head>
|
||||
|
||||
15296
client/package-lock.json
generated
@@ -1,47 +1,53 @@
|
||||
{
|
||||
"name": "zen-kanji-client",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:style": "stylelint \"**/*.{css,scss,vue}\"",
|
||||
"lint:style:fix": "stylelint \"**/*.{css,scss,vue}\" --fix",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:android": "vite build --mode android",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@mdi/font": "^7.3.67",
|
||||
"capacitor": "^0.5.6",
|
||||
"i18n": "^0.15.3",
|
||||
"pinia": "^3.0.4",
|
||||
"pug": "^3.0.3",
|
||||
"vue": "^3.3.4",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-pug": "^1.0.2",
|
||||
"vue-pug-plugin": "^2.0.4",
|
||||
"vue-router": "^4.2.5",
|
||||
"vuetify": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^7.4.4",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-jsonc": "^2.21.0",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"eslint-plugin-vue-pug": "^0.6.2",
|
||||
"globals": "^16.5.0",
|
||||
"sass": "^1.97.0",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-recommended-vue": "^1.6.1",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vue-eslint-parser": "^9.4.3"
|
||||
}
|
||||
"name": "zen-kanji-client",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"lint:style": "stylelint \"**/*.{css,scss,vue}\"",
|
||||
"lint:style:fix": "stylelint \"**/*.{css,scss,vue}\" --fix",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:android": "vite build --mode android",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@mdi/font": "^7.3.67",
|
||||
"capacitor": "^0.5.6",
|
||||
"eslint-plugin-jsonc": "^2.21.0",
|
||||
"eslint-plugin-vue": "^9.33.0",
|
||||
"globals": "^16.5.0",
|
||||
"i18n": "^0.15.3",
|
||||
"perfect-freehand": "^1.2.2",
|
||||
"pinia": "^3.0.4",
|
||||
"pug": "^3.0.3",
|
||||
"vite": "^7.3.0",
|
||||
"vue": "^3.5.26",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-pug": "^1.0.2",
|
||||
"vue-pug-plugin": "^2.0.4",
|
||||
"vue-router": "^4.2.5",
|
||||
"vuetify": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^7.4.4",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"sass": "^1.97.1",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-recommended-vue": "^1.6.1",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
v-icon(start icon="mdi-logout")
|
||||
| {{ $t('nav.logout') }}
|
||||
|
||||
v-app-bar.px-2.app-bar-blur(
|
||||
v-app-bar.px-2.app-bar-blur.safe-area-header(
|
||||
flat
|
||||
color="rgba(30, 30, 36, 0.8)"
|
||||
border="b"
|
||||
@@ -189,10 +189,10 @@
|
||||
.text-center.text-h5.font-weight-bold.text-teal-accent-3.mb-6
|
||||
| {{ tempBatchSize }} {{ $t('settings.items') }}
|
||||
|
||||
.text-caption.text-grey.mb-2 Drawing Tolerance
|
||||
.text-caption.text-grey.mb-2 {{ $t('settings.drawingTolerance') }}
|
||||
v-slider(
|
||||
v-model="tempDrawingAccuracy"
|
||||
:min="5"
|
||||
:min="1"
|
||||
:max="20"
|
||||
:step="1"
|
||||
thumb-label
|
||||
@@ -200,9 +200,9 @@
|
||||
track-color="grey-darken-3"
|
||||
)
|
||||
.d-flex.justify-space-between.text-caption.text-grey-lighten-1.mb-6.px-1
|
||||
span Strict (5)
|
||||
span {{ $t('settings.strict') }} (5)
|
||||
span.font-weight-bold.text-body-1(color="#00cec9") {{ tempDrawingAccuracy }}
|
||||
span Loose (20)
|
||||
span {{ $t('settings.loose') }} (20)
|
||||
|
||||
.text-caption.text-grey.mb-2 {{ $t('settings.language') }}
|
||||
v-btn-toggle.d-flex.w-100.border-subtle(
|
||||
@@ -255,9 +255,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import logo from '@/assets/icon.svg';
|
||||
|
||||
const drawer = ref(false);
|
||||
@@ -276,73 +277,86 @@ const tempBatchSize = ref(store.batchSize);
|
||||
const tempDrawingAccuracy = ref(store.drawingAccuracy);
|
||||
|
||||
onMounted(() => {
|
||||
if (store.token) {
|
||||
store.fetchStats();
|
||||
}
|
||||
if (store.token) {
|
||||
store.fetchStats();
|
||||
}
|
||||
});
|
||||
|
||||
watch(showSettings, (isOpen) => {
|
||||
if (isOpen) {
|
||||
tempBatchSize.value = store.batchSize;
|
||||
tempDrawingAccuracy.value = store.drawingAccuracy;
|
||||
}
|
||||
if (isOpen) {
|
||||
tempBatchSize.value = store.batchSize;
|
||||
tempDrawingAccuracy.value = store.drawingAccuracy;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
if (!inputKey.value) return;
|
||||
loggingIn.value = true;
|
||||
errorMsg.value = '';
|
||||
|
||||
try {
|
||||
const result = await store.login(inputKey.value.trim());
|
||||
if (result.user && !result.user.lastSync) {
|
||||
manualSync();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
errorMsg.value = e.message || t('login.failed');
|
||||
} finally {
|
||||
loggingIn.value = false;
|
||||
}
|
||||
async function manualSync() {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const result = await store.sync();
|
||||
snackbar.value = { show: true, text: t('alerts.syncSuccess', { count: result.count }), color: 'success' };
|
||||
await store.fetchQueue();
|
||||
await store.fetchStats();
|
||||
await store.fetchCollection();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
snackbar.value = { show: true, text: t('alerts.syncFailed'), color: 'error' };
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function manualSync() {
|
||||
syncing.value = true;
|
||||
try {
|
||||
const result = await store.sync();
|
||||
snackbar.value = { show: true, text: t('alerts.syncSuccess', { count: result.count }), color: 'success' };
|
||||
await store.fetchQueue();
|
||||
await store.fetchStats();
|
||||
await store.fetchCollection();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
snackbar.value = { show: true, text: t('alerts.syncFailed'), color: 'error' };
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
async function handleLogin() {
|
||||
if (!inputKey.value) return;
|
||||
loggingIn.value = true;
|
||||
errorMsg.value = '';
|
||||
|
||||
try {
|
||||
const result = await store.login(inputKey.value.trim());
|
||||
if (result.user && !result.user.lastSync) {
|
||||
manualSync();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
errorMsg.value = e.message || t('login.failed');
|
||||
} finally {
|
||||
loggingIn.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
store.saveSettings({
|
||||
batchSize: tempBatchSize.value,
|
||||
drawingAccuracy: tempDrawingAccuracy.value
|
||||
});
|
||||
store.saveSettings({
|
||||
batchSize: tempBatchSize.value,
|
||||
drawingAccuracy: tempDrawingAccuracy.value,
|
||||
});
|
||||
|
||||
localStorage.setItem('zen_locale', locale.value);
|
||||
localStorage.setItem('zen_locale', locale.value);
|
||||
|
||||
showSettings.value = false;
|
||||
store.fetchQueue();
|
||||
showSettings.value = false;
|
||||
store.fetchQueue();
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
showLogoutDialog.value = true;
|
||||
drawer.value = false;
|
||||
showLogoutDialog.value = true;
|
||||
drawer.value = false;
|
||||
}
|
||||
function confirmLogout() {
|
||||
store.logout();
|
||||
inputKey.value = '';
|
||||
showLogoutDialog.value = false;
|
||||
store.logout();
|
||||
inputKey.value = '';
|
||||
showLogoutDialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/pages/_app.scss"></style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.safe-area-header {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
|
||||
height: auto !important;
|
||||
|
||||
:deep(.v-toolbar__content) {
|
||||
min-height: 64px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
42
client/src/components/dashboard/DashboardWidget.vue
Normal 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>
|
||||
@@ -1,37 +1,38 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.rounded-xl.border-subtle.d-flex.align-center.justify-space-between(color="#1e1e24")
|
||||
div
|
||||
.text-subtitle-2.text-grey {{ $t('stats.accuracy') }}
|
||||
.text-h3.font-weight-bold.text-white {{ accuracyPercent }}%
|
||||
.text-caption.text-grey
|
||||
| {{ accuracy?.correct || 0 }} {{ $t('stats.correct') }} / {{ accuracy?.total || 0 }} {{ $t('stats.total') }}
|
||||
DashboardWidget(
|
||||
:title="$t('stats.accuracy')"
|
||||
icon="mdi-bullseye-arrow"
|
||||
)
|
||||
.d-flex.align-center.justify-space-between.flex-grow-1
|
||||
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(
|
||||
:model-value="accuracyPercent"
|
||||
color="#00cec9"
|
||||
size="100"
|
||||
width="10"
|
||||
bg-color="grey-darken-3"
|
||||
)
|
||||
v-icon(color="#00cec9" size="large") mdi-bullseye-arrow
|
||||
v-progress-circular(
|
||||
:model-value="accuracyPercent"
|
||||
color="primary"
|
||||
size="80"
|
||||
width="8"
|
||||
bg-color="grey-darken-3"
|
||||
)
|
||||
span.text-caption.font-weight-bold {{ accuracyPercent }}%
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
accuracy: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
correct: 0,
|
||||
total: 0
|
||||
})
|
||||
}
|
||||
accuracy: { type: Object, default: () => ({ correct: 0, total: 0 }) },
|
||||
});
|
||||
|
||||
const accuracyPercent = computed(() => {
|
||||
if (!props.accuracy || !props.accuracy.total) return 100;
|
||||
return Math.round((props.accuracy.correct / props.accuracy.total) * 100);
|
||||
if (!props.accuracy || !props.accuracy.total) return 100;
|
||||
return Math.round((props.accuracy.correct / props.accuracy.total) * 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-5.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
|
||||
v-card.widget-card.pa-5.d-flex.flex-column.flex-grow-1(flat)
|
||||
.d-flex.align-center.justify-space-between.mb-4
|
||||
.text-subtitle-1.font-weight-bold.d-flex.align-center
|
||||
v-icon(color="#ffeaa7" start size="small") mdi-chart-bar
|
||||
v-icon(color="secondary" start size="small") mdi-chart-bar
|
||||
| {{ $t('stats.srsDistribution') }}
|
||||
v-chip.font-weight-bold(
|
||||
size="x-small"
|
||||
@@ -10,7 +10,7 @@
|
||||
variant="tonal"
|
||||
) {{ totalItems }}
|
||||
|
||||
.srs-chart-container.d-flex.justify-space-between.align-end.px-2.gap-2.flex-grow-1
|
||||
.srs-chart-container.d-flex.justify-space-between.align-end.px-2.flex-grow-1
|
||||
.d-flex.flex-column.align-center.flex-grow-1.srs-column(
|
||||
v-for="lvl in 10"
|
||||
:key="lvl"
|
||||
@@ -20,11 +20,13 @@
|
||||
) {{ getCount(lvl) }}
|
||||
|
||||
.srs-track
|
||||
.srs-fill(:style="{\
|
||||
height: getBarHeight(getCount(lvl)) + '%',\
|
||||
background: getSRSColor(lvl),\
|
||||
boxShadow: getCount(lvl) > 0 ? `0 0 20px ${getSRSColor(lvl)}30` : 'none'\
|
||||
}")
|
||||
.srs-fill(
|
||||
:class="'bg-srs-' + lvl"
|
||||
:style="{\
|
||||
height: getBarHeight(getCount(lvl)) + '%',\
|
||||
'--shadow-color': 'var(--srs-' + lvl + ')'\
|
||||
}"
|
||||
)
|
||||
|
||||
.text-caption.text-grey-darken-1.font-weight-bold.mt-3(
|
||||
style="font-size: 10px !important;"
|
||||
@@ -32,41 +34,29 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
distribution: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
distribution: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const totalItems = computed(() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0));
|
||||
|
||||
const totalItems = computed(
|
||||
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
|
||||
);
|
||||
const getCount = (lvl) => props.distribution?.[lvl] || 0;
|
||||
|
||||
const getBarHeight = (count) => {
|
||||
const max = Math.max(...Object.values(props.distribution || {}), 1);
|
||||
if (count > 0 && (count / max) * 100 < 4) return 4;
|
||||
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 max = Math.max(...Object.values(props.distribution || {}), 1);
|
||||
if (count > 0 && (count / max) * 100 < 4) return 4;
|
||||
return (count / max) * 100;
|
||||
};
|
||||
|
||||
const toRoman = (num) => {
|
||||
const lookup = {
|
||||
1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V',
|
||||
6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X'
|
||||
};
|
||||
return lookup[num] || num;
|
||||
const lookup = {
|
||||
1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V', 6: 'VI', 7: 'VII', 8: 'VIII', 9: 'IX', 10: 'X',
|
||||
};
|
||||
return lookup[num] || num;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.rounded-xl.border-subtle.d-flex.flex-column.flex-grow-1(color="#1e1e24")
|
||||
.text-subtitle-1.font-weight-bold.mb-3.d-flex.align-center
|
||||
v-icon(color="#ffeaa7" start) mdi-clock-outline
|
||||
| {{ $t('stats.next24') }}
|
||||
|
||||
DashboardWidget(
|
||||
:title="$t('stats.next24')"
|
||||
icon="mdi-clock-outline"
|
||||
)
|
||||
.forecast-list.flex-grow-1(v-if="hasUpcoming")
|
||||
div(v-for="(count, hour) in forecast" :key="hour")
|
||||
.d-flex.justify-space-between.align-center.mb-2.py-2.border-b-subtle(v-if="count > 0")
|
||||
span.text-body-2.text-grey-lighten-1
|
||||
| {{ hour === 0 ? $t('stats.availableNow') : $t('stats.inHours', { n: hour }, hour) }}
|
||||
v-chip.font-weight-bold(
|
||||
|
||||
v-chip.font-weight-bold.text-primary(
|
||||
size="small"
|
||||
color="#2f3542"
|
||||
style="color: #00cec9 !important;"
|
||||
color="surface-light"
|
||||
) {{ count }}
|
||||
|
||||
.fill-height.d-flex.align-center.justify-center.text-grey.text-center.pa-4(v-else)
|
||||
@@ -20,22 +19,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
forecast: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
forecast: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const hasUpcoming = computed(() => {
|
||||
return props.forecast && props.forecast.some(c => c > 0);
|
||||
});
|
||||
const hasUpcoming = computed(() => props.forecast && props.forecast.some((c) => c > 0));
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped>
|
||||
.border-b-subtle {
|
||||
border-bottom: 1px solid rgb(255 255 255 / 5%);
|
||||
}
|
||||
</style>
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24")
|
||||
.d-flex.justify-space-between.align-center.mb-3
|
||||
.d-flex.align-center
|
||||
v-icon(color="#ff7675" start size="small") mdi-ghost
|
||||
div
|
||||
.text-subtitle-2.text-white {{ $t('stats.ghostTitle') }}
|
||||
.text-caption.text-grey {{ $t('stats.ghostSubtitle') }}
|
||||
DashboardWidget(
|
||||
:title="$t('stats.ghostTitle')"
|
||||
:subtitle="$t('stats.ghostSubtitle')"
|
||||
icon="mdi-ghost"
|
||||
icon-color="var(--srs-1)"
|
||||
)
|
||||
.grid-wrapper(style="display: grid; grid-template-columns: minmax(0, 1fr); width: 100%;")
|
||||
.d-flex.gap-2.overflow-x-auto.pb-2(
|
||||
v-if="ghosts && ghosts.length > 0"
|
||||
style="scrollbar-width: thin; max-width: 100%;"
|
||||
)
|
||||
.ghost-card(
|
||||
v-for="ghost in ghosts"
|
||||
:key="ghost._id"
|
||||
style="min-width: 80px; flex-shrink: 0;"
|
||||
)
|
||||
.text-h6.font-weight-bold.text-white.mb-1 {{ ghost.char }}
|
||||
|
||||
.d-flex.justify-space-between.gap-2(v-if="ghosts && ghosts.length > 0")
|
||||
.ghost-card.flex-grow-1(v-for="ghost in ghosts" :key="ghost._id")
|
||||
.text-h6.font-weight-bold.text-white.mb-1 {{ ghost.char }}
|
||||
v-chip.font-weight-bold.text-black.w-100.justify-center(
|
||||
size="x-small"
|
||||
color="red-accent-2"
|
||||
variant="flat"
|
||||
) {{ ghost.accuracy }}%
|
||||
v-chip.font-weight-bold.w-100.justify-center(
|
||||
size="x-small"
|
||||
class="bg-srs-1 text-black"
|
||||
variant="flat"
|
||||
) {{ ghost.accuracy }}%
|
||||
|
||||
.text-center.py-2.text-caption.text-grey(v-else)
|
||||
| {{ $t('stats.noGhosts') }}
|
||||
.text-center.py-2.text-caption.text-grey(v-else)
|
||||
| {{ $t('stats.noGhosts') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
ghosts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
ghosts: { type: Array, default: () => [] },
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,45 +1,43 @@
|
||||
<template lang="pug">
|
||||
v-card.widget-card.pa-5.rounded-xl.d-flex.flex-column.justify-center(color="#1e1e24" flat)
|
||||
.d-flex.justify-space-between.align-center.mb-2
|
||||
.d-flex.align-center
|
||||
v-icon(color="secondary" start size="small") mdi-trophy-outline
|
||||
span.text-subtitle-2.font-weight-bold {{ $t('stats.mastery') }}
|
||||
DashboardWidget(
|
||||
:title="$t('stats.mastery')"
|
||||
icon="mdi-trophy-outline"
|
||||
)
|
||||
template(#header-right)
|
||||
.text-subtitle-2.text-white.font-weight-bold {{ masteryPercent }}%
|
||||
|
||||
v-progress-linear(
|
||||
:model-value="masteryPercent"
|
||||
color="primary"
|
||||
height="8"
|
||||
rounded
|
||||
bg-color="grey-darken-3"
|
||||
striped
|
||||
)
|
||||
.d-flex.flex-column.justify-center.flex-grow-1
|
||||
v-progress-linear(
|
||||
:model-value="masteryPercent"
|
||||
color="primary"
|
||||
height="8"
|
||||
rounded
|
||||
bg-color="grey-darken-3"
|
||||
striped
|
||||
)
|
||||
|
||||
.text-caption.text-medium-emphasis.mt-2.text-right
|
||||
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
|
||||
.text-caption.text-medium-emphasis.mt-2.text-right
|
||||
| {{ masteredCount }} / {{ totalItems }} {{ $t('stats.items') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
distribution: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
distribution: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const totalItems = computed(() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0));
|
||||
|
||||
const masteredCount = computed(() => {
|
||||
const dist = props.distribution || {};
|
||||
return (dist[6] || 0);
|
||||
});
|
||||
|
||||
const masteryPercent = computed(() => {
|
||||
if (totalItems.value === 0) return 0;
|
||||
return Math.round((masteredCount.value / totalItems.value) * 100);
|
||||
});
|
||||
const totalItems = computed(
|
||||
() => Object.values(props.distribution || {}).reduce((a, b) => a + b, 0),
|
||||
);
|
||||
const masteredCount = computed(
|
||||
() => (props.distribution || {})[6] || 0,
|
||||
);
|
||||
const masteryPercent = computed(
|
||||
() => (totalItems.value === 0 ? 0 : Math.round((masteredCount.value / totalItems.value) * 100)),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_widgets.scss" scoped></style>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<template lang="pug">
|
||||
v-card.widget-card.pa-4.rounded-xl(color="#1e1e24" flat)
|
||||
.d-flex.flex-wrap.justify-space-between.align-center.mb-4.gap-2
|
||||
.text-subtitle-1.font-weight-bold.d-flex.align-center
|
||||
v-icon(color="secondary" start) mdi-calendar-check
|
||||
| {{ $t('stats.consistency') }}
|
||||
|
||||
DashboardWidget(
|
||||
:title="$t('stats.consistency')"
|
||||
icon="mdi-calendar-check"
|
||||
)
|
||||
template(#header-right)
|
||||
.legend-container
|
||||
span.text-caption.text-medium-emphasis.mr-1 {{ $t('stats.less') }}
|
||||
.legend-box.level-0
|
||||
@@ -22,10 +21,7 @@
|
||||
template(v-slot:activator="{ props }")
|
||||
.heatmap-cell(
|
||||
v-bind="props"
|
||||
:class="[\
|
||||
isToday(day.date) ? 'today-cell' : '',\
|
||||
getHeatmapClass(day.count)\
|
||||
]"
|
||||
:class="[isToday(day.date) ? 'today-cell' : '', getHeatmapClass(day.count)]"
|
||||
)
|
||||
.text-center
|
||||
.font-weight-bold {{ formatDate(day.date) }}
|
||||
@@ -33,53 +29,50 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { locale } = useI18n();
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const { locale } = useI18n();
|
||||
const props = defineProps({
|
||||
heatmapData: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
heatmapData: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const weeks = computed(() => {
|
||||
const data = props.heatmapData || {};
|
||||
const w = [];
|
||||
const today = new Date();
|
||||
const data = props.heatmapData || {};
|
||||
const w = [];
|
||||
const today = new Date();
|
||||
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - (52 * 7));
|
||||
const dayOfWeek = startDate.getDay();
|
||||
startDate.setDate(startDate.getDate() - dayOfWeek);
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(today.getDate() - (52 * 7));
|
||||
startDate.setDate(startDate.getDate() - startDate.getDay());
|
||||
|
||||
let currentWeek = [];
|
||||
for (let i = 0; i < 371; i++) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
let currentWeek = [];
|
||||
for (let i = 0; i < 371; i += 1) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
|
||||
currentWeek.push({
|
||||
date: dateStr,
|
||||
count: data[dateStr] || 0
|
||||
});
|
||||
currentWeek.push({
|
||||
date: dateStr,
|
||||
count: data[dateStr] || 0,
|
||||
});
|
||||
|
||||
if (currentWeek.length === 7) {
|
||||
w.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
}
|
||||
return w;
|
||||
if (currentWeek.length === 7) {
|
||||
w.push(currentWeek);
|
||||
currentWeek = [];
|
||||
}
|
||||
}
|
||||
return w;
|
||||
});
|
||||
|
||||
const getHeatmapClass = (count) => {
|
||||
if (count === 0) return 'level-0';
|
||||
if (count <= 5) return 'level-1';
|
||||
if (count <= 10) return 'level-2';
|
||||
if (count <= 20) return 'level-3';
|
||||
return 'level-4';
|
||||
if (count === 0) return 'level-0';
|
||||
if (count <= 5) return 'level-1';
|
||||
if (count <= 10) return 'level-2';
|
||||
if (count <= 20) return 'level-3';
|
||||
return 'level-4';
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => new Date(dateStr).toLocaleDateString(locale.value, { month: 'short', day: 'numeric' });
|
||||
|
||||
@@ -1,58 +1,61 @@
|
||||
<template lang="pug">
|
||||
v-card.pa-4.rounded-xl.border-subtle(color="#1e1e24")
|
||||
.d-flex.justify-space-between.align-start.mb-2
|
||||
div
|
||||
.text-subtitle-2.text-grey {{ $t('stats.streakTitle') }}
|
||||
.d-flex.align-center
|
||||
.text-h3.font-weight-bold.text-white.mr-2 {{ streak?.current || 0 }}
|
||||
.text-h6.text-grey {{ $t('stats.days') }}
|
||||
DashboardWidget(
|
||||
:title="$t('stats.streakTitle')"
|
||||
icon="mdi-fire"
|
||||
icon-color="var(--srs-1)"
|
||||
)
|
||||
template(#header-right)
|
||||
v-tooltip(location="start" :text="shieldTooltip")
|
||||
template(v-slot:activator="{ props }")
|
||||
v-avatar.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
|
||||
v-tooltip(
|
||||
location="start"
|
||||
:text="streak?.shield?.ready ? $t('stats.shieldActive') : $t('stats.shieldCooldown', { n: streak?.shield?.cooldown })"
|
||||
.d-flex.flex-column.justify-space-between.flex-grow-1
|
||||
.d-flex.align-end.mb-3
|
||||
.text-h3.font-weight-bold.text-white.mr-2(style="line-height: 1")
|
||||
| {{ streak?.current || 0 }}
|
||||
.text-body-1.text-grey.mb-1 {{ $t('stats.days') }}
|
||||
|
||||
.d-flex.justify-space-between.align-center.px-1
|
||||
.d-flex.flex-column.align-center(
|
||||
v-for="(day, idx) in (streak?.history || [])"
|
||||
:key="idx"
|
||||
)
|
||||
template(v-slot:activator="{ props }")
|
||||
v-avatar(
|
||||
v-bind="props"
|
||||
size="48"
|
||||
:color="streak?.shield?.ready ? 'rgba(0, 206, 201, 0.1)' : 'rgba(255, 255, 255, 0.05)'"
|
||||
style="border: 1px solid;"
|
||||
:style="{ borderColor: streak?.shield?.ready ? '#00cec9' : '#555' }"
|
||||
)
|
||||
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) }}
|
||||
.streak-dot.mb-1(:class="{ 'active': day.active }")
|
||||
v-icon(v-if="day.active" size="12" color="black") mdi-check
|
||||
.text-grey.text-uppercase.streak-day-label
|
||||
| {{ getDayLabel(day.date) }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import DashboardWidget from './DashboardWidget.vue';
|
||||
|
||||
const props = defineProps({
|
||||
streak: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
current: 0,
|
||||
history: [],
|
||||
shield: { ready: false, cooldown: 0 }
|
||||
})
|
||||
}
|
||||
streak: {
|
||||
type: Object,
|
||||
default: () => ({ current: 0, history: [], shield: { ready: false, cooldown: 0 } }),
|
||||
},
|
||||
});
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const shieldTooltip = computed(() => {
|
||||
const shield = props.streak?.shield;
|
||||
if (shield?.ready) return t('stats.shieldActive');
|
||||
return t('stats.shieldCooldown', { n: shield?.cooldown || 0 });
|
||||
});
|
||||
const { locale } = useI18n();
|
||||
|
||||
const getDayLabel = (dateStr) => {
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr).toLocaleDateString(locale.value, { weekday: 'short' });
|
||||
if (!dateStr) return '';
|
||||
return new Date(dateStr).toLocaleDateString(locale.value, { weekday: 'short' });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -3,11 +3,24 @@
|
||||
.text-h2.font-weight-bold.mb-2 {{ $t('hero.welcome') }}
|
||||
.text-h5.text-grey.mb-8 {{ $t('hero.subtitle') }}
|
||||
|
||||
.d-flex.justify-center.align-center.flex-column
|
||||
v-btn.text-h5.font-weight-bold.text-black.glow-btn(
|
||||
.d-flex.justify-center.align-center.flex-column.gap-4
|
||||
v-btn.text-h5.font-weight-bold.text-black.glow-btn.welcome-btn(
|
||||
v-if="lessonCount > 0"
|
||||
to="/lesson"
|
||||
rounded="xl"
|
||||
color="purple-accent-2"
|
||||
class="mb-3"
|
||||
)
|
||||
v-icon(size="32" start) mdi-school
|
||||
| {{ $t('hero.lessons') }}
|
||||
v-chip.ml-3.font-weight-bold(
|
||||
color="#1e1e24"
|
||||
variant="flat"
|
||||
size="default"
|
||||
style="color: white !important;"
|
||||
) {{ lessonCount }}
|
||||
v-btn.text-h5.font-weight-bold.text-black.glow-btn.welcome-btn(
|
||||
@click="$emit('start', 'shuffled')"
|
||||
height="80"
|
||||
width="280"
|
||||
rounded="xl"
|
||||
color="#00cec9"
|
||||
:disabled="queueLength === 0"
|
||||
@@ -38,39 +51,25 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
queueLength: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
hasLowerLevels: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
lowerLevelCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
forecast: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
queueLength: { type: Number, required: true },
|
||||
lessonCount: { type: Number, default: 0 },
|
||||
hasLowerLevels: { type: Boolean, default: false },
|
||||
lowerLevelCount: { type: Number, default: 0 },
|
||||
forecast: { type: Object, default: () => ({}) },
|
||||
});
|
||||
defineEmits(['start']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const nextReviewTime = computed(() => {
|
||||
if (!props.forecast) return "a while";
|
||||
|
||||
const idx = props.forecast.findIndex(c => c > 0);
|
||||
|
||||
if (idx === -1) return "a while";
|
||||
return idx === 0 ? t('hero.now') : t('stats.inHours', { n: idx }, idx);
|
||||
if (!props.forecast) return 'a while';
|
||||
const idx = props.forecast.findIndex((c) => c > 0);
|
||||
if (idx === -1) return 'a while';
|
||||
return idx === 0 ? t('hero.now') : t('stats.inHours', { n: idx }, idx);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_buttons.scss" scoped></style>
|
||||
|
||||
@@ -1,247 +1,140 @@
|
||||
<template lang="pug">
|
||||
.canvas-container
|
||||
.loading-text(v-if="loading") {{ $t('review.loading') }}
|
||||
|
||||
.canvas-wrapper(ref="wrapper")
|
||||
canvas(
|
||||
ref="bgCanvas"
|
||||
:width="CANVAS_SIZE"
|
||||
:height="CANVAS_SIZE"
|
||||
)
|
||||
canvas(
|
||||
ref="snapCanvas"
|
||||
:width="CANVAS_SIZE"
|
||||
:height="CANVAS_SIZE"
|
||||
)
|
||||
.canvas-container(
|
||||
style="touch-action: none; user-select: none; -webkit-user-select: none; overscroll-behavior: none;"
|
||||
)
|
||||
.canvas-wrapper(
|
||||
ref="wrapper"
|
||||
:class="{ 'shake': isShaking }"
|
||||
:style="{ width: size + 'px', height: size + 'px', touchAction: 'none', userSelect: 'none', overscrollBehavior: 'none' }"
|
||||
)
|
||||
canvas(ref="bgCanvas")
|
||||
canvas(ref="hintCanvas")
|
||||
canvas(
|
||||
ref="drawCanvas"
|
||||
:width="CANVAS_SIZE"
|
||||
:height="CANVAS_SIZE"
|
||||
@mousedown="startDraw"
|
||||
@mousemove="draw"
|
||||
@mouseup="endDraw"
|
||||
@mouseleave="endDraw"
|
||||
@touchstart.prevent="startDraw"
|
||||
@touchmove.prevent="draw"
|
||||
@touchend.prevent="endDraw"
|
||||
style="touch-action: none; user-select: none; -webkit-user-select: none; overscroll-behavior: none;"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@pointerleave="handlePointerUp"
|
||||
@pointercancel="handlePointerUp"
|
||||
@touchstart.prevent.stop
|
||||
@touchmove.prevent.stop
|
||||
@touchend.prevent.stop
|
||||
@touchcancel.prevent.stop
|
||||
@contextmenu.prevent
|
||||
)
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
import {
|
||||
ref, onMounted, watch, onBeforeUnmount,
|
||||
} from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { KanjiController } from '@/utils/KanjiController';
|
||||
|
||||
const props = defineProps({
|
||||
char: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
const emit = defineEmits(['complete', 'mistake']);
|
||||
const store = useAppStore();
|
||||
|
||||
const KANJI_SIZE = 109;
|
||||
const CANVAS_SIZE = 300;
|
||||
const SCALE = CANVAS_SIZE / KANJI_SIZE;
|
||||
const props = defineProps({
|
||||
char: String,
|
||||
autoHint: Boolean,
|
||||
size: { type: Number, default: 300 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['complete', 'mistake']);
|
||||
|
||||
const wrapper = ref(null);
|
||||
const bgCanvas = ref(null);
|
||||
const snapCanvas = ref(null);
|
||||
const hintCanvas = ref(null);
|
||||
const drawCanvas = ref(null);
|
||||
let ctxBg, ctxSnap, ctxDraw;
|
||||
const isShaking = ref(false);
|
||||
|
||||
const kanjiPaths = ref([]);
|
||||
const currentStrokeIndex = ref(0);
|
||||
const failureCount = ref(0);
|
||||
const loading = ref(false);
|
||||
let isDrawing = false;
|
||||
let userPath = [];
|
||||
let controller = null;
|
||||
|
||||
function getPoint(e) {
|
||||
if (!drawCanvas.value) return { x: 0, y: 0 };
|
||||
const rect = drawCanvas.value.getBoundingClientRect();
|
||||
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(() => {
|
||||
initContexts();
|
||||
if (props.char) loadKanji(props.char);
|
||||
controller = new KanjiController({
|
||||
size: props.size,
|
||||
accuracy: store.drawingAccuracy,
|
||||
onComplete: () => emit('complete'),
|
||||
onMistake: (needsHint) => {
|
||||
isShaking.value = true;
|
||||
setTimeout(() => { isShaking.value = false; }, 400);
|
||||
emit('mistake', needsHint);
|
||||
},
|
||||
});
|
||||
|
||||
if (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) => {
|
||||
if (newChar) loadKanji(newChar);
|
||||
if (controller && newChar) {
|
||||
controller.loadChar(newChar, props.autoHint);
|
||||
}
|
||||
});
|
||||
|
||||
function initContexts() {
|
||||
ctxBg = bgCanvas.value.getContext('2d');
|
||||
ctxSnap = snapCanvas.value.getContext('2d');
|
||||
ctxDraw = drawCanvas.value.getContext('2d');
|
||||
watch(() => props.autoHint, (shouldHint) => {
|
||||
if (!controller) return;
|
||||
if (shouldHint) controller.showHint();
|
||||
});
|
||||
|
||||
[ctxBg, ctxSnap, ctxDraw].forEach(ctx => {
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.scale(SCALE, SCALE);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
});
|
||||
}
|
||||
watch(() => props.size, (newSize) => {
|
||||
if (controller) controller.resize(newSize);
|
||||
});
|
||||
|
||||
async function loadKanji(char) {
|
||||
reset();
|
||||
loading.value = true;
|
||||
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
|
||||
watch(() => store.drawingAccuracy, (newVal) => {
|
||||
if (controller) controller.setAccuracy(newVal);
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${hex}.svg`);
|
||||
const txt = await res.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(txt, "image/svg+xml");
|
||||
kanjiPaths.value = Array.from(doc.getElementsByTagName("path")).map(p => p.getAttribute("d"));
|
||||
|
||||
drawGuide();
|
||||
} catch (e) {
|
||||
console.error("Failed to load KanjiVG data", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
currentStrokeIndex.value = 0;
|
||||
failureCount.value = 0;
|
||||
kanjiPaths.value = [];
|
||||
|
||||
[ctxBg, ctxSnap, ctxDraw].forEach(ctx => {
|
||||
ctx.clearRect(0, 0, KANJI_SIZE, KANJI_SIZE);
|
||||
});
|
||||
}
|
||||
|
||||
function getCoords(e) {
|
||||
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 });
|
||||
defineExpose({
|
||||
reset: () => controller?.reset(),
|
||||
showHint: () => controller?.showHint(),
|
||||
drawGuide: () => controller?.showHint(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>
|
||||
|
||||
@@ -1,138 +1,151 @@
|
||||
<template lang="pug">
|
||||
.svg-container(:class="{ loading: loading }")
|
||||
.svg-container(
|
||||
:class="{ 'canvas-mode': mode === 'animate', 'hero-mode': mode === 'hero' }"
|
||||
)
|
||||
svg.kanji-svg(
|
||||
v-if="!loading"
|
||||
v-show="!loading"
|
||||
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")
|
||||
path.stroke-path(
|
||||
:d="stroke.d"
|
||||
:class="{\
|
||||
'animating': isPlaying && currentStrokeIdx === i,\
|
||||
'hidden': isPlaying && currentStrokeIdx < i,\
|
||||
'drawn': isPlaying && currentStrokeIdx > i\
|
||||
}"
|
||||
:style="{\
|
||||
'--len': stroke.len,\
|
||||
'--duration': (stroke.len * 0.02) + 's'\
|
||||
}"
|
||||
:class="getStrokeClass(i)"
|
||||
:style="getStrokeStyle(stroke)"
|
||||
)
|
||||
|
||||
g(v-show="!isPlaying || currentStrokeIdx > i")
|
||||
circle.stroke-start-circle(
|
||||
v-if="stroke.start"
|
||||
:cx="stroke.start.x"
|
||||
:cy="stroke.start.y"
|
||||
r="3.5"
|
||||
)
|
||||
g(v-if="mode === 'animate' && (!isPlaying || currentStrokeIdx > -1)")
|
||||
g(v-for="(stroke, i) in strokes" :key="'anno-'+i")
|
||||
g(v-show="!isPlaying || currentStrokeIdx >= i")
|
||||
path.stroke-arrow-line(
|
||||
v-if="isPlaying && currentStrokeIdx === i && stroke.arrow"
|
||||
d="M -7 0 L 2 0 M -1 -3 L 2 0 L -1 3"
|
||||
:transform="getArrowTransform(stroke.arrow)"
|
||||
)
|
||||
|
||||
text.stroke-number(
|
||||
v-if="stroke.start"
|
||||
:x="stroke.start.x"
|
||||
:y="stroke.start.y + 0.5"
|
||||
) {{ i + 1 }}
|
||||
|
||||
path.stroke-arrow-line(
|
||||
v-if="stroke.arrow"
|
||||
d="M -7 0 L 2 0 M -1 -3 L 2 0 L -1 3"
|
||||
:transform="`translate(${stroke.arrow.x}, ${stroke.arrow.y}) rotate(${stroke.arrow.angle})`"
|
||||
)
|
||||
|
||||
.loading-spinner(v-else) {{ $t('review.loading') }}
|
||||
g.stroke-badge-group(
|
||||
v-if="stroke.start"
|
||||
:transform="`translate(${stroke.start.x}, ${stroke.start.y})`"
|
||||
)
|
||||
circle.stroke-badge-bg(r="4")
|
||||
text.stroke-badge-text(
|
||||
dy="0.5"
|
||||
) {{ i + 1 }}
|
||||
|
||||
button.play-btn(
|
||||
v-if="!loading && !isPlaying"
|
||||
@click="playAnimation"
|
||||
v-if="mode === 'animate' && !loading && !isPlaying"
|
||||
@click.stop="playAnimation"
|
||||
)
|
||||
svg(viewBox="0 0 24 24" fill="currentColor")
|
||||
path(d="M8 5v14l11-7z")
|
||||
path(d="M8,5.14V19.14L19,12.14L8,5.14Z")
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
char: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
char: { type: String, required: true },
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'animate',
|
||||
validator: (v) => ['hero', 'animate'].includes(v),
|
||||
},
|
||||
});
|
||||
|
||||
const strokes = ref([]);
|
||||
const loading = ref(true);
|
||||
const isPlaying = ref(false);
|
||||
const currentStrokeIdx = ref(-1);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.char) loadData(props.char);
|
||||
});
|
||||
|
||||
watch(() => props.char, (newChar) => {
|
||||
if (newChar) loadData(newChar);
|
||||
});
|
||||
|
||||
async function loadData(char) {
|
||||
loading.value = true;
|
||||
isPlaying.value = false;
|
||||
strokes.value = [];
|
||||
loading.value = true;
|
||||
isPlaying.value = false;
|
||||
strokes.value = [];
|
||||
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
|
||||
|
||||
const hex = char.charCodeAt(0).toString(16).padStart(5, '0');
|
||||
try {
|
||||
const res = await fetch(`https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji/${hex}.svg`);
|
||||
const txt = await res.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(txt, "image/svg+xml");
|
||||
try {
|
||||
const baseUrl = 'https://raw.githubusercontent.com/KanjiVG/kanjivg/master/kanji';
|
||||
const res = await fetch(`${baseUrl}/${hex}.svg`);
|
||||
|
||||
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 => {
|
||||
const data = { d, start: null, arrow: null, len: 0 };
|
||||
strokes.value = rawPaths.map((d) => {
|
||||
const data = {
|
||||
d, start: null, arrow: null, len: 0, duration: 0,
|
||||
};
|
||||
const tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
tempPath.setAttribute('d', d);
|
||||
try { data.len = tempPath.getTotalLength(); } catch (e) { data.len = 100; }
|
||||
data.duration = Math.floor(data.len * 20);
|
||||
|
||||
const tempPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
tempPath.setAttribute("d", d);
|
||||
try {
|
||||
data.len = tempPath.getTotalLength();
|
||||
} catch (e) { data.len = 100; }
|
||||
const startMatch = d.match(/[Mm]\s*([\d.]+)[,\s]([\d.]+)/);
|
||||
if (startMatch) data.start = { x: parseFloat(startMatch[1]), y: parseFloat(startMatch[2]) };
|
||||
|
||||
const startMatch = d.match(/[Mm]\s*([\d.]+)[,\s]([\d.]+)/);
|
||||
if (startMatch) {
|
||||
data.start = { x: parseFloat(startMatch[1]), y: parseFloat(startMatch[2]) };
|
||||
}
|
||||
try {
|
||||
const mid = tempPath.getPointAtLength(data.len / 2);
|
||||
const prev = tempPath.getPointAtLength(Math.max(0, (data.len / 2) - 1));
|
||||
const angle = Math.atan2(mid.y - prev.y, mid.x - prev.x) * (180 / Math.PI);
|
||||
data.arrow = { x: mid.x, y: mid.y, angle };
|
||||
} catch (e) { console.error(e); }
|
||||
return data;
|
||||
});
|
||||
} catch (e) { console.error(e); } finally { loading.value = false; }
|
||||
}
|
||||
|
||||
try {
|
||||
const mid = tempPath.getPointAtLength(data.len / 2);
|
||||
const prev = tempPath.getPointAtLength(Math.max(0, (data.len / 2) - 1));
|
||||
const angle = Math.atan2(mid.y - prev.y, mid.x - prev.x) * (180 / Math.PI);
|
||||
data.arrow = { x: mid.x, y: mid.y, angle };
|
||||
} catch (e) { console.error(e) }
|
||||
function getArrowTransform(arrow) {
|
||||
if (!arrow) return '';
|
||||
return `translate(${arrow.x}, ${arrow.y}) rotate(${arrow.angle})`;
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
function getStrokeClass(i) {
|
||||
if (props.mode === 'hero') return 'drawn';
|
||||
|
||||
} catch (e) {
|
||||
console.error("SVG Load Failed", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
if (isPlaying.value) {
|
||||
if (currentStrokeIdx.value === i) return 'animating';
|
||||
if (currentStrokeIdx.value > i) return 'drawn';
|
||||
return 'hidden';
|
||||
}
|
||||
return 'drawn';
|
||||
}
|
||||
|
||||
function getStrokeStyle(stroke) {
|
||||
if (props.mode === 'hero') return {};
|
||||
return { '--len': stroke.len, '--duration': `${stroke.duration}ms` };
|
||||
}
|
||||
|
||||
async function playAnimation() {
|
||||
if (isPlaying.value) return;
|
||||
isPlaying.value = true;
|
||||
currentStrokeIdx.value = -1;
|
||||
if (isPlaying.value) return;
|
||||
isPlaying.value = true;
|
||||
currentStrokeIdx.value = -1;
|
||||
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
await new Promise((r) => { setTimeout(r, 200); });
|
||||
|
||||
for (let i = 0; i < strokes.value.length; i++) {
|
||||
currentStrokeIdx.value = i;
|
||||
const duration = strokes.value[i].len * 20;
|
||||
await new Promise(r => setTimeout(r, duration + 100));
|
||||
}
|
||||
for (let i = 0; i < strokes.value.length; i += 1) {
|
||||
currentStrokeIdx.value = i;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => { setTimeout(r, strokes.value[i].duration); });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => { setTimeout(r, 100); });
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
isPlaying.value = false;
|
||||
await new Promise((r) => { setTimeout(r, 500); });
|
||||
isPlaying.value = false;
|
||||
}
|
||||
|
||||
defineExpose({ playAnimation });
|
||||
onMounted(() => { if (props.char) loadData(props.char); });
|
||||
watch(() => props.char, (n) => { if (n) loadData(n); });
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/components/_kanji.scss" scoped></style>
|
||||
|
||||
@@ -1,49 +1,58 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import i18n from '@/plugins/i18n'
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import '@/styles/main.scss'
|
||||
import '@/styles/main.scss';
|
||||
|
||||
import 'vuetify/styles'
|
||||
import { createVuetify } from 'vuetify'
|
||||
import * as components from 'vuetify/components'
|
||||
import * as directives from 'vuetify/directives'
|
||||
import { aliases, mdi } from 'vuetify/iconsets/mdi'
|
||||
import '@mdi/font/css/materialdesignicons.css'
|
||||
import 'vuetify/styles';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import * as components from 'vuetify/components';
|
||||
import * as directives from 'vuetify/directives';
|
||||
import { aliases, mdi } from 'vuetify/iconsets/mdi';
|
||||
import i18n from '@/plugins/i18n';
|
||||
import '@mdi/font/css/materialdesignicons.css';
|
||||
|
||||
import App from './App.vue'
|
||||
import Dashboard from './views/Dashboard.vue'
|
||||
import Collection from './views/Collection.vue'
|
||||
import Review from './views/Review.vue'
|
||||
import App from './App.vue';
|
||||
import Dashboard from './views/Dashboard.vue';
|
||||
import Collection from './views/Collection.vue';
|
||||
import Review from './views/Review.vue';
|
||||
import Lesson from './views/Lesson.vue';
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: Dashboard },
|
||||
{ path: '/dashboard', component: Dashboard },
|
||||
{ path: '/collection', component: Collection },
|
||||
{ path: '/review', component: Review }
|
||||
]
|
||||
})
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: Dashboard },
|
||||
{ path: '/dashboard', component: Dashboard },
|
||||
{ path: '/collection', component: Collection },
|
||||
{ path: '/review', component: Review },
|
||||
{ path: '/lesson', component: Lesson },
|
||||
],
|
||||
});
|
||||
|
||||
const vuetify = createVuetify({
|
||||
components,
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: 'dark',
|
||||
themes: {
|
||||
dark: { colors: { primary: '#00cec9', secondary: '#ffeaa7' } }
|
||||
}
|
||||
},
|
||||
icons: { defaultSet: 'mdi', aliases, sets: { mdi } }
|
||||
})
|
||||
components,
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: 'dark',
|
||||
themes: {
|
||||
dark: {
|
||||
colors: {
|
||||
primary: '#00cec9',
|
||||
secondary: '#ffeaa7',
|
||||
surface: '#1e1e24',
|
||||
background: '#121212',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
icons: { defaultSet: 'mdi', aliases, sets: { mdi } },
|
||||
});
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(vuetify)
|
||||
app.use(i18n)
|
||||
app.mount('#app')
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(vuetify);
|
||||
app.use(i18n);
|
||||
app.mount('#app');
|
||||
|
||||
@@ -1,306 +1,396 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
common: {
|
||||
close: "Close",
|
||||
cancel: "Cancel"
|
||||
},
|
||||
nav: {
|
||||
dashboard: "Dashboard",
|
||||
review: "Review",
|
||||
collection: "Collection",
|
||||
settings: "Settings",
|
||||
sync: "Sync",
|
||||
logout: "Logout",
|
||||
menu: "Menu"
|
||||
},
|
||||
login: {
|
||||
instruction: "Enter your WaniKani V2 API Key to login",
|
||||
placeholder: "Paste key here...",
|
||||
button: "Login",
|
||||
failed: "Login failed. Is server running?"
|
||||
},
|
||||
alerts: {
|
||||
syncSuccess: "Sync complete! Collection: {count}",
|
||||
syncFailed: "Sync failed.",
|
||||
logoutConfirm: "Are you sure you want to log out? This will end your session.",
|
||||
},
|
||||
hero: {
|
||||
welcome: "Welcome Back",
|
||||
subtitle: "Your mind is ready.",
|
||||
start: "Start Review",
|
||||
noReviews: "No Reviews",
|
||||
nextIn: "Next review in",
|
||||
now: "now",
|
||||
prioritize: "Prioritize Lower Levels ({count})"
|
||||
},
|
||||
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",
|
||||
en: {
|
||||
common: {
|
||||
close: 'Close',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
review: 'Review',
|
||||
collection: 'Collection',
|
||||
settings: 'Settings',
|
||||
sync: 'Sync',
|
||||
logout: 'Logout',
|
||||
menu: 'Menu',
|
||||
},
|
||||
login: {
|
||||
instruction: 'Enter your WaniKani V2 API Key to login',
|
||||
placeholder: 'Paste key here...',
|
||||
button: 'Login',
|
||||
failed: 'Login failed. Is server running?',
|
||||
},
|
||||
alerts: {
|
||||
syncSuccess: 'Sync complete! Collection: {count}',
|
||||
syncFailed: 'Sync failed.',
|
||||
logoutConfirm: 'Are you sure you want to log out? This will end your session.',
|
||||
},
|
||||
lesson: {
|
||||
phasePrimer: 'Study',
|
||||
phaseDemo: 'Observation',
|
||||
phaseGuided: 'Guided',
|
||||
phasePractice: 'Recall',
|
||||
understand: 'I Understand',
|
||||
ready: 'Ready to Draw',
|
||||
startPractice: 'Start Practice',
|
||||
continue: 'Continue',
|
||||
streak: 'Correct: {n} / {total}',
|
||||
hint: 'Show Hint (Resets Streak)',
|
||||
hintAction: 'Hint',
|
||||
watchAgain: 'Watch Again',
|
||||
completeTitle: 'Lesson Complete!',
|
||||
completeBody: 'You have unlocked new Kanji for review.',
|
||||
learned: "You've learned {n} new kanji.",
|
||||
components: 'Components',
|
||||
observe: 'Observe Stroke Order',
|
||||
trace: 'Trace',
|
||||
drawStep: 'Draw ({n}/{total})',
|
||||
backToDashboard: 'Back to Dashboard',
|
||||
},
|
||||
hero: {
|
||||
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",
|
||||
less: "Less",
|
||||
more: "More",
|
||||
consistency: 'Study Consistency',
|
||||
less: 'Less',
|
||||
more: 'More',
|
||||
|
||||
streakTitle: "Study Streak",
|
||||
days: "days",
|
||||
shieldActive: "Zen Shield Active: Protects streak if you miss 1 day.",
|
||||
shieldCooldown: "Regenerating: {n} days left",
|
||||
streakTitle: 'Study Streak',
|
||||
days: 'days',
|
||||
shieldActive: 'Zen Shield Active: Protects streak if you miss 1 day.',
|
||||
shieldCooldown: 'Regenerating: {n} days left',
|
||||
|
||||
ghostTitle: "Ghost Items",
|
||||
ghostSubtitle: "Lowest Accuracy",
|
||||
noGhosts: "No ghosts found! Keep it up."
|
||||
},
|
||||
settings: {
|
||||
title: "Settings",
|
||||
batchSize: "Review Batch Size",
|
||||
items: "Items",
|
||||
language: "Language",
|
||||
save: "Save & Close"
|
||||
},
|
||||
review: {
|
||||
meaning: "Meaning",
|
||||
level: "Level",
|
||||
draw: "Draw correctly",
|
||||
hint: "Hint Shown",
|
||||
tryAgain: "Try again",
|
||||
correct: "Correct!",
|
||||
next: "NEXT",
|
||||
sessionComplete: "Session Complete!",
|
||||
levelup: "You leveled up your Kanji skills.",
|
||||
back: "Back to Collection",
|
||||
caughtUp: "All Caught Up!",
|
||||
noReviews: "No reviews available right now.",
|
||||
viewCollection: "View Collection",
|
||||
queue: "Session queue:",
|
||||
loading: "Loading Kanji...",
|
||||
},
|
||||
collection: {
|
||||
searchLabel: "Search Kanji, Meaning, or Reading...",
|
||||
placeholder: "e.g. 'water', 'mizu', '水'",
|
||||
loading: "Loading Collection...",
|
||||
noMatches: "No matches found",
|
||||
tryDifferent: "Try searching for a different meaning or reading.",
|
||||
levelHeader: "LEVEL",
|
||||
onyomi: "On'yomi",
|
||||
kunyomi: "Kun'yomi",
|
||||
nanori: "Nanori",
|
||||
close: "Close"
|
||||
}
|
||||
},
|
||||
de: {
|
||||
common: {
|
||||
close: "Schließen",
|
||||
cancel: "Abbrechen"
|
||||
},
|
||||
nav: {
|
||||
dashboard: "Übersicht",
|
||||
review: "Lernen",
|
||||
collection: "Sammlung",
|
||||
settings: "Einstellungen",
|
||||
sync: "Sync",
|
||||
logout: "Abmelden",
|
||||
menu: "Menü"
|
||||
},
|
||||
login: {
|
||||
instruction: "Gib deinen WaniKani V2 API Key ein",
|
||||
placeholder: "Key hier einfügen...",
|
||||
button: "Anmelden",
|
||||
failed: "Login fehlgeschlagen. Läuft der Server?"
|
||||
},
|
||||
alerts: {
|
||||
syncSuccess: "Sync fertig! Sammlung: {count}",
|
||||
syncFailed: "Sync fehlgeschlagen.",
|
||||
logoutConfirm: "Möchtest du dich wirklich abmelden? Deine Sitzung wird beendet.",
|
||||
},
|
||||
hero: {
|
||||
welcome: "Willkommen zurück",
|
||||
subtitle: "Dein Geist ist bereit.",
|
||||
start: "Starten",
|
||||
noReviews: "Alles erledigt",
|
||||
nextIn: "Nächste Review in",
|
||||
now: "jetzt",
|
||||
prioritize: "Niedrige Stufen zuerst ({count})"
|
||||
},
|
||||
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",
|
||||
ghostTitle: 'Ghost Items',
|
||||
ghostSubtitle: 'Lowest Accuracy',
|
||||
noGhosts: 'No ghosts found! Keep it up.',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
batchSize: 'Review Batch Size',
|
||||
items: 'Items',
|
||||
language: 'Language',
|
||||
drawingTolerance: 'Drawing Tolerance',
|
||||
strict: 'Strict',
|
||||
loose: 'Loose',
|
||||
save: 'Save & Close',
|
||||
},
|
||||
review: {
|
||||
meaning: 'Meaning',
|
||||
level: 'Level',
|
||||
draw: 'Draw correctly',
|
||||
hint: 'Hint Shown',
|
||||
showHint: 'Show Hint',
|
||||
redoLesson: 'Redo Lesson',
|
||||
tryAgain: 'Try again',
|
||||
correct: 'Correct!',
|
||||
next: 'NEXT',
|
||||
sessionComplete: 'Session Complete!',
|
||||
levelup: 'You leveled up your Kanji skills.',
|
||||
back: 'Back to Collection',
|
||||
caughtUp: 'All Caught Up!',
|
||||
noReviews: 'No reviews available right now.',
|
||||
viewCollection: 'View Collection',
|
||||
queue: 'Session queue:',
|
||||
loading: 'Loading Kanji...',
|
||||
},
|
||||
collection: {
|
||||
searchLabel: 'Search Kanji, Meaning, or Reading...',
|
||||
placeholder: "e.g. 'water', 'mizu', '水'",
|
||||
loading: 'Loading Collection...',
|
||||
noMatches: 'No matches found',
|
||||
tryDifferent: 'Try searching for a different meaning or reading.',
|
||||
levelHeader: 'LEVEL',
|
||||
onyomi: "On'yomi",
|
||||
kunyomi: "Kun'yomi",
|
||||
nanori: 'Nanori',
|
||||
close: 'Close',
|
||||
startLesson: 'Start Lesson',
|
||||
redoLesson: 'Redo Lesson',
|
||||
},
|
||||
},
|
||||
de: {
|
||||
common: {
|
||||
close: 'Schließen',
|
||||
cancel: 'Abbrechen',
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'Übersicht',
|
||||
review: 'Lernen',
|
||||
collection: 'Sammlung',
|
||||
settings: 'Einstellungen',
|
||||
sync: 'Sync',
|
||||
logout: 'Abmelden',
|
||||
menu: 'Menü',
|
||||
},
|
||||
login: {
|
||||
instruction: 'Gib deinen WaniKani V2 API Key ein',
|
||||
placeholder: 'Key hier einfügen...',
|
||||
button: 'Anmelden',
|
||||
failed: 'Login fehlgeschlagen. Läuft der Server?',
|
||||
},
|
||||
alerts: {
|
||||
syncSuccess: 'Sync fertig! Sammlung: {count}',
|
||||
syncFailed: 'Sync fehlgeschlagen.',
|
||||
logoutConfirm: 'Möchtest du dich wirklich abmelden? Deine Sitzung wird beendet.',
|
||||
},
|
||||
lesson: {
|
||||
phasePrimer: 'Lernen',
|
||||
phaseDemo: 'Beobachtung',
|
||||
phaseGuided: 'Geführt',
|
||||
phasePractice: 'Abruf',
|
||||
understand: 'Verstanden',
|
||||
ready: 'Bereit zum Zeichnen',
|
||||
startPractice: 'Üben Starten',
|
||||
continue: 'Weiter',
|
||||
streak: 'Richtig: {n} / {total}',
|
||||
hint: 'Hinweis (Setzt Serie zurück)',
|
||||
hintAction: 'Hinweis',
|
||||
watchAgain: 'Nochmal ansehen',
|
||||
completeTitle: 'Lektion Fertig!',
|
||||
completeBody: 'Neue Kanji für Reviews freigeschaltet.',
|
||||
learned: 'Du hast {n} neue Kanji gelernt.',
|
||||
components: 'Komponenten',
|
||||
observe: 'Strichfolge beobachten',
|
||||
trace: 'Nachzeichnen',
|
||||
drawStep: 'Zeichnen ({n}/{total})',
|
||||
backToDashboard: 'Zurück zur Übersicht',
|
||||
},
|
||||
hero: {
|
||||
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",
|
||||
less: "Weniger",
|
||||
more: "Mehr",
|
||||
consistency: 'Lern-Konstanz',
|
||||
less: 'Weniger',
|
||||
more: 'Mehr',
|
||||
|
||||
streakTitle: "Lern-Serie",
|
||||
days: "Tage",
|
||||
shieldActive: "Zen-Schild Aktiv: Schützt dich bei einem verpassten Tag.",
|
||||
shieldCooldown: "Regeneriert: noch {n} Tage",
|
||||
streakTitle: 'Lern-Serie',
|
||||
days: 'Tage',
|
||||
shieldActive: 'Zen-Schild Aktiv: Schützt dich bei einem verpassten Tag.',
|
||||
shieldCooldown: 'Regeneriert: noch {n} Tage',
|
||||
|
||||
ghostTitle: "Geister-Items",
|
||||
ghostSubtitle: "Niedrigste Genauigkeit",
|
||||
noGhosts: "Keine Geister gefunden! Weiter so."
|
||||
},
|
||||
settings: {
|
||||
title: "Einstellungen",
|
||||
batchSize: "Anzahl pro Sitzung",
|
||||
items: "Einträge",
|
||||
language: "Sprache",
|
||||
save: "Speichern & Schließen"
|
||||
},
|
||||
review: {
|
||||
meaning: "Bedeutung",
|
||||
level: "Stufe",
|
||||
draw: "Zeichne das Kanji",
|
||||
hint: "Hinweis angezeigt",
|
||||
tryAgain: "Nochmal versuchen",
|
||||
correct: "Richtig!",
|
||||
next: "WEITER",
|
||||
sessionComplete: "Sitzung beendet!",
|
||||
levelup: "Du hast deine Kanji-Skills verbessert.",
|
||||
back: "Zurück zur Sammlung",
|
||||
caughtUp: "Alles erledigt!",
|
||||
noReviews: "Gerade keine Reviews verfügbar.",
|
||||
viewCollection: "Zur Sammlung",
|
||||
queue: "Verbleibend:",
|
||||
loading: "Lade Kanji...",
|
||||
},
|
||||
collection: {
|
||||
searchLabel: "Suche Kanji, Bedeutung oder Lesung...",
|
||||
placeholder: "z.B. 'Wasser', 'mizu'",
|
||||
loading: "Lade Sammlung...",
|
||||
noMatches: "Keine Treffer",
|
||||
tryDifferent: "Versuche einen anderen Suchbegriff.",
|
||||
levelHeader: "STUFE",
|
||||
onyomi: "On'yomi",
|
||||
kunyomi: "Kun'yomi",
|
||||
nanori: "Nanori",
|
||||
close: "Schließen"
|
||||
}
|
||||
},
|
||||
ja: {
|
||||
common: {
|
||||
close: "閉じる",
|
||||
cancel: "キャンセル"
|
||||
},
|
||||
nav: {
|
||||
dashboard: "ダッシュボード",
|
||||
review: "復習",
|
||||
collection: "コレクション",
|
||||
settings: "設定",
|
||||
sync: "同期",
|
||||
logout: "ログアウト",
|
||||
menu: "メニュー"
|
||||
},
|
||||
login: {
|
||||
instruction: "WaniKani V2 APIキーを入力してください",
|
||||
placeholder: "キーを貼り付け...",
|
||||
button: "ログイン",
|
||||
failed: "ログイン失敗。サーバーは起動していますか?"
|
||||
},
|
||||
alerts: {
|
||||
syncSuccess: "同期完了! コレクション: {count}",
|
||||
syncFailed: "同期に失敗しました。",
|
||||
logoutConfirm: "ログアウトしてもよろしいですか?セッションが終了します。",
|
||||
},
|
||||
hero: {
|
||||
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} レビュー",
|
||||
ghostTitle: 'Geister-Items',
|
||||
ghostSubtitle: 'Niedrigste Genauigkeit',
|
||||
noGhosts: 'Keine Geister gefunden! Weiter so.',
|
||||
},
|
||||
settings: {
|
||||
title: 'Einstellungen',
|
||||
batchSize: 'Anzahl pro Sitzung',
|
||||
items: 'Einträge',
|
||||
language: 'Sprache',
|
||||
drawingTolerance: 'Zeichentoleranz',
|
||||
strict: 'Strikt',
|
||||
loose: 'Locker',
|
||||
save: 'Speichern & Schließen',
|
||||
},
|
||||
review: {
|
||||
meaning: 'Bedeutung',
|
||||
level: 'Stufe',
|
||||
draw: 'Zeichne das Kanji',
|
||||
hint: 'Hinweis angezeigt',
|
||||
showHint: 'Hinweis zeigen',
|
||||
redoLesson: 'Lektion wiederholen',
|
||||
tryAgain: 'Nochmal versuchen',
|
||||
correct: 'Richtig!',
|
||||
next: 'WEITER',
|
||||
sessionComplete: 'Sitzung beendet!',
|
||||
levelup: 'Du hast deine Kanji-Skills verbessert.',
|
||||
back: 'Zurück zur Sammlung',
|
||||
caughtUp: 'Alles erledigt!',
|
||||
noReviews: 'Gerade keine Reviews verfügbar.',
|
||||
viewCollection: 'Zur Sammlung',
|
||||
queue: 'Verbleibend:',
|
||||
loading: 'Lade Kanji...',
|
||||
},
|
||||
collection: {
|
||||
searchLabel: 'Suche Kanji, Bedeutung oder Lesung...',
|
||||
placeholder: "z.B. 'Wasser', 'mizu'",
|
||||
loading: 'Lade Sammlung...',
|
||||
noMatches: 'Keine Treffer',
|
||||
tryDifferent: 'Versuche einen anderen Suchbegriff.',
|
||||
levelHeader: 'STUFE',
|
||||
onyomi: "On'yomi",
|
||||
kunyomi: "Kun'yomi",
|
||||
nanori: 'Nanori',
|
||||
close: 'Schließen',
|
||||
startLesson: 'Lektion starten',
|
||||
redoLesson: 'Lektion wiederholen',
|
||||
},
|
||||
},
|
||||
ja: {
|
||||
common: {
|
||||
close: '閉じる',
|
||||
cancel: 'キャンセル',
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'ダッシュボード',
|
||||
review: '復習',
|
||||
collection: 'コレクション',
|
||||
settings: '設定',
|
||||
sync: '同期',
|
||||
logout: 'ログアウト',
|
||||
menu: 'メニュー',
|
||||
},
|
||||
login: {
|
||||
instruction: 'WaniKani V2 APIキーを入力してください',
|
||||
placeholder: 'キーを貼り付け...',
|
||||
button: 'ログイン',
|
||||
failed: 'ログイン失敗。サーバーは起動していますか?',
|
||||
},
|
||||
alerts: {
|
||||
syncSuccess: '同期完了! コレクション: {count}',
|
||||
syncFailed: '同期に失敗しました。',
|
||||
logoutConfirm: 'ログアウトしてもよろしいですか?セッションが終了します。',
|
||||
},
|
||||
lesson: {
|
||||
phasePrimer: '学習',
|
||||
phaseDemo: '観察',
|
||||
phaseGuided: 'ガイド',
|
||||
phasePractice: '想起',
|
||||
understand: '理解した',
|
||||
ready: '描いてみる',
|
||||
startPractice: '練習開始',
|
||||
continue: '次へ',
|
||||
streak: '正解: {n} / {total}',
|
||||
hint: 'ヒント (連勝リセット)',
|
||||
hintAction: 'ヒント',
|
||||
watchAgain: 'もう一度見る',
|
||||
completeTitle: 'レッスン完了!',
|
||||
completeBody: '新しい漢字がレビューに追加されました。',
|
||||
learned: '{n}個の新しい漢字を覚えました。',
|
||||
components: '構成要素',
|
||||
observe: '書き順を見る',
|
||||
trace: 'なぞる',
|
||||
drawStep: '書く ({n}/{total})',
|
||||
backToDashboard: 'ダッシュボードに戻る',
|
||||
},
|
||||
hero: {
|
||||
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: "学習の一貫性",
|
||||
less: "少",
|
||||
more: "多",
|
||||
consistency: '学習の一貫性',
|
||||
less: '少',
|
||||
more: '多',
|
||||
|
||||
streakTitle: "連続学習日数",
|
||||
days: "日",
|
||||
shieldActive: "Zenシールド有効: 1日休んでもストリークを守ります。",
|
||||
shieldCooldown: "再チャージ中: 残り{n}日",
|
||||
streakTitle: '連続学習日数',
|
||||
days: '日',
|
||||
shieldActive: 'Zenシールド有効: 1日休んでもストリークを守ります。',
|
||||
shieldCooldown: '再チャージ中: 残り{n}日',
|
||||
|
||||
ghostTitle: "苦手なアイテム",
|
||||
ghostSubtitle: "正解率が低い",
|
||||
noGhosts: "苦手なアイテムはありません!"
|
||||
},
|
||||
settings: {
|
||||
title: "設定",
|
||||
batchSize: "1回の復習数",
|
||||
items: "個",
|
||||
language: "言語 (Language)",
|
||||
save: "保存して閉じる"
|
||||
},
|
||||
review: {
|
||||
meaning: "意味",
|
||||
level: "レベル",
|
||||
draw: "正しく描いてください",
|
||||
hint: "ヒント表示",
|
||||
tryAgain: "もう一度",
|
||||
correct: "正解!",
|
||||
next: "次へ",
|
||||
sessionComplete: "セッション完了!",
|
||||
levelup: "漢字力がアップしました。",
|
||||
back: "コレクションに戻る",
|
||||
caughtUp: "完了しました!",
|
||||
noReviews: "現在レビューするものはありません。",
|
||||
viewCollection: "コレクションを見る",
|
||||
queue: "残り:",
|
||||
loading: "漢字を読み込み中...",
|
||||
},
|
||||
collection: {
|
||||
searchLabel: "漢字、意味、読みで検索...",
|
||||
placeholder: "例: '水', 'mizu'",
|
||||
loading: "読み込み中...",
|
||||
noMatches: "見つかりませんでした",
|
||||
tryDifferent: "別のキーワードで検索してください。",
|
||||
levelHeader: "レベル",
|
||||
onyomi: "音読み",
|
||||
kunyomi: "訓読み",
|
||||
nanori: "名乗り",
|
||||
close: "閉じる"
|
||||
}
|
||||
}
|
||||
}
|
||||
ghostTitle: '苦手なアイテム',
|
||||
ghostSubtitle: '正解率が低い',
|
||||
noGhosts: '苦手なアイテムはありません!',
|
||||
},
|
||||
settings: {
|
||||
title: '設定',
|
||||
batchSize: '1回の復習数',
|
||||
items: '個',
|
||||
language: '言語 (Language)',
|
||||
drawingTolerance: '描画許容範囲',
|
||||
strict: '厳しい',
|
||||
loose: '甘い',
|
||||
save: '保存して閉じる',
|
||||
},
|
||||
review: {
|
||||
meaning: '意味',
|
||||
level: 'レベル',
|
||||
draw: '正しく描いてください',
|
||||
hint: 'ヒント表示',
|
||||
showHint: 'ヒントを表示',
|
||||
redoLesson: 'レッスンをやり直す',
|
||||
tryAgain: 'もう一度',
|
||||
correct: '正解!',
|
||||
next: '次へ',
|
||||
sessionComplete: 'セッション完了!',
|
||||
levelup: '漢字力がアップしました。',
|
||||
back: 'コレクションに戻る',
|
||||
caughtUp: '完了しました!',
|
||||
noReviews: '現在レビューするものはありません。',
|
||||
viewCollection: 'コレクションを見る',
|
||||
queue: '残り:',
|
||||
loading: '漢字を読み込み中...',
|
||||
},
|
||||
collection: {
|
||||
searchLabel: '漢字、意味、読みで検索...',
|
||||
placeholder: "例: '水', 'mizu'",
|
||||
loading: '読み込み中...',
|
||||
noMatches: '見つかりませんでした',
|
||||
tryDifferent: '別のキーワードで検索してください。',
|
||||
levelHeader: 'レベル',
|
||||
onyomi: '音読み',
|
||||
kunyomi: '訓読み',
|
||||
nanori: '名乗り',
|
||||
close: '閉じる',
|
||||
startLesson: 'レッスン開始',
|
||||
redoLesson: 'レッスンをやり直す',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const savedLocale = localStorage.getItem('zen_locale') || 'en'
|
||||
const savedLocale = localStorage.getItem('zen_locale') || 'en';
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: savedLocale,
|
||||
fallbackLocale: 'en',
|
||||
messages
|
||||
})
|
||||
legacy: false,
|
||||
locale: savedLocale,
|
||||
fallbackLocale: 'en',
|
||||
messages,
|
||||
});
|
||||
|
||||
export default i18n
|
||||
export default i18n;
|
||||
|
||||
@@ -3,134 +3,200 @@ import { defineStore } from 'pinia';
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('zen_token') || '',
|
||||
user: null,
|
||||
queue: [],
|
||||
collection: [],
|
||||
stats: {
|
||||
distribution: {},
|
||||
forecast: [],
|
||||
queueLength: 0,
|
||||
streak: {},
|
||||
accuracy: {},
|
||||
ghosts: []
|
||||
},
|
||||
batchSize: 20,
|
||||
drawingAccuracy: 10,
|
||||
loading: false
|
||||
}),
|
||||
state: () => ({
|
||||
token: localStorage.getItem('zen_token') || '',
|
||||
user: null,
|
||||
queue: [],
|
||||
lessonQueue: [],
|
||||
collection: [],
|
||||
stats: {
|
||||
distribution: {},
|
||||
forecast: [],
|
||||
queueLength: 0,
|
||||
lessonCount: 0,
|
||||
streak: {},
|
||||
accuracy: {},
|
||||
ghosts: [],
|
||||
},
|
||||
batchSize: parseInt(localStorage.getItem('zen_batch_size'), 10) || 20,
|
||||
drawingAccuracy: parseInt(localStorage.getItem('zen_drawing_accuracy'), 10) || 10,
|
||||
loading: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async login(apiKey) {
|
||||
const res = await fetch(`${BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey })
|
||||
});
|
||||
actions: {
|
||||
async login(apiKey) {
|
||||
const res = await fetch(`${BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Login failed');
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Login failed');
|
||||
|
||||
this.token = data.token;
|
||||
this.user = data.user;
|
||||
this.token = data.token;
|
||||
this.user = data.user;
|
||||
|
||||
if (data.user.settings) {
|
||||
this.batchSize = data.user.settings.batchSize || 20;
|
||||
this.drawingAccuracy = data.user.settings.drawingAccuracy || 10;
|
||||
}
|
||||
if (data.user.settings) {
|
||||
this.batchSize = data.user.settings.batchSize || 20;
|
||||
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();
|
||||
return data;
|
||||
},
|
||||
localStorage.setItem('zen_token', data.token);
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
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();
|
||||
}
|
||||
},
|
||||
await this.fetchStats();
|
||||
return data;
|
||||
},
|
||||
|
||||
clearData() {
|
||||
this.token = '';
|
||||
this.user = null;
|
||||
this.queue = [];
|
||||
this.stats = {};
|
||||
localStorage.removeItem('zen_token');
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
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();
|
||||
}
|
||||
},
|
||||
|
||||
getHeaders() {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
},
|
||||
clearData() {
|
||||
this.token = '';
|
||||
this.user = null;
|
||||
this.queue = [];
|
||||
this.stats = {};
|
||||
localStorage.removeItem('zen_token');
|
||||
localStorage.removeItem('zen_batch_size');
|
||||
localStorage.removeItem('zen_drawing_accuracy');
|
||||
},
|
||||
|
||||
async sync() {
|
||||
const res = await fetch(`${BASE_URL}/api/sync`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
return data;
|
||||
},
|
||||
getHeaders() {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
},
|
||||
|
||||
async fetchStats() {
|
||||
if (!this.token) return;
|
||||
const res = await fetch(`${BASE_URL}/api/stats`, { headers: this.getHeaders() });
|
||||
if (res.status === 401) return this.logout();
|
||||
const data = await res.json();
|
||||
this.stats = data;
|
||||
return data;
|
||||
},
|
||||
async sync() {
|
||||
const res = await fetch(`${BASE_URL}/api/sync`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
return data;
|
||||
},
|
||||
|
||||
async fetchQueue(sortMode = 'shuffled') {
|
||||
if (!this.token) return;
|
||||
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 fetchStats() {
|
||||
if (!this.token) return null;
|
||||
|
||||
async fetchCollection() {
|
||||
if (!this.token) return;
|
||||
const res = await fetch(`${BASE_URL}/api/collection`, { headers: this.getHeaders() });
|
||||
if (res.status === 401) return this.logout();
|
||||
this.collection = await res.json();
|
||||
},
|
||||
const res = await fetch(`${BASE_URL}/api/stats`, { headers: this.getHeaders() });
|
||||
if (res.status === 401) {
|
||||
await this.logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
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) return this.logout();
|
||||
return await res.json();
|
||||
},
|
||||
const data = await res.json();
|
||||
this.stats = data;
|
||||
return data;
|
||||
},
|
||||
|
||||
async saveSettings(settings) {
|
||||
if (settings.batchSize) this.batchSize = settings.batchSize;
|
||||
if (settings.drawingAccuracy) this.drawingAccuracy = settings.drawingAccuracy;
|
||||
async fetchQueue(sortMode = 'shuffled') {
|
||||
if (!this.token) return;
|
||||
|
||||
await fetch(`${BASE_URL}/api/settings`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${BASE_URL}/api/queue?limit=${this.batchSize}&sort=${sortMode}`, {
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
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),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,13 +6,32 @@ $color-surface-light: hsl(221deg 17% 22%);
|
||||
$color-text-white: hsl(0deg 0% 100%);
|
||||
$color-text-grey: hsl(213deg 14% 70%);
|
||||
$color-border: hsl(0deg 0% 100% / 8%);
|
||||
$color-zinc-900: #18181b;
|
||||
$color-reading-box: rgb(255 255 255 / 3%);
|
||||
$color-stroke-drawn: #a1a1aa;
|
||||
$color-srs-1: hsl(0deg 100% 73%);
|
||||
$color-srs-2: hsl(39deg 98% 71%);
|
||||
$color-srs-3: hsl(163deg 85% 64%);
|
||||
$color-srs-4: hsl(206deg 92% 46%);
|
||||
$color-srs-5: hsl(244deg 100% 82%);
|
||||
$color-srs-6: hsl(247deg 72% 63%);
|
||||
$color-srs-7: $color-primary;
|
||||
$color-srs-8: hsl(339deg 97% 73%);
|
||||
$color-srs-9: hsl(331deg 78% 59%);
|
||||
$color-srs-10: hsl(51deg 100% 50%);
|
||||
$color-danger: hsl(0deg 85% 65%);
|
||||
$color-success: hsl(160deg 80% 45%);
|
||||
$color-stroke-inactive: hsl(0deg 0% 33%);
|
||||
$color-dot-base: hsl(0deg 0% 27%);
|
||||
$srs-colors: (
|
||||
1: $color-srs-1,
|
||||
2: $color-srs-2,
|
||||
3: $color-srs-3,
|
||||
4: $color-srs-4,
|
||||
5: $color-srs-5,
|
||||
6: $color-srs-6,
|
||||
7: $color-srs-7,
|
||||
8: $color-srs-8,
|
||||
9: $color-srs-9,
|
||||
10: $color-srs-10
|
||||
);
|
||||
|
||||
@@ -18,6 +18,9 @@ $z-dropdown: 50;
|
||||
$z-modal: 100;
|
||||
$z-tooltip: 200;
|
||||
$z-above: 2;
|
||||
|
||||
// Neue Z-Indizes aus dem Refactoring
|
||||
$z-play-btn: 10;
|
||||
$size-canvas: 300px;
|
||||
$size-kanji-preview: 200px;
|
||||
$size-icon-btn: 32px;
|
||||
@@ -26,12 +29,6 @@ $stroke-width-main: 3px;
|
||||
$stroke-width-arrow: 2px;
|
||||
$font-size-svg-number: 5px;
|
||||
$radius-xs: 2px;
|
||||
$radius-sm: 4px;
|
||||
$radius-md: 8px;
|
||||
$radius-lg: 12px;
|
||||
$radius-xl: 24px;
|
||||
$radius-pill: 999px;
|
||||
$radius-circle: 50%;
|
||||
$size-legend-box: 12px;
|
||||
$size-streak-dot: 24px;
|
||||
$size-srs-track: 24px;
|
||||
@@ -44,3 +41,14 @@ $padding-page-x: 24px;
|
||||
$breakpoint-md: 960px;
|
||||
$offset-fab: 20px;
|
||||
$size-heatmap-cell-height: 10px;
|
||||
$stroke-width-kanji: 6px;
|
||||
$opacity-kanji-hint: 0.5;
|
||||
$dash-kanji-hint: 10px 15px;
|
||||
|
||||
// --- NEUE VARIABLEN (Wichtig!) ---
|
||||
$size-hero-wrapper: 140px;
|
||||
$size-lesson-card-width: 450px;
|
||||
$size-lesson-card-min-height: 500px;
|
||||
$size-avatar-small: 32px;
|
||||
$size-button-large-height: 70px;
|
||||
$size-button-large-width: 280px;
|
||||
|
||||
@@ -1,23 +1,53 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
@mixin grid-overlay {
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border-color: $color-border;
|
||||
border-style: dashed;
|
||||
border-width: 0;
|
||||
z-index: $z-normal;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 50%;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
left: 50%;
|
||||
top: 10px;
|
||||
bottom: 10px;
|
||||
border-left-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
position: relative;
|
||||
|
||||
@include flex-center;
|
||||
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
// The main drawing canvas wrapper
|
||||
.canvas-wrapper {
|
||||
position: relative;
|
||||
width: $size-canvas;
|
||||
height: $size-canvas;
|
||||
background: $color-surface;
|
||||
border-radius: $radius-lg;
|
||||
background: rgba($color-surface, 0.95);
|
||||
border: $border-width-md solid $color-surface-light;
|
||||
position: relative;
|
||||
cursor: crosshair;
|
||||
border: $border-subtle;
|
||||
box-shadow: $shadow-inset;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
@@ -26,39 +56,66 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
touch-action: none;
|
||||
z-index: $z-above;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
position: absolute;
|
||||
color: $color-text-grey;
|
||||
z-index: $z-sticky;
|
||||
font-size: $font-sm;
|
||||
font-weight: $weight-medium;
|
||||
letter-spacing: $tracking-wide;
|
||||
// The SVG Viewer wrapper (Review/Lesson/Collection)
|
||||
.svg-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
|
||||
// --- Unified Styles to match Canvas ---
|
||||
background: $color-surface;
|
||||
border-radius: $radius-lg; // Matches canvas-wrapper
|
||||
border: $border-subtle; // Matches canvas-wrapper
|
||||
box-shadow: $shadow-inset; // Matches canvas-wrapper
|
||||
// -------------------------------------
|
||||
|
||||
@include grid-overlay;
|
||||
|
||||
&.hero-mode {
|
||||
background: linear-gradient(145deg, $color-surface, #18181b);
|
||||
border: 2px solid $color-border;
|
||||
box-shadow: 0 4px 20px rgb(0 0 0 / 40%);
|
||||
|
||||
.stroke-path.drawn {
|
||||
stroke: $color-text-white;
|
||||
filter: drop-shadow(0 0 2px rgb(255 255 255 / 25%));
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.svg-container {
|
||||
width: $size-kanji-preview;
|
||||
height: $size-kanji-preview;
|
||||
margin: 0 auto $spacing-lg;
|
||||
background: rgba($color-bg-dark, 0.2);
|
||||
border-radius: $radius-md;
|
||||
border: $border-subtle;
|
||||
position: relative;
|
||||
|
||||
@include flex-center;
|
||||
|
||||
overflow: hidden;
|
||||
.kanji-svg {
|
||||
z-index: $z-above;
|
||||
display: block;
|
||||
padding: 12%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stroke-path {
|
||||
fill: none;
|
||||
stroke: $color-stroke-inactive;
|
||||
stroke-width: $stroke-width-main;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
transition: stroke $duration-normal;
|
||||
stroke-width: $stroke-width-main;
|
||||
transition:
|
||||
stroke 0.3s ease,
|
||||
opacity 0.3s ease;
|
||||
|
||||
&.drawn {
|
||||
stroke: $color-stroke-drawn;
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
@@ -70,10 +127,75 @@
|
||||
stroke-dashoffset: var(--len);
|
||||
animation: draw-stroke var(--duration) linear forwards;
|
||||
}
|
||||
}
|
||||
|
||||
&.drawn {
|
||||
stroke: $color-text-white;
|
||||
opacity: 1;
|
||||
.stroke-ghost {
|
||||
fill: none;
|
||||
stroke: $color-stroke-inactive;
|
||||
stroke-width: $stroke-width-main;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.5;
|
||||
stroke-dasharray: 10 15;
|
||||
}
|
||||
|
||||
.stroke-badge-group {
|
||||
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 50%));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stroke-badge-bg {
|
||||
fill: $color-primary;
|
||||
opacity: 0.9;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.stroke-badge-text {
|
||||
fill: $color-bg-dark;
|
||||
font-size: $font-size-svg-number;
|
||||
font-family: sans-serif;
|
||||
font-weight: $weight-black;
|
||||
user-select: none;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
|
||||
.stroke-arrow-line {
|
||||
fill: none;
|
||||
stroke: $color-primary;
|
||||
stroke-width: 1.5px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 2px rgb(0 0 0 / 50%));
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: $size-icon-btn;
|
||||
height: $size-icon-btn;
|
||||
border-radius: $radius-circle;
|
||||
background: rgb(255 255 255 / 10%);
|
||||
border: 1px solid rgba($color-primary, 0.3);
|
||||
color: $color-primary;
|
||||
|
||||
@include flex-center;
|
||||
|
||||
cursor: pointer;
|
||||
z-index: $z-play-btn;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
background: rgba($color-primary, 0.2);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,65 +205,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
.stroke-start-circle {
|
||||
fill: $color-srs-1;
|
||||
}
|
||||
|
||||
.stroke-number {
|
||||
fill: $color-text-white;
|
||||
font-size: $font-size-svg-number;
|
||||
font-family: $font-family-sans;
|
||||
font-weight: $weight-bold;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stroke-arrow-line {
|
||||
fill: none;
|
||||
stroke: rgba($color-secondary, 0.7);
|
||||
stroke-width: $stroke-width-arrow;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
color: $color-text-grey;
|
||||
font-size: $font-sm;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
position: absolute;
|
||||
top: $spacing-sm;
|
||||
right: $spacing-sm;
|
||||
background: rgba($color-bg-dark, 0.3);
|
||||
border: $border-width-sm solid rgba($color-primary, 0.5);
|
||||
color: $color-primary;
|
||||
border-radius: $radius-circle;
|
||||
width: $size-icon-btn;
|
||||
height: $size-icon-btn;
|
||||
|
||||
@include flex-center;
|
||||
|
||||
cursor: pointer;
|
||||
transition: all $duration-fast ease;
|
||||
z-index: $z-sticky;
|
||||
backdrop-filter: $blur-sm;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
background: $color-primary;
|
||||
color: $color-bg-dark;
|
||||
border-color: $color-primary;
|
||||
box-shadow: $shadow-glow-base;
|
||||
@keyframes shake-x {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
20% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: $size-icon-small;
|
||||
height: $size-icon-small;
|
||||
40% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
}
|
||||
|
||||
.shake {
|
||||
animation: shake-x 0.4s ease-in-out;
|
||||
border-color: $color-danger !important;
|
||||
}
|
||||
|
||||
@@ -6,26 +6,27 @@
|
||||
@include card-base;
|
||||
|
||||
border: $border-subtle;
|
||||
background-color: $color-surface !important;
|
||||
border-radius: $radius-xl !important;
|
||||
}
|
||||
|
||||
.level-0 {
|
||||
background-color: $bg-glass-subtle;
|
||||
.welcome-btn {
|
||||
height: $size-button-large-height !important;
|
||||
width: $size-button-large-width !important;
|
||||
|
||||
.v-chip {
|
||||
color: white !important;
|
||||
background-color: $color-surface !important;
|
||||
}
|
||||
}
|
||||
|
||||
.level-1 {
|
||||
background-color: color.mix($color-primary, $color-surface, 25%);
|
||||
.streak-shield-avatar {
|
||||
border: 1px solid currentcolor;
|
||||
background: rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
.level-2 {
|
||||
background-color: color.mix($color-primary, $color-surface, 50%);
|
||||
}
|
||||
|
||||
.level-3 {
|
||||
background-color: $color-primary;
|
||||
}
|
||||
|
||||
.level-4 {
|
||||
background-color: color.scale($color-primary, $lightness: 40%);
|
||||
.streak-day-label {
|
||||
font-size: 9px !important;
|
||||
}
|
||||
|
||||
.heatmap-container {
|
||||
@@ -72,6 +73,26 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.level-0 {
|
||||
background-color: $bg-glass-subtle;
|
||||
}
|
||||
|
||||
.level-1 {
|
||||
background-color: color.mix($color-primary, $color-surface, 25%);
|
||||
}
|
||||
|
||||
.level-2 {
|
||||
background-color: color.mix($color-primary, $color-surface, 50%);
|
||||
}
|
||||
|
||||
.level-3 {
|
||||
background-color: $color-primary;
|
||||
}
|
||||
|
||||
.level-4 {
|
||||
background-color: color.scale($color-primary, $lightness: 40%);
|
||||
}
|
||||
|
||||
.legend-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -153,22 +174,16 @@
|
||||
@include scrollbar;
|
||||
}
|
||||
|
||||
.gap-1 {
|
||||
gap: $spacing-xs;
|
||||
.srs-chart-container {
|
||||
gap: 8px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.border-subtle {
|
||||
border: $border-subtle;
|
||||
}
|
||||
|
||||
.border-b-subtle {
|
||||
border-bottom: $border-subtle;
|
||||
}
|
||||
|
||||
.border-t-subtle {
|
||||
border-top: $border-subtle;
|
||||
.srs-track {
|
||||
@media (max-width: 400px) {
|
||||
width: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@
|
||||
@use 'pages/dashboard';
|
||||
@use 'pages/review';
|
||||
@use 'pages/collection';
|
||||
@use 'pages/lesson';
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
:root {
|
||||
--v-theme-background: #{$color-bg-dark};
|
||||
--color-surface: #{$color-surface};
|
||||
|
||||
@each $level, $color in $srs-colors {
|
||||
--srs-#{$level}: #{$color};
|
||||
}
|
||||
}
|
||||
|
||||
body,
|
||||
@@ -59,3 +64,13 @@ html,
|
||||
opacity: $opacity-hover;
|
||||
}
|
||||
}
|
||||
|
||||
@each $level, $color in $srs-colors {
|
||||
.text-srs-#{$level} {
|
||||
color: var(--srs-#{$level}) !important;
|
||||
}
|
||||
|
||||
.bg-srs-#{$level} {
|
||||
background-color: var(--srs-#{$level}) !important;
|
||||
}
|
||||
}
|
||||
|
||||
78
client/src/styles/pages/_lesson.scss
Normal 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;
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
@use '../abstracts/variables' as *;
|
||||
@use '../abstracts/mixins' as *;
|
||||
|
||||
.review-card {
|
||||
border: $border-subtle;
|
||||
background-color: $color-surface !important;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.text-shadow {
|
||||
text-shadow: $text-shadow;
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
.review-canvas-area {
|
||||
position: relative;
|
||||
width: $size-canvas;
|
||||
height: $size-canvas;
|
||||
border-radius: $radius-lg;
|
||||
background: $bg-glass-dark;
|
||||
box-shadow: $shadow-inset;
|
||||
margin-bottom: $spacing-sm;
|
||||
|
||||
.next-fab {
|
||||
position: absolute;
|
||||
bottom: -#{$offset-fab};
|
||||
right: -#{$offset-fab};
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: $z-sticky;
|
||||
}
|
||||
}
|
||||
@@ -45,3 +49,15 @@
|
||||
opacity: 0;
|
||||
transform: translateY(-#{$dist-slide-sm});
|
||||
}
|
||||
|
||||
.opacity-80 {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
411
client/src/utils/KanjiController.js
Normal 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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@
|
||||
.text-h2.font-weight-bold.mb-1 {{ selectedItem?.char }}
|
||||
.text-subtitle-1.text-grey-lighten-1.text-capitalize.mb-4 {{ selectedItem?.meaning }}
|
||||
|
||||
KanjiSvgViewer(
|
||||
KanjiSvgViewer.mb-4(
|
||||
v-if="showModal && selectedItem"
|
||||
:char="selectedItem.char"
|
||||
)
|
||||
@@ -97,21 +97,35 @@
|
||||
.reading-label {{ $t('collection.nanori') }}
|
||||
.reading-value {{ selectedItem?.nanori.join(', ') }}
|
||||
|
||||
v-btn.text-white(
|
||||
block
|
||||
color="#2f3542"
|
||||
@click="showModal = false"
|
||||
) {{ $t('collection.close') }}
|
||||
v-row.px-2.pb-2
|
||||
v-col.pr-2(cols="6")
|
||||
v-btn.text-white(
|
||||
block
|
||||
color="#2f3542"
|
||||
@click="showModal = false"
|
||||
) {{ $t('collection.close') }}
|
||||
v-col.pl-2(cols="6")
|
||||
v-btn.text-black(
|
||||
block
|
||||
color="purple-accent-2"
|
||||
@click="redoLesson"
|
||||
)
|
||||
| {{ selectedItem?.srsLevel === 0
|
||||
| ? $t('collection.startLesson')
|
||||
| : $t('collection.redoLesson') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useAppStore();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(true);
|
||||
const showModal = ref(false);
|
||||
@@ -119,62 +133,77 @@ const selectedItem = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchCollection();
|
||||
loading.value = false;
|
||||
await store.fetchCollection();
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
const filteredCollection = computed(() => {
|
||||
if (!searchQuery.value) return store.collection;
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
if (!searchQuery.value) return store.collection;
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
|
||||
return store.collection.filter(item => {
|
||||
if (item.meaning && item.meaning.toLowerCase().includes(q)) return true;
|
||||
if (item.char && item.char.includes(q)) return true;
|
||||
if (item.onyomi && item.onyomi.some(r => r.includes(q))) return true;
|
||||
if (item.kunyomi && item.kunyomi.some(r => r.includes(q))) return true;
|
||||
if (item.nanori && item.nanori.some(r => r.includes(q))) return true;
|
||||
return false;
|
||||
});
|
||||
return store.collection.filter((item) => {
|
||||
if (item.meaning && item.meaning.toLowerCase().includes(q)) return true;
|
||||
if (item.char && item.char.includes(q)) return true;
|
||||
if (item.onyomi && item.onyomi.some((r) => r.includes(q))) return true;
|
||||
if (item.kunyomi && item.kunyomi.some((r) => r.includes(q))) return true;
|
||||
if (item.nanori && item.nanori.some((r) => r.includes(q))) return true;
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
const groupedItems = computed(() => {
|
||||
const groups = {};
|
||||
filteredCollection.value.forEach(i => {
|
||||
if (!groups[i.level]) groups[i.level] = [];
|
||||
groups[i.level].push(i);
|
||||
});
|
||||
return groups;
|
||||
const groups = {};
|
||||
filteredCollection.value.forEach((i) => {
|
||||
if (!groups[i.level]) groups[i.level] = [];
|
||||
groups[i.level].push(i);
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
|
||||
const SRS_COLORS = {
|
||||
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4',
|
||||
4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
|
||||
7: '#00cec9', 8: '#fd79a8', 9: '#e84393',
|
||||
10: '#ffd700'
|
||||
1: '#ff7675',
|
||||
2: '#fdcb6e',
|
||||
3: '#55efc4',
|
||||
4: '#0984e3',
|
||||
5: '#a29bfe',
|
||||
6: '#6c5ce7',
|
||||
7: '#00cec9',
|
||||
8: '#fd79a8',
|
||||
9: '#e84393',
|
||||
10: '#ffd700',
|
||||
};
|
||||
|
||||
const getSRSColor = (lvl) => SRS_COLORS[lvl] || '#444';
|
||||
|
||||
const openDetail = (item) => {
|
||||
selectedItem.value = item;
|
||||
showModal.value = true;
|
||||
selectedItem.value = item;
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const redoLesson = () => {
|
||||
if (!selectedItem.value) return;
|
||||
router.push({
|
||||
path: '/lesson',
|
||||
query: { subjectId: selectedItem.value.wkSubjectId },
|
||||
state: { item: JSON.parse(JSON.stringify(selectedItem.value)) },
|
||||
});
|
||||
};
|
||||
|
||||
const hasReading = (arr) => arr && arr.length > 0;
|
||||
|
||||
const getNextReviewText = (dateStr, srsLvl) => {
|
||||
if (srsLvl >= 10) return 'COMPLETE';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
if (date <= now) return t('stats.availableNow');
|
||||
if (srsLvl >= 10) return 'COMPLETE';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
if (date <= now) return t('stats.availableNow');
|
||||
|
||||
const diffMs = date - now;
|
||||
const diffHrs = Math.floor(diffMs / 3600000);
|
||||
const diffMs = date - now;
|
||||
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);
|
||||
return `Next: ${diffDays}d`;
|
||||
const diffDays = Math.floor(diffHrs / 24);
|
||||
return `Next: ${diffDays}d`;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.dashboard-layout.fade-in
|
||||
WidgetWelcome(
|
||||
:queue-length="stats.queueLength"
|
||||
:lesson-count="stats.lessonCount"
|
||||
:has-lower-levels="stats.hasLowerLevels"
|
||||
:lower-level-count="stats.lowerLevelCount"
|
||||
:forecast="stats.forecast"
|
||||
@@ -31,9 +32,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/* eslint-disable no-unused-vars */
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
|
||||
import WidgetWelcome from '@/components/dashboard/WidgetWelcome.vue';
|
||||
import WidgetHeatmap from '@/components/dashboard/WidgetHeatmap.vue';
|
||||
@@ -49,32 +51,33 @@ const router = useRouter();
|
||||
const loading = ref(true);
|
||||
|
||||
const stats = ref({
|
||||
queueLength: 0,
|
||||
hasLowerLevels: false,
|
||||
lowerLevelCount: 0,
|
||||
distribution: {},
|
||||
forecast: [],
|
||||
ghosts: [],
|
||||
accuracy: { total: 0, correct: 0 },
|
||||
heatmap: {},
|
||||
streak: { current: 0, history: [], shield: { ready: false } }
|
||||
queueLength: 0,
|
||||
lessonCount: 0,
|
||||
hasLowerLevels: false,
|
||||
lowerLevelCount: 0,
|
||||
distribution: {},
|
||||
forecast: [],
|
||||
ghosts: [],
|
||||
accuracy: { total: 0, correct: 0 },
|
||||
heatmap: {},
|
||||
streak: { current: 0, history: [], shield: { ready: false } },
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await store.fetchStats();
|
||||
if (data) {
|
||||
stats.value = { ...stats.value, ...data };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Dashboard Load Error:", e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
try {
|
||||
const data = await store.fetchStats();
|
||||
if (data) {
|
||||
stats.value = { ...stats.value, ...data };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Dashboard Load Error:', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function handleStart(mode) {
|
||||
router.push({ path: '/review', query: { mode: mode } });
|
||||
router.push({ path: '/review', query: { mode } });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
194
client/src/views/Lesson.vue
Normal file
@@ -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>
|
||||
@@ -1,11 +1,8 @@
|
||||
<template lang="pug">
|
||||
v-container.fill-height.justify-center.pa-4
|
||||
v-fade-transition(mode="out-in")
|
||||
v-card.pa-6.rounded-xl.elevation-10.border-subtle.d-flex.flex-column.align-center(
|
||||
v-card.pa-6.rounded-xl.elevation-10.d-flex.flex-column.align-center.review-card(
|
||||
v-if="currentItem"
|
||||
color="#1e1e24"
|
||||
width="100%"
|
||||
max-width="420"
|
||||
)
|
||||
.d-flex.align-center.w-100.mb-6
|
||||
v-btn.mr-2(
|
||||
@@ -30,24 +27,25 @@
|
||||
.text-h3.font-weight-bold.text-white.text-shadow
|
||||
| {{ currentItem.meaning }}
|
||||
|
||||
.canvas-wrapper.mb-2
|
||||
.review-canvas-area
|
||||
KanjiCanvas(
|
||||
ref="kanjiCanvasRef"
|
||||
:char="currentItem.char"
|
||||
@complete="handleComplete"
|
||||
@mistake="handleMistake"
|
||||
)
|
||||
|
||||
transition(name="scale")
|
||||
v-btn.next-fab.glow-btn.text-black(
|
||||
v-if="showNext"
|
||||
@click="next"
|
||||
color="#00cec9"
|
||||
color="primary"
|
||||
icon="mdi-arrow-right"
|
||||
size="large"
|
||||
elevation="8"
|
||||
)
|
||||
|
||||
.mb-4
|
||||
.mb-4.d-flex.gap-2
|
||||
v-btn.text-caption.font-weight-bold.opacity-80(
|
||||
variant="text"
|
||||
color="amber-lighten-1"
|
||||
@@ -55,7 +53,16 @@
|
||||
size="small"
|
||||
:disabled="showNext || statusCode === 'hint'"
|
||||
@click="triggerHint"
|
||||
) Show Hint
|
||||
) {{ $t('review.showHint') }}
|
||||
|
||||
v-btn.text-caption.font-weight-bold.opacity-80(
|
||||
variant="text"
|
||||
color="purple-lighten-2"
|
||||
prepend-icon="mdi-school-outline"
|
||||
size="small"
|
||||
:disabled="showNext"
|
||||
@click="redoLesson"
|
||||
) {{ $t('review.redoLesson') }}
|
||||
|
||||
v-sheet.d-flex.align-center.justify-center(
|
||||
width="100%"
|
||||
@@ -68,29 +75,25 @@
|
||||
:class="getStatusClass(statusCode)"
|
||||
) {{ $t('review.' + statusCode) }}
|
||||
|
||||
v-progress-linear.mt-4(
|
||||
v-progress-linear.mt-4.progress-bar(
|
||||
v-model="progressPercent"
|
||||
color="#00cec9"
|
||||
color="primary"
|
||||
height="4"
|
||||
rounded
|
||||
style="opacity: 0.3;"
|
||||
)
|
||||
|
||||
v-card.pa-8.rounded-xl.elevation-10.border-subtle.text-center(
|
||||
v-card.pa-8.rounded-xl.elevation-10.text-center.review-card(
|
||||
v-else-if="sessionTotal > 0 && store.queue.length === 0"
|
||||
color="#1e1e24"
|
||||
width="100%"
|
||||
max-width="400"
|
||||
)
|
||||
.mb-6
|
||||
v-avatar(color="rgba(0, 206, 201, 0.1)" size="80")
|
||||
v-icon(size="40" color="#00cec9") mdi-trophy
|
||||
v-icon(size="40" color="primary") mdi-trophy
|
||||
|
||||
.text-h4.font-weight-bold.mb-2 {{ $t('review.sessionComplete') }}
|
||||
.text-body-1.text-grey.mb-8 {{ $t('review.levelup') }}
|
||||
|
||||
v-row.mb-6
|
||||
v-col.border-r.border-grey-darken-3(cols="6")
|
||||
v-col.border-r.border-subtle(cols="6")
|
||||
.text-h3.font-weight-bold.text-white {{ accuracy }}%
|
||||
.text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.accuracy') }}
|
||||
v-col(cols="6")
|
||||
@@ -100,7 +103,7 @@
|
||||
v-btn.text-black.font-weight-bold.glow-btn(
|
||||
to="/dashboard"
|
||||
block
|
||||
color="#00cec9"
|
||||
color="primary"
|
||||
height="50"
|
||||
rounded="lg"
|
||||
) {{ $t('review.back') }}
|
||||
@@ -111,19 +114,23 @@
|
||||
.text-grey.mb-6 {{ $t('review.noReviews') }}
|
||||
v-btn.font-weight-bold(
|
||||
to="/dashboard"
|
||||
color="#00cec9"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
) {{ $t('review.viewCollection') }}
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
/* eslint-disable no-unused-vars */
|
||||
import {
|
||||
ref, onMounted, computed, watch,
|
||||
} from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import { useRoute } from 'vue-router';
|
||||
import KanjiCanvas from '@/components/kanji/KanjiCanvas.vue';
|
||||
|
||||
const store = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const currentItem = ref(null);
|
||||
const statusCode = ref('draw');
|
||||
@@ -136,103 +143,113 @@ const sessionDone = ref(0);
|
||||
const sessionCorrect = ref(0);
|
||||
|
||||
const accuracy = computed(() => {
|
||||
if (sessionDone.value === 0) return 100;
|
||||
return Math.round((sessionCorrect.value / sessionDone.value) * 100);
|
||||
if (sessionDone.value === 0) return 100;
|
||||
return Math.round((sessionCorrect.value / sessionDone.value) * 100);
|
||||
});
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
if (sessionTotal.value === 0) return 0;
|
||||
return (sessionDone.value / sessionTotal.value) * 100;
|
||||
if (sessionTotal.value === 0) return 0;
|
||||
return (sessionDone.value / sessionTotal.value) * 100;
|
||||
});
|
||||
|
||||
function loadNext() {
|
||||
if (store.queue.length === 0) {
|
||||
currentItem.value = null;
|
||||
return;
|
||||
}
|
||||
const idx = Math.floor(Math.random() * store.queue.length);
|
||||
currentItem.value = store.queue[idx];
|
||||
|
||||
statusCode.value = 'draw';
|
||||
showNext.value = false;
|
||||
isFailure.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const mode = route.query.mode || 'shuffled';
|
||||
await store.fetchQueue(mode);
|
||||
const mode = route.query.mode || 'shuffled';
|
||||
await store.fetchQueue(mode);
|
||||
|
||||
if (sessionTotal.value === 0 && store.queue.length > 0) {
|
||||
sessionTotal.value = store.queue.length;
|
||||
}
|
||||
loadNext();
|
||||
});
|
||||
|
||||
watch(() => store.batchSize, async () => {
|
||||
resetSession();
|
||||
if (sessionTotal.value === 0 && store.queue.length > 0) {
|
||||
sessionTotal.value = store.queue.length;
|
||||
}
|
||||
loadNext();
|
||||
});
|
||||
|
||||
async function resetSession() {
|
||||
sessionDone.value = 0;
|
||||
sessionCorrect.value = 0;
|
||||
sessionTotal.value = 0;
|
||||
currentItem.value = null;
|
||||
const mode = route.query.mode || 'shuffled';
|
||||
await store.fetchQueue(mode);
|
||||
if (store.queue.length > 0) sessionTotal.value = store.queue.length;
|
||||
loadNext();
|
||||
sessionDone.value = 0;
|
||||
sessionCorrect.value = 0;
|
||||
sessionTotal.value = 0;
|
||||
currentItem.value = null;
|
||||
const mode = route.query.mode || 'shuffled';
|
||||
await store.fetchQueue(mode);
|
||||
if (store.queue.length > 0) sessionTotal.value = store.queue.length;
|
||||
loadNext();
|
||||
}
|
||||
|
||||
function loadNext() {
|
||||
if (store.queue.length === 0) {
|
||||
currentItem.value = null;
|
||||
return;
|
||||
}
|
||||
const idx = Math.floor(Math.random() * store.queue.length);
|
||||
currentItem.value = store.queue[idx];
|
||||
|
||||
statusCode.value = "draw";
|
||||
showNext.value = false;
|
||||
isFailure.value = false;
|
||||
}
|
||||
watch(() => store.batchSize, async () => {
|
||||
resetSession();
|
||||
});
|
||||
|
||||
function triggerHint() {
|
||||
if (!kanjiCanvasRef.value) return;
|
||||
if (!kanjiCanvasRef.value) return;
|
||||
|
||||
isFailure.value = true;
|
||||
statusCode.value = "hint";
|
||||
isFailure.value = true;
|
||||
statusCode.value = 'hint';
|
||||
|
||||
kanjiCanvasRef.value.drawGuide(true);
|
||||
kanjiCanvasRef.value.drawGuide(true);
|
||||
}
|
||||
|
||||
function handleMistake(isHint) {
|
||||
if (isHint) {
|
||||
isFailure.value = true;
|
||||
statusCode.value = "hint";
|
||||
} else {
|
||||
statusCode.value = "tryAgain";
|
||||
}
|
||||
if (isHint) {
|
||||
isFailure.value = true;
|
||||
statusCode.value = 'hint';
|
||||
} else {
|
||||
statusCode.value = 'tryAgain';
|
||||
}
|
||||
}
|
||||
|
||||
function handleComplete() {
|
||||
statusCode.value = "correct";
|
||||
showNext.value = true;
|
||||
statusCode.value = 'correct';
|
||||
showNext.value = true;
|
||||
}
|
||||
|
||||
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++;
|
||||
if (!isFailure.value) sessionCorrect.value++;
|
||||
const index = store.queue.findIndex(i => i._id === currentItem.value._id);
|
||||
if (index !== -1) {
|
||||
store.queue.splice(index, 1);
|
||||
}
|
||||
sessionDone.value += 1;
|
||||
if (!isFailure.value) sessionCorrect.value += 1;
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const index = store.queue.findIndex((i) => i._id === currentItem.value._id);
|
||||
if (index !== -1) {
|
||||
store.queue.splice(index, 1);
|
||||
}
|
||||
|
||||
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 colors = { 1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7' };
|
||||
return colors[lvl] || 'grey';
|
||||
const colors = {
|
||||
1: '#ff7675', 2: '#fdcb6e', 3: '#55efc4', 4: '#0984e3', 5: '#a29bfe', 6: '#6c5ce7',
|
||||
};
|
||||
return colors[lvl] || 'grey';
|
||||
};
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
switch (status) {
|
||||
case 'hint': return 'text-red-lighten-1';
|
||||
case 'correct': return 'text-teal-accent-3';
|
||||
default: return 'text-grey-lighten-1';
|
||||
}
|
||||
switch (status) {
|
||||
case 'hint': return 'text-red-lighten-1';
|
||||
case 'correct': return 'text-teal-accent-3';
|
||||
default: return 'text-grey-lighten-1';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" src="@/styles/pages/_review.scss" scoped></style>
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
export default {
|
||||
extends: [
|
||||
'stylelint-config-standard-scss',
|
||||
'stylelint-config-recommended-vue'
|
||||
'stylelint-config-recommended-vue',
|
||||
],
|
||||
ignoreFiles: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/android/**'
|
||||
'**/android/**',
|
||||
],
|
||||
rules: {
|
||||
'at-rule-no-unknown': null,
|
||||
'scss/at-rule-no-unknown': true,
|
||||
'no-empty-source': null,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `@use "@/styles/abstracts/_variables.scss" as *; @use "@/styles/abstracts/_mixins.scss" as *;`
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||