Implement timetable editing

This commit is contained in:
2023-06-18 23:43:08 +02:00
parent 46b235ba91
commit 4d4a92bff3
10 changed files with 532 additions and 49 deletions

View File

@ -41,7 +41,7 @@ const isDataView = computed(() => route.meta.dataView || false);
:error="loadingFailed" :error="loadingFailed"
/> />
<div class="center"> <div class="center">
<main> <div class="container">
<DateSelector <DateSelector
:selectedDate="selectedDate" :selectedDate="selectedDate"
:selectedDay="selectedDay" :selectedDay="selectedDay"
@ -49,10 +49,12 @@ const isDataView = computed(() => route.meta.dataView || false);
@changeDate="(date) => (changeDate = date)" @changeDate="(date) => (changeDate = date)"
v-show="isDataView" v-show="isDataView"
/> />
<div class="wrapper"> <main>
<RouterView /> <div class="wrapper">
</div> <RouterView />
</main> </div>
</main>
</div>
<BottomNavbar v-show="!$route.meta.hideNav" /> <BottomNavbar v-show="!$route.meta.hideNav" />
</div> </div>
</div> </div>
@ -90,15 +92,20 @@ body {
} }
main { main {
width: 100%; height: 100%;
max-width: 900px;
overflow: hidden; overflow: hidden;
display: grid; display: grid;
grid-template-rows: auto 1fr; grid-template-rows: 1fr;
} }
</style> </style>
<style scoped> <style scoped>
.container {
width: inherit;
max-width: 900px;
width: 100%;
}
.wrapper { .wrapper {
overflow: hidden; overflow: hidden;
} }

View File

