This commit is contained in:
Rene Kievits
2025-12-18 01:30:52 +01:00
commit 6438660b03
78 changed files with 14230 additions and 0 deletions

348
client/src/App.vue Normal file
View File

@@ -0,0 +1,348 @@
<template lang="pug">
v-app.zen-app
v-navigation-drawer(
v-model="drawer"
temporary
location="right"
color="#1e1e24"
class="border-none"
width="280"
)
.d-flex.flex-column.h-100.pa-4
.d-flex.align-center.mb-6.px-2
img.mr-3(:src="logo" height="32" width="32" alt="Logo")
span.text-h6.font-weight-bold {{ $t('nav.menu') }}
v-list.pa-0(nav bg-color="transparent")
v-list-item.mb-2(
to="/"
rounded="lg"
:active="$route.path === '/'"
)
template(v-slot:prepend)
v-icon(color="#00cec9" icon="mdi-view-dashboard")
v-list-item-title.font-weight-bold {{ $t('nav.dashboard') }}
v-list-item.mb-2(
to="/collection"
rounded="lg"
:active="$route.path === '/collection'"
)
template(v-slot:prepend)
v-icon(color="#00cec9" icon="mdi-bookshelf")
v-list-item-title.font-weight-bold {{ $t('nav.collection') }}
v-divider.my-4.border-subtle
.d-flex.flex-column.gap-2
v-btn.justify-start.px-4(
variant="text"
block
color="grey-lighten-1"
@click="showSettings = true; drawer = false"
)
v-icon(start icon="mdi-cog")
| {{ $t('nav.settings') }}
v-btn.justify-start.px-4(
variant="text"
block
color="grey-lighten-1"
:loading="syncing"
@click="manualSync"
)
v-icon(start icon="mdi-sync")
| {{ $t('nav.sync') }}
v-btn.justify-start.px-4.text-red-lighten-2(
variant="text"
block
@click="handleLogout"
)
v-icon(start icon="mdi-logout")
| {{ $t('nav.logout') }}
v-app-bar.px-2.app-bar-blur(
flat
color="rgba(30, 30, 36, 0.8)"
border="b"
)
v-app-bar-title.font-weight-bold.text-h6(style="min-width: fit-content;")
.d-flex.align-center.cursor-pointer.logo-hover(@click="$router.push('/')")
img(:src="logo" height="32" width="32" alt="Zen Kanji Logo")
span.ml-3.tracking-tight Zen Kanji
v-spacer
template(v-if="store.token")
.d-none.d-md-flex.align-center
v-btn.mx-1(
to="/"
:active="false"
variant="text"
:color="$route.path === '/' ? '#00cec9' : 'grey'"
) {{ $t('nav.dashboard') }}
v-btn.mx-1(
to="/collection"
:active="false"
variant="text"
:color="$route.path === '/collection' ? '#00cec9' : 'grey'"
) {{ $t('nav.collection') }}
v-divider.mx-2.my-auto(vertical length="20" color="grey-darken-2")
v-tooltip(:text="$t('nav.settings')" location="bottom")
template(v-slot:activator="{ props }")
v-btn(
v-bind="props"
icon="mdi-cog"
variant="text"
color="grey-lighten-1"
@click="showSettings = true"
)
v-tooltip(:text="$t('nav.sync')" location="bottom")
template(v-slot:activator="{ props }")
v-btn(
v-bind="props"
icon="mdi-sync"
variant="text"
:loading="syncing"
color="grey-lighten-1"
@click="manualSync"
)
v-tooltip(:text="$t('nav.logout')" location="bottom")
template(v-slot:activator="{ props }")
v-btn(
v-bind="props"
icon="mdi-logout"
variant="text"
color="grey-darken-1"
@click="handleLogout"
)
.d-flex.d-md-none
v-btn(
icon="mdi-menu"
variant="text"
color="grey-lighten-1"
@click="drawer = !drawer"
)
v-main
.fill-height.d-flex.align-center.justify-center.px-4(v-if="!store.token")
v-card.pa-6.rounded-lg.elevation-10.text-center.border-subtle(
color="#1e1e24"
width="100%"
max-width="400"
)
img.mb-4(:src="logo" height="64" width="64")
h1.text-h5.font-weight-bold.mb-2 {{ $t('hero.welcome') }}
p.text-grey-lighten-1.text-body-2.mb-6 {{ $t('login.instruction') }}
v-text-field.mb-2(
v-model="inputKey"
:placeholder="$t('login.placeholder')"
variant="solo-filled"
bg-color="#2f3542"
color="white"
hide-details
density="comfortable"
@keyup.enter="handleLogin"
)
v-alert.mb-4.text-left.text-caption(
v-if="errorMsg"
type="error"
density="compact"
variant="tonal"
) {{ errorMsg }}
v-btn.text-black.font-weight-bold.mt-4(
block
color="#00cec9"
height="44"
:loading="loggingIn"
@click="handleLogin"
) {{ $t('login.button') }}
router-view(v-else)
v-dialog(v-model="showSettings" max-width="340")
v-card.rounded-xl.pa-4.border-subtle(color="#1e1e24")
v-card-title.text-center.font-weight-bold {{ $t('settings.title') }}
v-card-text.pt-4
.text-caption.text-grey.mb-2 {{ $t('settings.batchSize') }}
v-slider(
v-model="tempBatchSize"
:min="5"
:max="100"
:step="5"
thumb-label
color="#00cec9"
track-color="grey-darken-3"
)
.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
v-slider(
v-model="tempDrawingAccuracy"
:min="5"
:max="20"
:step="1"
thumb-label
color="#00cec9"
track-color="grey-darken-3"
)
.d-flex.justify-space-between.text-caption.text-grey-lighten-1.mb-6.px-1
span Strict (5)
span.font-weight-bold.text-body-1(color="#00cec9") {{ tempDrawingAccuracy }}
span Loose (20)
.text-caption.text-grey.mb-2 {{ $t('settings.language') }}
v-btn-toggle.d-flex.w-100.border-subtle(
v-model="$i18n.locale"
mandatory
rounded="lg"
color="#00cec9"
)
v-btn.flex-grow-1(value="en") EN
v-btn.flex-grow-1(value="de") DE
v-btn.flex-grow-1(value="ja") JA
v-card-actions.mt-2
v-btn.text-white(
block
color="#2f3542"
@click="saveSettings"
) {{ $t('settings.save') }}
v-dialog(v-model="showLogoutDialog" max-width="320")
v-card.rounded-xl.pa-4.border-subtle(color="#1e1e24")
.d-flex.flex-column.align-center.text-center.pt-2
v-avatar.mb-3(color="red-darken-4" size="48")
v-icon(icon="mdi-logout" size="24" color="red-lighten-1")
h3.text-h6.font-weight-bold.mb-2 {{ $t('nav.logout') }}?
p.text-body-2.text-grey-lighten-1.px-2.mb-4
| {{ $t('alerts.logoutConfirm') }}
v-card-actions.d-flex.gap-2.pa-0.mt-2
v-btn.flex-grow-1.text-capitalize(
variant="tonal"
color="grey"
height="40"
@click="showLogoutDialog = false"
) {{ $t('common.cancel') }}
v-btn.flex-grow-1.text-capitalize(
color="red-accent-2"
variant="flat"
height="40"
@click="confirmLogout"
) {{ $t('nav.logout') }}
v-snackbar(v-model="snackbar.show" :color="snackbar.color" timeout="3000")
| {{ snackbar.text }}
template(v-slot:actions)
v-btn(variant="text" @click="snackbar.show = false") {{ $t('common.close') }}
</template>
<script setup>
import { ref, watch, onMounted } from 'vue';
import { useAppStore } from '@/stores/appStore';
import { useI18n } from 'vue-i18n';
import logo from '@/assets/icon.svg';
const drawer = ref(false);
const { locale, t } = useI18n();
const store = useAppStore();
const inputKey = ref('');
const loggingIn = ref(false);
const syncing = ref(false);
const errorMsg = ref('');
const showSettings = ref(false);
const showLogoutDialog = ref(false);
const snackbar = ref({ show: false, text: '', color: 'success' });
const tempBatchSize = ref(store.batchSize);
const tempDrawingAccuracy = ref(store.drawingAccuracy);
onMounted(() => {
if (store.token) {
store.fetchStats();
}
});
watch(showSettings, (isOpen) => {
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;
}
}
function saveSettings() {
store.saveSettings({
batchSize: tempBatchSize.value,
drawingAccuracy: tempDrawingAccuracy.value
});
localStorage.setItem('zen_locale', locale.value);
showSettings.value = false;
store.fetchQueue();
}
function handleLogout() {
showLogoutDialog.value = true;
drawer.value = false;
}
function confirmLogout() {
store.logout();
inputKey.value = '';
showLogoutDialog.value = false;
}
</script>
<style lang="scss" src="@/styles/pages/_app.scss"></style>