3 Commits

Author SHA1 Message Date
Rene Kievits
f703cc5727 add more infos to overview, fix redo lessons button, add readings to review for clarification
All checks were successful
Release Build / build-docker (push) Successful in 1m27s
Release Build / build-android-and-release (push) Successful in 2m59s
2026-01-11 21:36:45 +01:00
Rene Kievits
2129cee5c4 update android style to test auto update
All checks were successful
Release Build / build-docker (push) Successful in 25s
Release Build / build-android-and-release (push) Successful in 2m36s
2025-12-26 23:05:47 +01:00
Rene Kievits
3da110d6cc add auto update for android
All checks were successful
Release Build / build-docker (push) Successful in 41s
Release Build / build-android-and-release (push) Successful in 2m35s
2025-12-26 22:57:40 +01:00
19 changed files with 286 additions and 123 deletions

View File

@@ -73,6 +73,7 @@ jobs:
run: |
npm ci
npm run build:android
zip -r dist.zip dist
- name: Sync Capacitor to Android
working-directory: client
@@ -105,4 +106,5 @@ jobs:
- Client: `${{ vars.REGISTRY }}/${{ vars.CLIENT_IMAGE }}:latest`
files: |
client/android/app/build/outputs/apk/release/*.apk
client/dist.zip
api_key: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -9,7 +9,8 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capgo-capacitor-updater')
}

View File

@@ -1,3 +1,9 @@
// 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')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capgo-capacitor-updater'
project(':capgo-capacitor-updater').projectDir = new File('../node_modules/@capgo/capacitor-updater/android')

View File

@@ -5,5 +5,10 @@
"server": {
"androidScheme": "https",
"cleartext": false
},
"plugins": {
"CapacitorUpdater": {
"autoUpdate": false
}
}
}

View File

@@ -9,7 +9,9 @@
"version": "0.0.0",
"dependencies": {
"@capacitor/android": "^8.0.0",
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.0.0",
"@capgo/capacitor-updater": "^8.40.6",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@mdi/font": "^7.3.67",
@@ -639,6 +641,15 @@
"@capacitor/core": "^8.0.0"
}
},
"node_modules/@capacitor/app": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.0.0.tgz",
"integrity": "sha512-OwzIkUs4w433Bu9WWAEbEYngXEfJXZ9Wmdb8eoaqzYBgB0W9/3Ed/mh6sAYPNBAZlpyarmewgP7Nb+d3Vrh+xA==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/cli": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-7.4.4.tgz",
@@ -681,6 +692,15 @@
"tslib": "^2.1.0"
}
},
"node_modules/@capgo/capacitor-updater": {
"version": "8.40.6",
"resolved": "https://registry.npmjs.org/@capgo/capacitor-updater/-/capacitor-updater-8.40.6.tgz",
"integrity": "sha512-Z2uG8E9VKClYeZRhvkN8p/w3tXqxj5DB61M3uKwuHyHgPWEOoBv+drlRtnwDL8+XhSljNLG7yGPWUg8WAi8cBQ==",
"license": "MPL-2.0",
"peerDependencies": {
"@capacitor/core": "^8.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",

View File

@@ -14,7 +14,9 @@
},
"dependencies": {
"@capacitor/android": "^8.0.0",
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.0.0",
"@capgo/capacitor-updater": "^8.40.6",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@mdi/font": "^7.3.67",

View File

@@ -1,28 +1,7 @@
<template lang="pug">
v-app.zen-app.overflow-hidden
.parallax-bg(
:style="parallaxStyle"
:class="{ 'zooming': viewState === 'zooming' }"
)
.bg-gradient
.bg-grid
.kanji-layer
span.bg-kanji(
v-for="(k, i) in backgroundKanjis"
:key="i"
:style="k.style"
) {{ k.char }}
.orb.orb-1(:class="{ 'zooming': viewState === 'zooming' }")
.orb.orb-2(:class="{ 'zooming': viewState === 'zooming' }")
.orb.orb-3(:class="{ 'zooming': viewState === 'zooming' }")
.app-content-wrapper(
v-if="store.token || viewState === 'zooming'"
:class="{ 'app-entering': viewState === 'zooming', 'app-visible': viewState === 'app' }"
)
v-navigation-drawer(
v-if="store.token || viewState === 'zooming'"
v-model="drawer"
temporary
location="right"
@@ -83,6 +62,28 @@
)
v-icon(start icon="mdi-logout")
| {{ $t('nav.logout') }}
.parallax-bg(
:style="parallaxStyle"
:class="{ 'zooming': viewState === 'zooming' }"
)
.bg-gradient
.bg-grid
.kanji-layer
span.bg-kanji(
v-for="(k, i) in backgroundKanjis"
:key="i"
:style="k.style"
) {{ k.char }}
.orb.orb-1(:class="{ 'zooming': viewState === 'zooming' }")
.orb.orb-2(:class="{ 'zooming': viewState === 'zooming' }")
.orb.orb-3(:class="{ 'zooming': viewState === 'zooming' }")
.app-content-wrapper(
v-if="store.token || viewState === 'zooming'"
:class="{ 'app-entering': viewState === 'zooming', 'app-visible': viewState === 'app' }"
)
v-app-bar.px-2.app-bar-blur.safe-area-header(
flat
@@ -287,7 +288,9 @@ import {
ref, watch, onMounted, computed, onUnmounted,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { App as CapacitorApp } from '@capacitor/app';
import { useAppStore } from '@/stores/appStore';
import { checkForUpdates } from '@/utils/autoUpdate';
import logo from '@/assets/icon.svg';
const drawer = ref(false);
@@ -388,6 +391,9 @@ const handleMouseMove = (e) => {
};
onMounted(() => {
CapacitorApp.addListener('appStateChange', ({ isActive }) => {
if (isActive) checkForUpdates();
});
if (store.token) {
viewState.value = 'app';
store.fetchStats();

View File

@@ -25,10 +25,10 @@ const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Dashboard },
{ path: '/dashboard', component: Dashboard },
{ path: '/collection', component: Collection },
{ path: '/review', component: Review },
{ path: '/lesson', component: Lesson },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
});

View File

@@ -1,3 +1,5 @@
@use 'readings';
@use 'radicals';
@use 'buttons';
@use 'cards';
@use 'kanji/index' as kanji;

View File

@@ -0,0 +1,20 @@
@use '../abstracts' as *;
.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-width-sm solid $color-border;
background-color: $color-surface-lighter;
img {
filter: invert(1);
width: 100%;
}
}

View File

@@ -0,0 +1,28 @@
@use '../abstracts' as *;
.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: 0.0625rem;
text-transform: uppercase;
margin-bottom: $spacing-xs;
}
.val {
font-size: 1.1rem;
font-weight: $weight-bold;
}
}

View File

@@ -2,7 +2,7 @@
.v-navigation-drawer {
background-color: $color-surface !important;
border-right: $border-width-sm solid $color-border;
padding-top: env(safe-area-inset-top) !important;
.v-list-item--active {
background-color: $bg-glass-strong;

View File

@@ -16,52 +16,6 @@
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-width-sm solid $color-border;
background-color: $color-surface-lighter;
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: 0.0625rem;
text-transform: uppercase;
margin-bottom: $spacing-xs;
}
.val {
font-size: 1.1rem;
font-weight: $weight-bold;
}
}
.lesson-canvas-wrapper {
margin: 0 auto;
}

View File

@@ -45,3 +45,10 @@
.gap-2 {
gap: $spacing-sm;
}
.reading-box--small {
padding: $spacing-sm;
.val {
font-size: 1rem;
}
}

View File

@@ -0,0 +1,55 @@
import { Capacitor } from '@capacitor/core';
import { App } from '@capacitor/app';
export async function checkForUpdates() {
if (!Capacitor.isNativePlatform()) {
console.log('Auto-update skipped (running on web)');
return;
}
function isNewer(current, target) {
const c = current.replace(/^v/, '').split('.').map(Number);
const t = target.replace(/^v/, '').split('.').map(Number);
for (let i = 0; i < 3; i++) {
if (t[i] > c[i]) return true;
if (t[i] < c[i]) return false;
}
return false;
}
try {
const { CapacitorUpdater } = await import('@capgo/capacitor-updater');
await CapacitorUpdater.notifyAppReady();
const GITEA_API_URL = 'https://git.crylia.de/Crylia/zen-kanji/releases/latest';
const appInfo = await App.getInfo();
const currentVersion = appInfo.version;
const response = await fetch(GITEA_API_URL);
if (!response.ok) return;
const release = await response.json();
const latestTag = release.tag_name;
if (!isNewer(currentVersion, latestTag)) {
return;
}
const asset = release.assets.find((a) => a.name === 'dist.zip');
if (!asset) return;
console.log(`Downloading update: ${latestTag}`);
const version = await CapacitorUpdater.download({
url: asset.browser_download_url,
version: latestTag,
});
await CapacitorUpdater.set(version);
} catch (error) {
console.error('Auto-update failed:', error);
}
}

View File

@@ -92,6 +92,18 @@
:svg-content="preloadedSvg"
)
.radical-section.mb-4(v-if="selectedItem?.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 selectedItem.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-container.mb-4
.reading-group(v-if="hasReading(selectedItem?.onyomi)")
.reading-label {{ $t('collection.onyomi') }}
@@ -105,6 +117,17 @@
.reading-label {{ $t('collection.nanori') }}
.reading-value {{ selectedItem?.nanori.join(', ') }}
.px-2.mb-4(v-if="selectedItem?.stats?.total > 0")
.d-flex.justify-space-between.align-center.mb-1
.text-caption.text-grey {{ $t('stats.accuracy') }}
.text-caption.font-weight-bold {{ accuracyStats.text }}
v-progress-linear(
:model-value="accuracyStats.percent"
color="light-blue"
height="6"
rounded
)
v-row.px-2.pb-2
v-col.pr-2(cols="6")
v-btn.text-white(
@@ -142,6 +165,16 @@ const searchQuery = ref('');
const preparingId = ref(null);
const preloadedSvg = ref('');
const accuracyStats = computed(() => {
if (!selectedItem.value?.stats || selectedItem.value.stats.total === 0) {
return { percent: 100, text: 'No history' };
}
const { correct, total } = selectedItem.value.stats;
const percent = Math.round((correct / total) * 100);
const text = `${correct} / ${total}`;
return { percent, text };
});
onMounted(async () => {
await store.fetchCollection();
loading.value = false;

View File

@@ -8,7 +8,7 @@
v-else-if="currentItem"
)
.d-flex.align-center.pa-4.border-b-subtle
v-btn(icon="mdi-close" variant="text" to="/dashboard" color="grey")
v-btn(icon="mdi-close" variant="text" to="/" color="grey")
v-spacer
.text-caption.text-grey-darken-1.font-weight-bold
| {{ sessionDone + 1 }} / {{ sessionTotal }}
@@ -38,13 +38,13 @@
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
.readings-grid(v-if="(currentItem.onyomi && currentItem.onyomi.length > 0) || (currentItem.kunyomi && currentItem.kunyomi.length > 0)")
.reading-box(v-if="currentItem.onyomi && currentItem.onyomi.length > 0")
.label {{ $t('collection.onyomi') }}
.val.text-cyan-lighten-3 {{ currentItem.onyomi[0] || '-' }}
.reading-box
.val.text-cyan-lighten-3 {{ currentItem.onyomi.join(', ') }}
.reading-box(v-if="currentItem.kunyomi && currentItem.kunyomi.length > 0")
.label {{ $t('collection.kunyomi') }}
.val.text-pink-lighten-3 {{ currentItem.kunyomi[0] || '-' }}
.val.text-pink-lighten-3 {{ currentItem.kunyomi.join(', ') }}
v-window-item(value="demo")
.d-flex.flex-column.align-center.justify-center.h-100.pa-6
@@ -95,7 +95,7 @@
.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"
to="/"
block
color="cyan"
height="50"
@@ -111,12 +111,14 @@ import {
ref, computed, onMounted, nextTick, watch,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
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 route = useRoute();
const currentItem = ref(null);
const phase = ref('primer');
const subPhase = ref('guided');
@@ -179,7 +181,12 @@ async function handleDrawComplete() {
canvas.value?.reset();
canvasOpacity.value = 1;
} else {
const isRedo = !!route.query.subjectId;
if (!isRedo) {
await store.submitLesson(currentItem.value.wkSubjectId);
}
sessionDone.value += 1;
store.lessonQueue.shift();
loadNext();
@@ -200,7 +207,15 @@ function useHint() {
onMounted(async () => {
loading.value = true;
const { subjectId } = route.query;
const historyState = window.history.state;
if (subjectId && historyState && historyState.item) {
store.lessonQueue = [historyState.item];
} else {
await store.fetchLessonQueue();
}
sessionTotal.value = store.lessonQueue.length;
loadNext();
loading.value = false;

View File

@@ -28,8 +28,15 @@
.text-center.mb-6
.text-caption.text-grey.text-uppercase.tracking-wide.mb-1
| {{ $t('review.meaning') }}
.text-h3.font-weight-bold.text-white.text-shadow
.text-h3.font-weight-bold.text-white.text-shadow.mb-4
| {{ currentItem.meaning }}
.readings-grid(v-if="(currentItem.onyomi && currentItem.onyomi.length > 0) || (currentItem.kunyomi && currentItem.kunyomi.length > 0)")
.reading-box.reading-box--small(v-if="currentItem.onyomi && currentItem.onyomi.length > 0")
.label {{ $t('collection.onyomi') }}
.val.text-cyan-lighten-3 {{ currentItem.onyomi.join(', ') }}
.reading-box.reading-box--small(v-if="currentItem.kunyomi && currentItem.kunyomi.length > 0")
.label {{ $t('collection.kunyomi') }}
.val.text-pink-lighten-3 {{ currentItem.kunyomi.join(', ') }}
.review-canvas-area
KanjiCanvas(
@@ -96,7 +103,7 @@
.text-caption.text-grey.text-uppercase.mt-1 {{ $t('stats.correct') }}
v-btn.text-black.font-weight-bold.glow-btn(
to="/dashboard"
to="/"
block
color="primary"
height="50"
@@ -108,7 +115,7 @@
.text-h4.mb-2.text-white {{ $t('review.caughtUp') }}
.text-grey.mb-6 {{ $t('review.noReviews') }}
v-btn.font-weight-bold(
to="/dashboard"
to="/"
color="primary"
variant="tonal"
) {{ $t('review.viewCollection') }}

View File

@@ -93,7 +93,7 @@ export const getUserStats = async (user) => {
accuracy: Math.round((item.stats.correct / item.stats.total) * 100)
})).filter(i => i.accuracy < 85)
.sort((a, b) => a.accuracy - b.accuracy)
.slice(0, 4);
.slice(0, 10);
return {
distribution: dist,