@ -1,8 +1,25 @@
<script setup> <script setup>
import { getSubstitutionColor } from "@/util"; import { getSubstitutionColor } from "@/util";
import { times } from "@/store"; import { times } from "@/store";
import { ref } from "vue";
import { TrashIcon } from "lucide-vue-next";
defineProps(["lesson", "index"]); const props = defineProps(["lesson", "index", "edit", "buttons"]);
const emit = defineEmits(["change", "delete"]);
const subjectElement = ref();
const teacherElement = ref();
const roomElement = ref();
const lessonLength = ref(props.lesson.length || 1);
function update() {
emit("change", {
subject: subjectElement.value.innerText,
teacher: teacherElement.value.innerText,
room: roomElement.value.innerText,
length: lessonLength.value,
});
}
function isChanged(lesson, key) { function isChanged(lesson, key) {
const substitution = lesson.substitution; const substitution = lesson.substitution;
@ -43,13 +60,21 @@ function getTime(index) {
<div class="infos"> <div class="infos">
<!-- Subject changed --> <!-- Subject changed -->
<span class="subject" v-if="isChanged(lesson, 'subject')"> <span class="subject" v-if="isChanged(lesson, 'subject')">
<s>{{ lesson.subject }}</s> {{ lesson.substitution.change.subject }} <s>{{ lesson.subject }}</s>
{{ lesson.substitution.change.subject }}
</span> </span>
<!-- Cancellation --> <!-- Cancellation -->
<span class="subject" v-else-if="isCancelled(lesson.substitution)"> <span class="subject" v-else-if="isCancelled(lesson.substitution)">
<s>{{ lesson.subject }}</s> <s>{{ lesson.subject }}</s>
</span> </span>
<span class="subject" v-else> <span
class="subject"
v-else
:contenteditable="edit"
data-ph="Subject"
ref="subjectElement"
@input="update"
>
{{ lesson.subject }} {{ lesson.subject }}
</span> </span>
<div class="info"> <div class="info">
@ -58,22 +83,43 @@ function getTime(index) {
<s>{{ lesson.teacher }}</s> <s>{{ lesson.teacher }}</s>
{{ lesson.substitution.change.teacher }}</span {{ lesson.substitution.change.teacher }}</span
> >
<span class="info" v-else> {{ lesson.teacher }}</span> <span
class="info"
v-else
:contenteditable="edit"
data-ph="Teacher"
ref="teacherElement"
@input="update"
>
{{ lesson.teacher }}</span
>
<!-- Room changed --> <!-- Room changed -->
<span class="info" v-if="isChanged(lesson, 'room')" <span class="info" v-if="isChanged(lesson, 'room')"
>, <s>{{ lesson.room }}</s> {{ lesson.substitution.change.room }} >, <s>{{ lesson.room }}</s> {{ lesson.substitution.change.room }}
</span> </span>
<span class="info" v-else-if="lesson.room">, {{ lesson.room }}</span> <span class="info" v-else-if="lesson.room || edit"
>,
<span
:contenteditable="edit"
data-ph="Room"
ref="roomElement"
@input="update"
>{{ lesson.room }}</span
></span
>
<!-- Show notes if available --> <!-- Show notes if available -->
<span class="info" v-if="getNotes(lesson.substitution)" <span class="info" v-if="getNotes(lesson.substitution)"
>, {{ $t("timetable.notes") }} {{ getNotes(lesson.substitution) }} >, {{ $t("timetable.notes") }} {{ getNotes(lesson.substitution) }}
</span> </span>
</div> </div>
</div> </div>
<div class="times" v-if="getTime(index).start"> <div class="times" v-if="getTime(index).start && !edit">
<span>{{ getTime(index).start }} -</span> <span>{{ getTime(index).start }} -</span>
<span>{{ getTime(index).end }}</span> <span>{{ getTime(index).end }}</span>
</div> </div>
<div class="buttons" v-if="edit && buttons">
<TrashIcon @click="$emit('delete')" />
</div>
</div> </div>
</template> </template>
@ -119,4 +165,19 @@ function getTime(index) {
opacity: 0.5; opacity: 0.5;
font-weight: 300; font-weight: 300;
} }
.buttons {
margin-left: auto;
opacity: 0.7;
display: grid;
gap: 5px;
align-items: center;
cursor: pointer;
}
[contenteditable="true"]:empty:before {
content: attr(data-ph);
opacity: 0.4;
cursor: text;
}
</style> </style>

View File

@ -0,0 +1,100 @@
<script setup>
import { toRaw, ref } from "vue";
import LessonCard from "@/components/lesson-card.vue";
import {
ArrowUpIcon,
ArrowDownIcon,
SettingsIcon,
TrashIcon,
PlusIcon,
} from "lucide-vue-next";
const props = defineProps(["lesson", "index", "edit"]);
const emit = defineEmits(["change", "move"]);
const advancedOptions = ref(false);
const lessonLength = ref(1);
function updateLength() {
let newLesson = structuredClone(toRaw(props.lesson));
for (let entry of newLesson) {
entry.length = lessonLength.value;
}
emit("change", newLesson);
}
function appendOption() {
let newLesson = structuredClone(toRaw(props.lesson));
if (Array.isArray(newLesson)) newLesson.push({});
else newLesson = [newLesson, {}];
emit("change", newLesson);
}
function lessonChanged(index, data) {
let newLesson = structuredClone(toRaw(props.lesson));
newLesson[index] = data;
if (newLesson.length > 1)
newLesson[index].group = `${data.subject}: ${data.teacher}`;
else delete newLesson[index].group;
emit("change", newLesson);
}
function lessonDeleted(index) {
let newLesson = structuredClone(toRaw(props.lesson));
newLesson.splice(index, 1);
if (newLesson.length == 0) emit("delete");
else emit("change", newLesson);
}
</script>
<template>
<div class="lessonList">
<div class="buttons" v-if="edit">
<ArrowUpIcon @click="$emit('move', 'up')" />
<ArrowDownIcon @click="$emit('move', 'down')" />
<SettingsIcon @click="advancedOptions = !advancedOptions" />
<TrashIcon @click="$emit('delete')" />
<PlusIcon @click="appendOption" />
</div>
<LessonCard
v-for="(option, groupIndex) in lesson"
:key="groupIndex"
:lesson="option"
:index="index"
:edit="edit"
:buttons="edit"
@change="(data) => lessonChanged(groupIndex, data)"
@delete="() => lessonDeleted(groupIndex)"
/>
<div class="advanced" v-show="advancedOptions" v-if="edit">
<span>Length: </span>
<input
type="number"
v-model="lessonLength"
@change="updateLength"
min="1"
/>
</div>
</div>
</template>
<style scoped>
.lessonList {
border-left: 2px solid var(--text-color);
padding-left: 5px;
display: flex;
flex-direction: column;
gap: 5px;
}
.buttons {
opacity: 0.7;
cursor: pointer;
display: flex;
gap: 3px;
}
input {
width: 30px;
}
</style>

View File

@ -0,0 +1,44 @@
<script setup>
import { BanIcon, SaveIcon } from "lucide-vue-next";
defineEmits(["abort", "save"]);
</script>
<template>
<div class="bottomnav">
<div class="entry danger" @click="$emit('back')">
<BanIcon />
<span>Abort</span>
</div>
<div class="entry success" @click="$emit('save')">
<SaveIcon />
<span>Save</span>
</div>
</div>
</template>
<style scoped>
.bottomnav {
width: calc(90% - 40px);
height: 50px;
max-width: 800px;
padding: 0px 20px;
position: fixed;
bottom: 20px;
display: grid;
grid-auto-columns: 1fr;
grid-auto-flow: column;
z-index: 100;
border-radius: 10px;
background-color: var(--bottomnav-color);
box-shadow: var(--bottomnav-shadow);
}
.entry {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
}
</style>

View File

@ -42,7 +42,7 @@ const linkedTimetable = computed(() => {
</div> </div>
<div class="listContainer"> <div class="listContainer">
<template v-for="(lesson, index) in linkedTimetable" :key="index"> <template v-for="(lesson, index) in linkedTimetable" :key="index">
<LessonCard :lesson="lesson" :index="index" /> <LessonCard :lesson="lesson" :index="index" :edit="false" />
</template> </template>
</div> </div>
</template> </template>

View File

@ -9,6 +9,7 @@ import LoginView from "@/views/LoginView.vue";
import TokenView from "@/views/TokenView.vue"; import TokenView from "@/views/TokenView.vue";
import FilteringSettings from "@/views/settings/FilteringSettings.vue"; import FilteringSettings from "@/views/settings/FilteringSettings.vue";
import TimetableSettings from "@/views/settings/TimetableSettings.vue"; import TimetableSettings from "@/views/settings/TimetableSettings.vue";
import TimetableEditor from "@/views/settings/TimetableEditor.vue";
import TimetableGroupSettings from "@/views/settings/TimetableGroupSettings.vue"; import TimetableGroupSettings from "@/views/settings/TimetableGroupSettings.vue";
import AppearanceSettings from "@/views/settings/AppearanceSettings.vue"; import AppearanceSettings from "@/views/settings/AppearanceSettings.vue";
import AboutPage from "@/views/settings/AboutPage.vue"; import AboutPage from "@/views/settings/AboutPage.vue";
@ -76,6 +77,14 @@ const router = createRouter({
}, },
], ],
}, },
{
path: "/settings/timetable/edit/:id?",
name: "title.editTimetable",
component: TimetableEditor,
meta: {
hideNav: true,
},
},
{ {
path: "/login", path: "/login",
name: "title.login", name: "title.login",

View File

@ -34,9 +34,13 @@ watch(
}, },
{ deep: true } { deep: true }
); );
watch(localTimetables, (newValue) => { watch(
localStorage.setItem("timetables", JSON.stringify(newValue)); localTimetables,
}); (newValue) => {
localStorage.setItem("timetables", JSON.stringify(newValue));
},
{ deep: true }
);
watch(theme, (newValue) => { watch(theme, (newValue) => {
localStorage.setItem("theme", newValue); localStorage.setItem("theme", newValue);
}); });
@ -186,7 +190,7 @@ export const parsedTimetable = computed(() => {
let usedLesson = { ...lesson }; let usedLesson = { ...lesson };
// Check if lesson has multiple options // Check if lesson has multiple options
// (timetable groups) // (timetable groups)
if (Array.isArray(lesson)) { if (Array.isArray(lesson) && lesson.length > 1) {
let matchingLesson = lesson.find((e) => let matchingLesson = lesson.find((e) =>
timetableGroups.value.includes(e.group) timetableGroups.value.includes(e.group)
); );
@ -200,6 +204,8 @@ export const parsedTimetable = computed(() => {
}; };
} }
usedLesson = { ...matchingLesson }; usedLesson = { ...matchingLesson };
} else if (Array.isArray(lesson)) {
usedLesson = { ...lesson[0] };
} }
// Duplicate the lesson if its length is > 1 for it // Duplicate the lesson if its length is > 1 for it
// to show up multiple times in the timetable view // to show up multiple times in the timetable view
@ -220,7 +226,7 @@ export const possibleTimetableGroups = computed(() => {
for (const lesson of day) { for (const lesson of day) {
if (Array.isArray(lesson)) { if (Array.isArray(lesson)) {
for (const group of lesson) { for (const group of lesson) {
if (!foundTimetableGroups.includes(group.group)) { if (group.group && !foundTimetableGroups.includes(group.group)) {
foundTimetableGroups.push(group.group); foundTimetableGroups.push(group.group);
} }
} }

View File

@ -6,6 +6,7 @@ export const strings = {
history: "History", history: "History",
login: "Login", login: "Login",
token: "Token", token: "Token",
editTimetable: "Edit Timetable",
settings: { settings: {
main: "Settings", main: "Settings",
filtering: "Filtering", filtering: "Filtering",
@ -28,6 +29,7 @@ export const strings = {
text: { text: {
filtering: filtering:
"Select a class here so the correct timetable is used and only relevant substitutions are shown.", "Select a class here so the correct timetable is used and only relevant substitutions are shown.",
createTimetable: "Create a new Timetable",
timetableGroups: timetableGroups:
"A timetable group defines, which lesson should be displayed, and which substitution should be shown if there are multiple possibilities for a single lesson within one class.", "A timetable group defines, which lesson should be displayed, and which substitution should be shown if there are multiple possibilities for a single lesson within one class.",
language: "Change the language of all texts in the application.", language: "Change the language of all texts in the application.",
@ -110,6 +112,9 @@ export const strings = {
token: { token: {
header: "Generate API-Token", header: "Generate API-Token",
}, },
editor: {
newLesson: "Create new Lesson",
},
}, },
de: { de: {
title: { title: {
@ -117,6 +122,7 @@ export const strings = {
substitutions: "Vertretungsplan", substitutions: "Vertretungsplan",
history: "Verlauf", history: "Verlauf",
login: "Anmelden", login: "Anmelden",
editTimetable: "Stundenplan Editieren",
settings: { settings: {
main: "Einstellungen", main: "Einstellungen",
filtering: "Filter", filtering: "Filter",
@ -139,6 +145,7 @@ export const strings = {
text: { text: {
filtering: filtering:
"Wähle hier deine Klasse aus, damit du deinen Stundenplan angezeigt bekommst und du nur relevante Vertretungen siehst.", "Wähle hier deine Klasse aus, damit du deinen Stundenplan angezeigt bekommst und du nur relevante Vertretungen siehst.",
createTimetable: "Neuen Stundenplan erstellen",
timetableGroups: timetableGroups:
"Stundenplan-Gruppen legen fest, welche Stundenplan-Daten du angezeigt bekommst, wenn es mehrere Möglichkeiten für eine Stunde gibt.", "Stundenplan-Gruppen legen fest, welche Stundenplan-Daten du angezeigt bekommst, wenn es mehrere Möglichkeiten für eine Stunde gibt.",
language: "Ändere die Sprache aller Texte dieser Anwendung.", language: "Ändere die Sprache aller Texte dieser Anwendung.",
@ -220,5 +227,8 @@ export const strings = {
"An den Vertretungen für diesen Tag wurde noch nichts geändert", "An den Vertretungen für diesen Tag wurde noch nichts geändert",
}, },
}, },
editor: {
newLesson: "Neue Stunde hinzufügen",
},
}, },
}; };

View File

@ -0,0 +1,221 @@
<script setup>
import { useRoute } from "vue-router";
import { Swiper, SwiperSlide } from "swiper/vue";
import { ref, toRaw } from "vue";
import { localTimetables } from "@/store";
import { PlusIcon } from "lucide-vue-next";
import EditorNavbar from "@/components/settings/editor-navbar.vue";
import DateSelector from "@/components/date-selector.vue";
import ScrollableContainer from "@/components/scrollable-container.vue";
import LessonGroupList from "@/components/lesson-group-list.vue";
const route = useRoute();
const timetable = localTimetables.value.find(
(e) => e.id == route.params.id
) || {
data: [],
};
timetable.data = timetable.data.map((day) => {
if (!day) return [];
return day.map((lesson) => {
if (Array.isArray(lesson)) return toRaw(lesson);
else return [toRaw(lesson)];
});
});
const timetableClone = ref(structuredClone(toRaw(timetable)));
const swiperElement = ref();
function setSwiper(swiper) {
swiperElement.value = swiper;
}
function getLessonIndex(index, day) {
let lessonIndex = 0;
for (let i = 0; i < index; i++) {
lessonIndex += day[i][0].length || 1;
}
return lessonIndex;
}
function getLessonLength(lesson) {
if (Array.isArray(lesson)) return lesson[0].length || 1;
else return lesson.length || 1;
}
function arrayMove(arr, fromIndex, toIndex) {
let element = arr[fromIndex];
arr.splice(fromIndex, 1);
arr.splice(toIndex, 0, element);
}
</script>
<template>
<div class="editor">
<DateSelector
:selectedDay="(swiperElement || { activeIndex: 0 }).activeIndex"
@changeDay="
(day) => swiperElement.slideTo(swiperElement.activeIndex + day)
"
/>
<div class="settings">
<span>Name: </span>
<input type="text" v-model="timetableClone.title" />
</div>
<div class="wrapper">
<swiper
:slides-per-view="1"
:space-between="50"
:initial-slide="0"
@swiper="setSwiper"
>
<swiper-slide :key="day" v-for="day in 5">
<ScrollableContainer>
<div class="list">
<div
class="lesson"
v-for="(lesson, index) in timetableClone.data[day - 1]"
:key="index"
>
<!-- Add main editable element -->
<LessonGroupList
:index="getLessonIndex(index, timetableClone.data[day - 1])"
:lesson="lesson"
:edit="true"
@change="(e) => (timetableClone.data[day - 1][index] = e)"
@delete="timetableClone.data[day - 1].splice(index, 1)"
@move="
(direction) => {
if (direction == 'up' && index > 0) {
arrayMove(
timetableClone.data[day - 1],
index,
index - 1
);
} else if (
direction == 'down' &&
index < timetableClone.data[day - 1].length
) {
arrayMove(
timetableClone.data[day - 1],
index,
index + 1
);
}
}
"
/>
<!-- Add placeholder elements for lessons with length > 1 -->
<div
class="inactive"
v-for="placeholderLesson in getLessonLength(lesson) - 1"
:key="placeholderLesson"
>
<LessonGroupList
:index="
getLessonIndex(index, timetableClone.data[day - 1]) +
placeholderLesson
"
:lesson="lesson"
:edit="false"
/>
</div>
</div>
</div>
<div
class="create"
@click="
if (timetableClone.data[day - 1]) {
timetableClone.data[day - 1].push([{}]);
} else {
timetableClone.data[day - 1] = [[{}]];
}
"
>
<PlusIcon />
<span>{{ $t("editor.newLesson") }}</span>
</div>
</ScrollableContainer>
</swiper-slide>
</swiper>
</div>
</div>
<div class="navbar">
<EditorNavbar
@back="$router.back()"
@save="
() => {
timetable.data = timetableClone.data;
timetable.title = timetableClone.title;
timetable.source = 'Manual';
timetable.trusted = true;
$router.back();
}
"
/>
</div>
</template>
<style>
@import "swiper/css";
</style>
<style scoped>
.editor {
height: 100%;
display: flex;
flex-direction: column;
}
.settings {
padding: 5px 10px 0px 10px;
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
margin-bottom: 5px;
}
.settings input {
border: unset;
background-color: unset;
color: unset;
border-bottom: 1px solid var(--text-color);
outline: unset;
}
.wrapper {
overflow: hidden;
height: 100%;
}
.navbar {
display: flex;
justify-content: center;
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
}
.swiper {
padding: 5px 10px 0px 10px;
height: 100%;
box-sizing: border-box;
}
.inactive {
opacity: 0.7;
margin-top: 10px;
}
.create {
display: flex;
justify-content: center;
align-items: center;
padding: 5px;
margin-top: 10px;
gap: 5px;
cursor: pointer;
}
</style>

View File

@ -1,6 +1,7 @@
<script setup> <script setup>
import TimetableCard from "@/components/settings/timetable-card.vue"; import TimetableCard from "@/components/settings/timetable-card.vue";
import { timetables, localTimetables, timetableId } from "@/store"; import { timetables, localTimetables, timetableId } from "@/store";
import { PlusIcon } from "lucide-vue-next";
import { toRaw } from "vue"; import { toRaw } from "vue";
function copyTimetable(timetable) { function copyTimetable(timetable) {
@ -9,39 +10,54 @@ function copyTimetable(timetable) {
newTimetable.id = new Date().getTime(); newTimetable.id = new Date().getTime();
localTimetables.value.push(newTimetable); localTimetables.value.push(newTimetable);
} }
function createTimetable() {
localTimetables.value.push({
id: new Date().getTime(),
title: "New timetable",
data: [],
});
}
</script> </script>
<template> <template>
<h2>{{ $t("settings.heading.localTimetables") }}</h2> <div class="content" v-if="$route.name == 'title.settings.timetable'">
<div class="list"> <h2>{{ $t("settings.heading.localTimetables") }}</h2>
<TimetableCard <div class="list">
v-for="timetable in localTimetables" <TimetableCard
:key="timetable.id" v-for="timetable in localTimetables"
:timetable="timetable" :key="timetable.id"
:selected="timetableId == timetable.id" :timetable="timetable"
:editable="true" :selected="timetableId == timetable.id"
@click="timetableId = timetable.id" :editable="true"
@copy="copyTimetable(timetable)" @click="timetableId = timetable.id"
@delete=" @edit="$router.push('timetable/edit/' + timetable.id)"
localTimetables.splice( @copy="copyTimetable(timetable)"
localTimetables.findIndex((e) => e.id == timetable.id), @delete="
1 localTimetables.splice(
) localTimetables.findIndex((e) => e.id == timetable.id),
" 1
/> )
</div> "
<h2>{{ $t("settings.heading.remoteTimetables") }}</h2> />
<div class="list"> </div>
<TimetableCard <div class="create" @click="createTimetable">
v-for="timetable in timetables" <PlusIcon /> {{ $t("settings.text.createTimetable") }}
:key="timetable.id" </div>
:timetable="timetable" <h2>{{ $t("settings.heading.remoteTimetables") }}</h2>
:selected="timetableId == timetable.id" <div class="list">
:editable="false" <TimetableCard
@click="timetableId = timetable.id" v-for="timetable in timetables"
@copy="copyTimetable(timetable)" :key="timetable.id"
/> :timetable="timetable"
:selected="timetableId == timetable.id"
:editable="false"
@click="timetableId = timetable.id"
@copy="copyTimetable(timetable)"
/>
</div>
</div> </div>
<RouterView v-else />
</template> </template>
<style scoped> <style scoped>
@ -57,4 +73,13 @@ p {
display: grid; display: grid;
gap: 8px; gap: 8px;
} }
.create {
display: flex;
justify-content: center;
align-items: center;
margin-top: 5px;
padding: 10px;
cursor: pointer;
}
</style> </style>