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

View File

@ -1,8 +1,25 @@
<script setup>
import { getSubstitutionColor } from "@/util";
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) {
const substitution = lesson.substitution;
@ -43,13 +60,21 @@ function getTime(index) {
<div class="infos">
<!-- Subject changed -->
<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>
<!-- Cancellation -->
<span class="subject" v-else-if="isCancelled(lesson.substitution)">
<s>{{ lesson.subject }}</s>
</span>
<span class="subject" v-else>
<span
class="subject"
v-else
:contenteditable="edit"
data-ph="Subject"
ref="subjectElement"
@input="update"
>
{{ lesson.subject }}
</span>
<div class="info">
@ -58,22 +83,43 @@ function getTime(index) {
<s>{{ lesson.teacher }}</s>
{{ 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 -->
<span class="info" v-if="isChanged(lesson, 'room')"
>, <s>{{ lesson.room }}</s> {{ lesson.substitution.change.room }}
</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 -->
<span class="info" v-if="getNotes(lesson.substitution)"
>, {{ $t("timetable.notes") }} {{ getNotes(lesson.substitution) }}
</span>
</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).end }}</span>
</div>
<div class="buttons" v-if="edit && buttons">
<TrashIcon @click="$emit('delete')" />
</div>
</div>
</template>
@ -119,4 +165,19 @@ function getTime(index) {
opacity: 0.5;
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>

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 class="listContainer">
<template v-for="(lesson, index) in linkedTimetable" :key="index">
<LessonCard :lesson="lesson" :index="index" />
<LessonCard :lesson="lesson" :index="index" :edit="false" />
</template>
</div>
</template>

View File

@ -9,6 +9,7 @@ import LoginView from "@/views/LoginView.vue";
import TokenView from "@/views/TokenView.vue";
import FilteringSettings from "@/views/settings/FilteringSettings.vue";
import TimetableSettings from "@/views/settings/TimetableSettings.vue";
import TimetableEditor from "@/views/settings/TimetableEditor.vue";
import TimetableGroupSettings from "@/views/settings/TimetableGroupSettings.vue";
import AppearanceSettings from "@/views/settings/AppearanceSettings.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",
name: "title.login",

View File

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

View File

@ -6,6 +6,7 @@ export const strings = {
history: "History",
login: "Login",
token: "Token",
editTimetable: "Edit Timetable",
settings: {
main: "Settings",
filtering: "Filtering",
@ -28,6 +29,7 @@ export const strings = {
text: {
filtering:
"Select a class here so the correct timetable is used and only relevant substitutions are shown.",
createTimetable: "Create a new Timetable",
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.",
language: "Change the language of all texts in the application.",
@ -110,6 +112,9 @@ export const strings = {
token: {
header: "Generate API-Token",
},
editor: {
newLesson: "Create new Lesson",
},
},
de: {
title: {
@ -117,6 +122,7 @@ export const strings = {
substitutions: "Vertretungsplan",
history: "Verlauf",
login: "Anmelden",
editTimetable: "Stundenplan Editieren",
settings: {
main: "Einstellungen",
filtering: "Filter",
@ -139,6 +145,7 @@ export const strings = {
text: {
filtering:
"Wähle hier deine Klasse aus, damit du deinen Stundenplan angezeigt bekommst und du nur relevante Vertretungen siehst.",
createTimetable: "Neuen Stundenplan erstellen",
timetableGroups:
"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.",
@ -220,5 +227,8 @@ export const strings = {
"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>
import TimetableCard from "@/components/settings/timetable-card.vue";
import { timetables, localTimetables, timetableId } from "@/store";
import { PlusIcon } from "lucide-vue-next";
import { toRaw } from "vue";
function copyTimetable(timetable) {
@ -9,39 +10,54 @@ function copyTimetable(timetable) {
newTimetable.id = new Date().getTime();
localTimetables.value.push(newTimetable);
}
function createTimetable() {
localTimetables.value.push({
id: new Date().getTime(),
title: "New timetable",
data: [],
});
}
</script>
<template>
<h2>{{ $t("settings.heading.localTimetables") }}</h2>
<div class="list">
<TimetableCard
v-for="timetable in localTimetables"
:key="timetable.id"
:timetable="timetable"
:selected="timetableId == timetable.id"
:editable="true"
@click="timetableId = timetable.id"
@copy="copyTimetable(timetable)"
@delete="
localTimetables.splice(
localTimetables.findIndex((e) => e.id == timetable.id),
1
)
"
/>
</div>
<h2>{{ $t("settings.heading.remoteTimetables") }}</h2>
<div class="list">
<TimetableCard
v-for="timetable in timetables"
:key="timetable.id"
:timetable="timetable"
:selected="timetableId == timetable.id"
:editable="false"
@click="timetableId = timetable.id"
@copy="copyTimetable(timetable)"
/>
<div class="content" v-if="$route.name == 'title.settings.timetable'">
<h2>{{ $t("settings.heading.localTimetables") }}</h2>
<div class="list">
<TimetableCard
v-for="timetable in localTimetables"
:key="timetable.id"
:timetable="timetable"
:selected="timetableId == timetable.id"
:editable="true"
@click="timetableId = timetable.id"
@edit="$router.push('timetable/edit/' + timetable.id)"
@copy="copyTimetable(timetable)"
@delete="
localTimetables.splice(
localTimetables.findIndex((e) => e.id == timetable.id),
1
)
"
/>
</div>
<div class="create" @click="createTimetable">
<PlusIcon /> {{ $t("settings.text.createTimetable") }}
</div>
<h2>{{ $t("settings.heading.remoteTimetables") }}</h2>
<div class="list">
<TimetableCard
v-for="timetable in timetables"
:key="timetable.id"
:timetable="timetable"
:selected="timetableId == timetable.id"
:editable="false"
@click="timetableId = timetable.id"
@copy="copyTimetable(timetable)"
/>
</div>
</div>
<RouterView v-else />
</template>
<style scoped>
@ -57,4 +73,13 @@ p {
display: grid;
gap: 8px;
}
.create {
display: flex;
justify-content: center;
align-items: center;
margin-top: 5px;
padding: 10px;
cursor: pointer;
}
</style>