12 Commits
Author SHA1 Message Date
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
10 changed files with 301 additions and 71 deletions
@@ -29,6 +29,7 @@ fun Application.configureDatabases() {
SchemaUtils.create(GottesdienstGroups)
SchemaUtils.create(Gottesdienste)
SchemaUtils.create(Marks)
// TODO Add proper versioning and migration for database
exec("ALTER TABLE MINISTRANTEN ALTER COLUMN birthday BIGINT NULL;")
}
}
@@ -59,7 +59,7 @@ fun Payload.mid() = getClaim("id").asInt()
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? {
println("Username $username password $password")
@@ -3,6 +3,7 @@ package de.walamana.views
import at.favre.lib.crypto.bcrypt.BCrypt
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import de.walamana.models.Ministrant
import de.walamana.models.MinistrantenDao
import de.walamana.plugins.Security
import de.walamana.plugins.getJWTEnvironment
@@ -98,6 +99,28 @@ fun Route.configureAuthenticationRoutes() {
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))
}
}
}
}
+4 -1
View File
@@ -22,8 +22,11 @@ function print(){
window.print()
}
onMounted(() => {
onMounted(async () => {
Auth.checkForToken()
if (Auth.getToken()) {
await Auth.refreshToken()
}
})
LoginService.subject.subscribe(() => {
+8 -3
View File
@@ -159,13 +159,13 @@ function getOwnMarkHint(): string {
>
<i v-if="getOwnMarkIcon()" class="icon">{{ getOwnMarkIcon() }}</i>
</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">
<i class="icon">edit</i>
</button>
</div>
<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="attendance">Anwesenheit: {{ formatTime(gottesdienst.attendance) }}</span>
</div>
@@ -307,6 +307,11 @@ function getOwnMarkHint(): string {
}
}
.day {
font-weight: 700;
flex: 1;
}
.info {
display: flex;
flex-wrap: wrap;
@@ -374,7 +379,7 @@ function getOwnMarkHint(): string {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0 8px 26px;
padding: 8px 0 8px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
+122 -57
View File
@@ -133,31 +133,33 @@ function dateToValueString(time) {
<template>
<div class="bar">
<div class="controls">
<button class="flat left" v-if="prev != null && prev <= groups.length - 1" @click="back">
<div class="top-row">
<button class="flat arrow" v-if="prev != null && prev <= groups.length - 1" @click="back">
<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>
<div class="width: 100%"/>
<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 v-else></div>
<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>
</button>
<button class="flat right" v-if="(next == null || next <= 0) && admin" style="margin-right: 20px"
@click="$emit('new')">
<i>add</i> Neuer plan
</button>
<div v-else></div>
</div>
<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>
<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>
</template>
@@ -165,56 +167,119 @@ function dateToValueString(time) {
.bar {
display: flex;
width: calc(100% - 30px);
flex-direction: column;
align-items: center;
justify-content: center;
border-bottom: 1px solid #d7d5d5;
z-index: 10;
padding: 15px;
position: relative;
padding: 8px 12px;
gap: 6px;
.controls {
.top-row {
display: grid;
align-items: center;
width: 100%;
gap: 6px;
grid-template-columns: 60px 1fr 60px;
}
.arrow {
display: flex;
justify-content: space-between;
position: absolute;
top: 0;
left: 0;
width: calc(100% - 10px);
height: calc(100% - 10px);
padding: 5px;
}
.left, .right{
align-items: center;
gap: 4px;
flex-shrink: 0;
}
padding: 6px 8px 6px 6px;
.left {
margin-left: 10px;
}
.right {
i {
margin-left: 10px;
font-size: 22px;
}
}
span {
font-weight: 700;
}
button {
margin-right: 10px;
}
.input {
margin: 0 10px
}
@media(max-width: 800px) {
.hide-mobile {
.date-label {
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">
import {ref} from "vue"
import {ref, watch} from "vue"
import type {Gottesdienst, Mark, SimplifiedMinistrant} from "@/models/models"
import GottesdienstCard from "@/components/GottesdienstCard.vue"
defineProps<{
const STORAGE_KEY = "miniplan_list_tutorial_seen"
const props = defineProps<{
gottesdienste: Gottesdienst[]
ministranten: SimplifiedMinistrant[]
marks: Mark[]
@@ -22,11 +24,21 @@ const emit = defineEmits<{
}>()
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>
<template>
<div class="list-view">
<div class="tab-bar">
<div class="tab-bar" v-if="admin">
<button
class="tab"
:class="{ active: activeTab === 'gottesdienste' }"
@@ -59,11 +71,12 @@ const activeTab = ref<"gottesdienste" | "ministranten">("gottesdienste")
</button>
</template>
<template v-else>
<template v-if="admin && activeTab === 'ministranten'">
<div class="mini-list">
<div v-for="mini in ministranten" :key="mini.id" class="mini-row">
<span class="mini-name">{{ mini.firstname }} {{ mini.lastname }}</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)">
<i class="icon">edit</i>
</button>
@@ -76,12 +89,31 @@ const activeTab = ref<"gottesdienste" | "ministranten">("gottesdienste")
</button>
</div>
</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>
</template>
<style scoped lang="less">
.list-view {
padding: 12px;
max-width: 800px;
margin: 0 auto;
@media(min-width: 800px) {
}
}
.tab-bar {
@@ -149,6 +181,17 @@ const activeTab = ref<"gottesdienste" | "ministranten">("gottesdienste")
.mini-username {
font-size: 13px;
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 {
display: flex;
width: 100%;
@@ -9,7 +9,8 @@ import {onKeyPress} from "@/composables/enter";
interface CreateGottesdienstDialogProps extends DialogControls {
onCreate: (arg0: Gottesdienst) => (Promise<any> | undefined),
gottesdienst?: Gottesdienst,
planId: number
planId: number,
onDelete?: () => void
}
onKeyPress("Enter", create)
@@ -70,9 +71,12 @@ async function create(){
<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="godi.attendance" type="time" label="Anwesenheit"/>
<div class="buttons" style="display: flex; justify-content: end; margin-top: 20px;">
<button @click="onDismiss">Abbrechen</button>
<button @click="create">{{ godi?.id == -1 ? "Erstellen" : "Speichern"}}</button>
<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="create">{{ godi?.id == -1 ? "Erstellen" : "Speichern"}}</button>
</div>
</div>
</Dialog>
+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) {
var base64Url = token.split('.')[1];
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.push(godi)
}
}
},
onDelete: gottesdienst ? () => deleteGottedienst(gottesdienst.id) : undefined
})
}