21 Commits
Author SHA1 Message Date
walamana 87812d02d3 feat: show notification to install miniplan as app
Deploy Miniplan / build (push) Successful in 59s
2026-07-21 16:38:05 +02:00
walamana 1104280c0c fix: upgrade node in Dockerfile to v24
Deploy Miniplan / build (push) Successful in 2m7s
2026-07-21 14:02:57 +02:00
walamana 2e829b281b fix: upgrade all dependencies and resolve conflicts
Deploy Miniplan / build (push) Failing after 1m16s
2026-07-21 14:00:22 +02:00
walamana 7adeb41402 fix: add missing dependency
Deploy Miniplan / build (push) Failing after 1m2s
2026-07-21 13:49:29 +02:00
walamana c7f3fb0e6e fix: update package-lock.json
Deploy Miniplan / build (push) Failing after 52s
2026-07-21 13:47:57 +02:00
walamana ccabdc580c fix: build
Deploy Miniplan / build (push) Failing after 54s
2026-07-21 13:45:46 +02:00
walamana 0350b80cda feat: make miniplan a PWA
Deploy Miniplan / build (push) Failing after 1m20s
2026-07-21 13:41:18 +02:00
walamana 2dac1598ff fix: use common sql syntax for dropping null constraint on birthday field
Deploy Miniplan / build (push) Successful in 2m58s
2026-07-14 22:59:35 +02:00
walamana 32109debfd Merge pull request 'develop' (#9) from develop into main
Deploy Miniplan / build (push) Successful in 2m59s
Reviewed-on: #9
2026-07-14 22:47:19 +02:00
walamana e1928f7af9 feat: refresh token when page is reloaded 2026-07-14 22:45:55 +02:00
walamana cd7bc8427f fix: add note to add proper versioning for database 2026-07-14 22:41:36 +02:00
walamana 1b248ad121 fix: remove unneccesary padding in ministrant-row 2026-07-14 22:39:32 +02:00
walamana fa44d0d738 feat: one-time tutorial overlay on first list view visit 2026-07-14 22:36:53 +02:00
walamana 88f8f4241b feat: only show tabs bar for admin users 2026-07-14 22:35:25 +02:00
walamana cbe703d3d2 finishing touches 2026-07-14 22:32:49 +02:00
walamana 1a34535467 style: swap name and date in gottesdienst card header 2026-07-14 22:31:49 +02:00
walamana 5e854e4341 feat: show x-mark count per ministrant in ministrant list 2026-07-14 22:29:06 +02:00
walamana a6044f3a83 fix: adjust layout of plan selector for mobile 2026-07-14 22:24:46 +02:00
walamana 3b6a87752b fix: center title with margin auto instead of fragile spacer divs 2026-07-14 22:22:55 +02:00
walamana 2396a927ab refactor: improve mobile layout for plan changer
- Stack title and controls vertically on mobile
- Separate admin actions into own row below
- No more absolute positioning or fragile layout
- Show date labels on prev/next buttons only on desktop
- Cleaner responsive breakpoint at 800px
2026-07-14 22:20:27 +02:00
walamana 3beb92c300 feat: add delete button to gottesdienst edit dialog 2026-07-14 22:16:58 +02:00
32 changed files with 6777 additions and 907 deletions
+1
View File
@@ -2,3 +2,4 @@
.idea .idea
node_modules node_modules
package-lock.json package-lock.json
/.idea/
+1 -1
View File
@@ -4,7 +4,7 @@ COPY --chown=gradle:gradle /private/minis-backend /home/gradle/src
WORKDIR /home/gradle/src WORKDIR /home/gradle/src
RUN gradle buildFatJar --no-daemon RUN gradle buildFatJar --no-daemon
FROM node:18 as build_frontend FROM node:24 as build_frontend
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
WORKDIR /home/node/app WORKDIR /home/node/app
+1 -4
View File
@@ -8,8 +8,5 @@
}, },
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {}
"dropzone-vue3": "^1.0.2",
"rxjs": "^7.8.1"
}
} }
@@ -29,7 +29,8 @@ fun Application.configureDatabases() {
SchemaUtils.create(GottesdienstGroups) SchemaUtils.create(GottesdienstGroups)
SchemaUtils.create(Gottesdienste) SchemaUtils.create(Gottesdienste)
SchemaUtils.create(Marks) SchemaUtils.create(Marks)
exec("ALTER TABLE MINISTRANTEN ALTER COLUMN birthday BIGINT NULL;") // TODO Add proper versioning and migration for database
exec("ALTER TABLE MINISTRANTEN ALTER COLUMN birthday DROP NOT NULL;")
} }
} }
@@ -59,7 +59,7 @@ fun Payload.mid() = getClaim("id").asInt()
object Security { object Security {
fun DEFAULT_EXPIRY() = Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24 * 14); fun DEFAULT_EXPIRY() = Date(System.currentTimeMillis() + 1000L * 60 * 60 * 24 * 40);
suspend fun authenticateUser(application: Application, username: String, password: String): Ministrant? { suspend fun authenticateUser(application: Application, username: String, password: String): Ministrant? {
println("Username $username password $password") println("Username $username password $password")
@@ -3,6 +3,7 @@ package de.walamana.views
import at.favre.lib.crypto.bcrypt.BCrypt import at.favre.lib.crypto.bcrypt.BCrypt
import com.auth0.jwt.JWT import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm import com.auth0.jwt.algorithms.Algorithm
import de.walamana.models.Ministrant
import de.walamana.models.MinistrantenDao import de.walamana.models.MinistrantenDao
import de.walamana.plugins.Security import de.walamana.plugins.Security
import de.walamana.plugins.getJWTEnvironment import de.walamana.plugins.getJWTEnvironment
@@ -98,6 +99,28 @@ fun Route.configureAuthenticationRoutes() {
call.respond(hashMapOf("password" to newPassword)) call.respond(hashMapOf("password" to newPassword))
} }
post("refresh") {
val principal = call.principal<JWTPrincipal>()!!
val jwtEnv = application.getJWTEnvironment()
val username = principal.payload.username()
val ministrant = if (username == "admin") {
val allMinis = MinistrantenDao.allMinistranten().map { it.username }
Ministrant(0, "admin", "", "admin", "admin", null, allMinis)
} else {
MinistrantenDao.getMinistrant(username) ?: return@post
}
val newToken = Security.createToken(jwtEnv, ministrant)
val expiry = Security.DEFAULT_EXPIRY().toGMTString()
call.response.header(
"Set-Cookie",
"token=$newToken; HttpOnly; Expires=$expiry"
)
call.respond(AuthenticationResult(true, newToken, ministrant.privileges))
}
} }
} }
} }
+1
View File
@@ -26,3 +26,4 @@ coverage
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/dev-dist/
+6 -1
View File
@@ -3,9 +3,14 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/minis_logo.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#ffffff">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Miniplan">
<title>Miniplan App</title> <title>Miniplan App</title>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet">
</head> </head>
<body> <body>
+6256 -813
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -10,6 +10,7 @@
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false" "type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false"
}, },
"dependencies": { "dependencies": {
"crypto-js": "^4.2.0",
"dropzone-vue3": "^1.0.2", "dropzone-vue3": "^1.0.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"underscore": "^1.13.7", "underscore": "^1.13.7",
@@ -19,12 +20,15 @@
"devDependencies": { "devDependencies": {
"@tsconfig/node18": "^18.2.0", "@tsconfig/node18": "^18.2.0",
"@types/node": "^18.17.0", "@types/node": "^18.17.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^4.2.3", "@vitejs/plugin-vue": "^4.2.3",
"@vue/tsconfig": "^0.4.0", "@vue/tsconfig": "^0.4.0",
"less": "^4.2.0", "less": "^4.2.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"typescript": "~5.1.6", "typescript": "~5.1.6",
"vite": "^4.4.6", "vite": "^5.4.0",
"vue-tsc": "^1.8.6" "vite-plugin-pwa": "^1.3.0",
"vue-tsc": "^3.3.7",
"workbox-window": "^7.4.1"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 931 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 891 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+40 -1
View File
@@ -6,6 +6,8 @@ import {onMounted, ref} from "vue";
import {Auth} from "@/services/auth"; import {Auth} from "@/services/auth";
import DialogHost from "@/components/dialog/DialogHost.vue"; import DialogHost from "@/components/dialog/DialogHost.vue";
import {LoginService} from "@/services/LoginService"; import {LoginService} from "@/services/LoginService";
import {Dialogs} from "@/services/DialogService";
import InstallPWADialog from "@/components/dialog/InstallPWADialog.vue";
let showPopup = ref(false) let showPopup = ref(false)
let loggedIn = ref(false) let loggedIn = ref(false)
@@ -22,14 +24,51 @@ function print(){
window.print() window.print()
} }
onMounted(() => { onMounted(async () => {
Auth.checkForToken() Auth.checkForToken()
if (Auth.getToken()) {
await Auth.refreshToken()
}
}) })
LoginService.subject.subscribe(() => { LoginService.subject.subscribe(() => {
showPopup.value = true showPopup.value = true
}) })
const installEvent = ref<Event>()
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
console.log("beforeinstallprompt")
installEvent.value = e
scheduleInstallDialog()
})
function scheduleInstallDialog() {
if (isPwa()) return
if (localStorage.getItem('installPromptDismissed')) return
if (!installEvent.value) return
Dialogs.createDialog(InstallPWADialog, {
onPositive(dontShowAgain: boolean) {
if (dontShowAgain) localStorage.setItem('installPromptDismissed', 'true')
;(installEvent.value as any).prompt()
},
onNegative(dontShowAgain: boolean) {
if (dontShowAgain) localStorage.setItem('installPromptDismissed', 'true')
},
onDismiss() {}
}, {})
}
function isPwa() {
return window.matchMedia('(display-mode: standalone)').matches
}
window.addEventListener('appinstalled', () => {
localStorage.setItem('installPromptDismissed', 'true')
})
</script> </script>
<template> <template>
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 58 KiB

+8 -3
View File
@@ -159,13 +159,13 @@ function getOwnMarkHint(): string {
> >
<i v-if="getOwnMarkIcon()" class="icon">{{ getOwnMarkIcon() }}</i> <i v-if="getOwnMarkIcon()" class="icon">{{ getOwnMarkIcon() }}</i>
</button> </button>
<span class="name">{{ gottesdienst.name !== "" ? gottesdienst.name : "Gottesdienst" }}</span> <span class="day">{{ formatWeekday(gottesdienst.date) }} {{ formatDay(gottesdienst.date) }}</span>
<button v-if="isAdmin" class="edit-godi-btn" @click="emit('editGottesdienst', gottesdienst.id)" title="Gottesdienst bearbeiten"> <button v-if="isAdmin" class="edit-godi-btn" @click="emit('editGottesdienst', gottesdienst.id)" title="Gottesdienst bearbeiten">
<i class="icon">edit</i> <i class="icon">edit</i>
</button> </button>
</div> </div>
<div class="info"> <div class="info">
<span class="date">am {{ formatWeekday(gottesdienst.date) }} {{ formatDay(gottesdienst.date) }}</span> <span class="name">{{ gottesdienst.name !== "" ? gottesdienst.name : "Gottesdienst" }}</span>
<span class="time">um {{ formatTime(gottesdienst.date) }} Uhr</span> <span class="time">um {{ formatTime(gottesdienst.date) }} Uhr</span>
<span class="attendance">Anwesenheit: {{ formatTime(gottesdienst.attendance) }}</span> <span class="attendance">Anwesenheit: {{ formatTime(gottesdienst.attendance) }}</span>
</div> </div>
@@ -307,6 +307,11 @@ function getOwnMarkHint(): string {
} }
} }
.day {
font-weight: 700;
flex: 1;
}
.info { .info {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -374,7 +379,7 @@ function getOwnMarkHint(): string {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 0 8px 26px; padding: 8px 0 8px 0;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid #f0f0f0;
&:last-child { &:last-child {
+122 -57
View File
@@ -133,31 +133,33 @@ function dateToValueString(time) {
<template> <template>
<div class="bar"> <div class="bar">
<div class="controls"> <div class="top-row">
<button class="flat left" v-if="prev != null && prev <= groups.length - 1" @click="back"> <button class="flat arrow" v-if="prev != null && prev <= groups.length - 1" @click="back">
<i>arrow_left_alt</i> <i>arrow_left_alt</i>
<span class="hide-mobile">{{ formatDateShort(get(prev).from) }} - {{ formatDateShort(get(prev).to) }}</span> <span class="date-label">{{ formatDateShort(get(prev).from) }} - {{ formatDateShort(get(prev).to) }}</span>
</button> </button>
<div class="width: 100%"/> <div v-else></div>
<button class="flat right" v-if="next != null && next > 0" @click="forward">
<span class="hide-mobile">{{ formatDateShort(get(next).from) }} - {{ formatDateShort(get(next).to) }}</span> <div class="title">
<template v-if="groups.length > 0">
<span class="prefix">Miniplan vom</span>
{{ formatDate(get(cur).from) }} {{ formatDate(get(cur).to) }}
</template>
<span v-else>Keine Gottesdienstgruppen vorhanden</span>
</div>
<button class="flat arrow" v-if="next != null && next > 0" @click="forward">
<span class="date-label">{{ formatDateShort(get(next).from) }} - {{ formatDateShort(get(next).to) }}</span>
<i>arrow_right_alt</i> <i>arrow_right_alt</i>
</button> </button>
<button class="flat right" v-if="(next == null || next <= 0) && admin" style="margin-right: 20px" <div v-else></div>
@click="$emit('new')"> </div>
<i>add</i> Neuer plan
</button> <div class="admin-row" v-if="admin && groups.length > 0">
<button class="flat" @click="$emit('new')"><i>add</i> Neuer Plan</button>
<button class="flat icon" @click="$emit('delete', get(cur).id)"><i>delete</i></button>
<button class="flat icon" @click="$emit('edit', get(cur).id)"><i>edit</i></button>
</div> </div>
<template v-if="groups.length > 0">
<span style="z-index: 1"><span class="hide-mobile">Miniplan vom </span> {{ formatDate(get(cur).from) }} bis {{ formatDate(get(cur).to) }}</span>
<span v-if="admin" style="display: flex; align-items: center; z-index: 1">
<button class="icon flat" @click="$emit('delete', get(cur).id)"><i>delete</i></button>
<button class="icon flat" @click="$emit('edit', get(cur).id)"><i>edit</i></button>
</span>
</template>
<span v-else>
Keine Gottesdienstgruppen vorhanden
</span>
</div> </div>
</template> </template>
@@ -165,56 +167,119 @@ function dateToValueString(time) {
.bar { .bar {
display: flex; display: flex;
width: calc(100% - 30px); flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
border-bottom: 1px solid #d7d5d5; border-bottom: 1px solid #d7d5d5;
z-index: 10; z-index: 10;
padding: 15px; padding: 8px 12px;
position: relative; gap: 6px;
.controls { .top-row {
display: grid;
align-items: center;
width: 100%;
gap: 6px;
grid-template-columns: 60px 1fr 60px;
}
.arrow {
display: flex; display: flex;
justify-content: space-between; align-items: center;
position: absolute; gap: 4px;
top: 0;
left: 0;
width: calc(100% - 10px);
height: calc(100% - 10px);
padding: 5px;
}
.left, .right{
flex-shrink: 0; flex-shrink: 0;
} padding: 6px 8px 6px 6px;
.left {
margin-left: 10px;
}
.right {
i { i {
margin-left: 10px; font-size: 22px;
}
} }
span { .date-label {
font-weight: 700;
}
button {
margin-right: 10px;
}
.input {
margin: 0 10px
}
@media(max-width: 800px) {
.hide-mobile {
display: none; display: none;
} }
}
.title {
flex: 1;
text-align: center;
font-weight: 700;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
margin: 0 auto;
.prefix {
display: none;
}
}
.admin-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding-top: 4px;
border-top: 1px solid #eee;
button {
font-size: 13px;
padding: 4px 10px 4px 6px;
i {
font-size: 18px;
margin-right: 4px;
}
&.icon {
padding: 4px 8px;
i {
margin-right: 0;
}
}
}
}
@media(min-width: 800px) {
padding: 10px 16px;
gap: 0;
.top-row {
flex: 1;
gap: 10px;
grid-template-columns: 180px 1fr 180px;
}
.arrow {
padding: 6px 14px 6px 10px;
.date-label {
display: inline;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
}
.title {
font-size: 16px;
white-space: normal;
.prefix {
display: inline;
font-weight: 400;
color: #777;
}
}
.admin-row {
width: auto;
border-top: none;
padding-top: 0;
margin-left: 12px;
}
} }
} }
+108 -4
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import {ref} from "vue" import {ref, watch} from "vue"
import type {Gottesdienst, Mark, SimplifiedMinistrant} from "@/models/models" import type {Gottesdienst, Mark, SimplifiedMinistrant} from "@/models/models"
import GottesdienstCard from "@/components/GottesdienstCard.vue" import GottesdienstCard from "@/components/GottesdienstCard.vue"
defineProps<{ const STORAGE_KEY = "miniplan_list_tutorial_seen"
const props = defineProps<{
gottesdienste: Gottesdienst[] gottesdienste: Gottesdienst[]
ministranten: SimplifiedMinistrant[] ministranten: SimplifiedMinistrant[]
marks: Mark[] marks: Mark[]
@@ -22,11 +24,21 @@ const emit = defineEmits<{
}>() }>()
const activeTab = ref<"gottesdienste" | "ministranten">("gottesdienste") const activeTab = ref<"gottesdienste" | "ministranten">("gottesdienste")
const showTutorial = ref(!localStorage.getItem(STORAGE_KEY))
function dismissTutorial() {
showTutorial.value = false
localStorage.setItem(STORAGE_KEY, "true")
}
function countXMarks(miniId: number): number {
return props.marks.filter(m => m.mid === miniId && m.value === 1).length
}
</script> </script>
<template> <template>
<div class="list-view"> <div class="list-view">
<div class="tab-bar"> <div class="tab-bar" v-if="admin">
<button <button
class="tab" class="tab"
:class="{ active: activeTab === 'gottesdienste' }" :class="{ active: activeTab === 'gottesdienste' }"
@@ -59,11 +71,12 @@ const activeTab = ref<"gottesdienste" | "ministranten">("gottesdienste")
</button> </button>
</template> </template>
<template v-else> <template v-if="admin && activeTab === 'ministranten'">
<div class="mini-list"> <div class="mini-list">
<div v-for="mini in ministranten" :key="mini.id" class="mini-row"> <div v-for="mini in ministranten" :key="mini.id" class="mini-row">
<span class="mini-name">{{ mini.firstname }} {{ mini.lastname }}</span> <span class="mini-name">{{ mini.firstname }} {{ mini.lastname }}</span>
<span class="mini-username">@{{ mini.username }}</span> <span class="mini-username">@{{ mini.username }}</span>
<span class="x-count">{{ countXMarks(mini.id) }}</span>
<button v-if="admin" class="edit-btn" @click="emit('editMinistrant', mini.id)"> <button v-if="admin" class="edit-btn" @click="emit('editMinistrant', mini.id)">
<i class="icon">edit</i> <i class="icon">edit</i>
</button> </button>
@@ -76,12 +89,31 @@ const activeTab = ref<"gottesdienste" | "ministranten">("gottesdienste")
</button> </button>
</div> </div>
</template> </template>
<div class="tutorial-overlay" v-if="showTutorial" @click.self="dismissTutorial">
<div class="tutorial-card">
<h3><i class="icon">info</i> Listenansicht</h3>
<ul>
<li><strong>Gottesdienste</strong> sind als Karten dargestellt jede Karte zeigt Datum, Uhrzeit und Rückmeldungen.</li>
<li>Klicke auf dein <strong>Kästchen</strong> in der Karte, um deine Teilnahme anzugeben: <span class="chip cross">✓</span> dabei, <span class="chip minus">✗</span> nicht dabei.</li>
<li>Die <strong>Rückmeldungen</strong> der anderen siehst du aufgeklappt unter „X Rückmeldungen".</li>
<li v-if="admin">Als Admin siehst du zusätzlich den <strong>Ministranten-Tab</strong> und kannst Markierungen direkt anklicken.</li>
</ul>
<button @click="dismissTutorial">Verstanden!</button>
</div>
</div>
</div> </div>
</template> </template>
<style scoped lang="less"> <style scoped lang="less">
.list-view { .list-view {
padding: 12px; padding: 12px;
max-width: 800px;
margin: 0 auto;
@media(min-width: 800px) {
}
} }
.tab-bar { .tab-bar {
@@ -149,6 +181,17 @@ const activeTab = ref<"gottesdienste" | "ministranten">("gottesdienste")
.mini-username { .mini-username {
font-size: 13px; font-size: 13px;
color: #999; color: #999;
flex: 1;
}
.x-count {
font-size: 13px;
font-weight: 700;
color: #045b04;
background: #d1fcd1;
padding: 1px 8px;
border-radius: 10px;
margin: 0 8px;
} }
} }
@@ -241,6 +284,67 @@ const activeTab = ref<"gottesdienste" | "ministranten">("gottesdienste")
} }
} }
.tutorial-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
.tutorial-card {
background: #fff;
border-radius: 12px;
padding: 28px 32px;
max-width: 440px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 16px;
font-size: 20px;
i {
font-size: 26px;
color: #555;
}
}
ul {
margin: 0 0 20px;
padding: 0 0 0 18px;
line-height: 1.7;
font-size: 15px;
color: #333;
li {
margin-bottom: 6px;
}
}
.chip {
display: inline-block;
padding: 0 6px;
border-radius: 4px;
font-size: 13px;
font-weight: 700;
&.cross { background: #d1fcd1; color: #045b04; }
&.minus { background: #fdd5d5; color: #690b0b; }
}
button {
display: flex;
margin-left: auto;
}
}
}
.import-btn { .import-btn {
display: flex; display: flex;
width: 100%; width: 100%;
@@ -9,7 +9,8 @@ import {onKeyPress} from "@/composables/enter";
interface CreateGottesdienstDialogProps extends DialogControls { interface CreateGottesdienstDialogProps extends DialogControls {
onCreate: (arg0: Gottesdienst) => (Promise<any> | undefined), onCreate: (arg0: Gottesdienst) => (Promise<any> | undefined),
gottesdienst?: Gottesdienst, gottesdienst?: Gottesdienst,
planId: number planId: number,
onDelete?: () => void
} }
onKeyPress("Enter", create) onKeyPress("Enter", create)
@@ -70,10 +71,13 @@ async function create(){
<Input class="input" v-model:value="date" date-format="string" type="date" label="Datum"/> <Input class="input" v-model:value="date" date-format="string" type="date" label="Datum"/>
<Input class="input" v-model:value="time" date-format="string" type="time" label="Um"/> <Input class="input" v-model:value="time" date-format="string" type="time" label="Um"/>
<Input class="input" v-model:value="godi.attendance" type="time" label="Anwesenheit"/> <Input class="input" v-model:value="godi.attendance" type="time" label="Anwesenheit"/>
<div class="buttons" style="display: flex; justify-content: end; margin-top: 20px;"> <div class="buttons" style="display: flex; justify-content: space-between; margin-top: 20px;">
<button v-if="godi.id != -1 && onDelete" class="red" @click="onDelete(); onDismiss()"><i class="icon">delete</i> Löschen</button>
<div style="display: flex; gap: 8px; margin-left: auto;">
<button @click="onDismiss">Abbrechen</button> <button @click="onDismiss">Abbrechen</button>
<button @click="create">{{ godi?.id == -1 ? "Erstellen" : "Speichern"}}</button> <button @click="create">{{ godi?.id == -1 ? "Erstellen" : "Speichern"}}</button>
</div> </div>
</div>
</Dialog> </Dialog>
</template> </template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import {ref} from "vue";
import Dialog from "@/components/dialog/Dialog.vue";
import type {DialogControls} from "@/components/dialog/dialog";
interface InstallPWADialogProps extends DialogControls {
}
defineProps<InstallPWADialogProps>()
const dontShowAgain = ref<boolean>(false)
</script>
<template>
<Dialog class="dialog">
<h3 style="margin-bottom: 10px; display: flex; gap: 1rem; align-items: center">
Zum Startbildschirm hinzufügen
</h3>
<p style="line-height: 1.7rem">
Dein Miniplan kann jetzt auf Ihrem Gerät installiert werden, um schnell und einfach darauf zuzugreifen.
</p>
<label class="checkbox">
<input type="checkbox" v-model="dontShowAgain" />
Nicht mehr anzeigen
</label>
<div class="buttons">
<button @click="onNegative(dontShowAgain)" class="dismiss">Später</button>
<button @click="onPositive(dontShowAgain)" ><i>browser_updated</i>Jetzt hinzufügen</button>
</div>
</Dialog>
</template>
<style scoped lang="less">
.dialog {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 500px
}
.checkbox {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
font-size: 14px;
cursor: pointer;
}
.buttons {
display: flex;
justify-content: end;
margin-top: 20px;
.dismiss {
background: transparent;
}
}
</style>
+13
View File
@@ -4,6 +4,19 @@ import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { registerSW } from 'virtual:pwa-register'
registerSW({
onNeedRefresh() {
if (confirm('New content available. Reload?')) {
window.location.reload()
}
},
onOfflineReady() {
console.log('App ready to work offline')
}
})
const app = createApp(App) const app = createApp(App)
app.use(router) app.use(router)
+24
View File
@@ -71,6 +71,30 @@ export namespace Auth {
} }
} }
export async function refreshToken(): Promise<boolean> {
const token = getToken()
if (!token) return false
return api("/auth/refresh", "POST").then(res => {
if (res.status == 200) {
return res.json().then(data => {
if (data.success) {
setToken(data.token ?? "")
const payload = parseJwt(data.token)
loggedIn = true
user = payload.username
privileges = payload.privileges
privileges.push(user)
loggedInSubject.next(true)
return true
}
return false
})
}
return false
}).catch(() => false)
}
function parseJwt (token) { function parseJwt (token) {
var base64Url = token.split('.')[1]; var base64Url = token.split('.')[1];
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
+2 -1
View File
@@ -128,7 +128,8 @@ async function createGottesdienst(gottesdienstId?: number){
plan.gottesdienste.splice(index, 1) plan.gottesdienste.splice(index, 1)
plan.gottesdienste.push(godi) plan.gottesdienste.push(godi)
} }
} },
onDelete: gottesdienst ? () => deleteGottedienst(gottesdienst.id) : undefined
}) })
} }
+70 -5
View File
@@ -1,16 +1,81 @@
import { fileURLToPath, URL } from 'node:url' import {fileURLToPath, URL} from 'node:url'
import { defineConfig } from 'vite' import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import {VitePWA} from 'vite-plugin-pwa'
// https://vitejs.dev/config/ export default defineConfig(({mode}) => (
export default defineConfig({ {
plugins: [ plugins: [
vue(), vue(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'minis_logo.png'],
manifest: {
name: 'Miniplan App',
short_name: 'Miniplan',
description: 'Lade deinen Miniplan direkt auf dein Handy. So hast du den Miniplan jederzeit und schnell erreichbar.',
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
scope: '/',
start_url: '/',
screenshots: [
{
src: 'screenshot1.png',
sizes: '505x710',
type: 'image/png',
form_factor: "narrow",
label: "Miniplan Liste"
},
{
src: 'screenshot2.png',
sizes: '505x711',
type: 'image/png',
form_factor: "narrow",
label: "Miniplan Tabelle"
},
{
src: "screenshot3.png",
sizes: '1003x709',
type: 'image/png',
form_factor: "wide",
label: "Miniplan Tabelle"
}
],
icons: [
{
src: "pwa-64x64.png",
sizes: "64x64",
type: "image/png"
},
{
src: "pwa-192x192.png",
sizes: "192x192",
type: "image/png"
},
{
src: "pwa-512x512.png",
sizes: "512x512",
type: "image/png"
},
{
src: "maskable-icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable"
}
]
},
devOptions: {
enabled: mode === "development"
}
})
], ],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
} }
} }
}) })
)