✨ Add support for multiple remote timetables
- Add timetable "title" column to db - Make API return array of timetables - Add settings page for selecting a timetable - Add InfoCard if no timetable is selected
This commit is contained in:
@ -13,7 +13,7 @@ export async function getTimetable(req, res) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestedClass = req.query.class.toLowerCase();
|
const requestedClass = req.query.class.toLowerCase();
|
||||||
const timetable = await prisma.timetable.findFirst({
|
const timetables = await prisma.timetable.findMany({
|
||||||
where: {
|
where: {
|
||||||
class: requestedClass,
|
class: requestedClass,
|
||||||
},
|
},
|
||||||
@ -22,19 +22,9 @@ export async function getTimetable(req, res) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const times = await prisma.time.findMany();
|
const times = await prisma.time.findMany();
|
||||||
if (!timetable) {
|
|
||||||
res.status(404).send({
|
|
||||||
success: false,
|
|
||||||
error: "no_timetable",
|
|
||||||
message: "No timetable was found for this class",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.send({
|
res.send({
|
||||||
trusted: timetable.trusted,
|
timetables,
|
||||||
source: timetable.source,
|
times,
|
||||||
data: timetable.data,
|
|
||||||
times: times,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ datasource db {
|
|||||||
|
|
||||||
model Timetable {
|
model Timetable {
|
||||||
id Int @id @unique @default(autoincrement())
|
id Int @id @unique @default(autoincrement())
|
||||||
|
title String @default("Default")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
class String
|
class String
|
||||||
|
73
src/components/settings/timetable-card.vue
Normal file
73
src/components/settings/timetable-card.vue
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
CircleIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
Edit2Icon,
|
||||||
|
TrashIcon,
|
||||||
|
CopyIcon,
|
||||||
|
} from "lucide-vue-next";
|
||||||
|
|
||||||
|
defineProps(["timetable", "editable", "selected"]);
|
||||||
|
defineEmits(["click", "edit", "delete", "copy"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="button" @click="$emit('click')">
|
||||||
|
<CheckCircleIcon v-if="selected" />
|
||||||
|
<CircleIcon v-else />
|
||||||
|
|
||||||
|
<div class="info">
|
||||||
|
<span class="name">{{ timetable.title }}</span>
|
||||||
|
<span class="detail"
|
||||||
|
>{{ $t("settings.source") }}: {{ timetable.source }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<Edit2Icon v-if="editable" @click="$emit('edit')" />
|
||||||
|
<TrashIcon v-if="editable" @click="$emit('delete')" />
|
||||||
|
<CopyIcon v-if="!editable" @click="$emit('copy')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 15px;
|
||||||
|
background-color: var(--element-color);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 10px;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 7px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { timetable, parsedTimetable, substitutions } from "@/store";
|
import { timetable, times, parsedTimetable, substitutions } from "@/store";
|
||||||
import { getSubstitutionColor } from "@/util";
|
import { getSubstitutionColor } from "@/util";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
@ -52,18 +52,17 @@ function isCancelled(substitution) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTime(index) {
|
function getTime(index) {
|
||||||
const times = {
|
const lessonTimes = {
|
||||||
...(timetable.value.times.find((e) => e.lesson == index + 1) || {}),
|
...(times.value.find((e) => e.lesson == index + 1) || {}),
|
||||||
};
|
};
|
||||||
console.log(times);
|
Object.keys(lessonTimes).forEach((e) => {
|
||||||
Object.keys(times).forEach((e) => {
|
|
||||||
if (e == "lesson") return;
|
if (e == "lesson") return;
|
||||||
const date = new Date(times[e]);
|
const date = new Date(lessonTimes[e]);
|
||||||
const hours = date.getHours().toString().padStart(2, "0");
|
const hours = date.getHours().toString().padStart(2, "0");
|
||||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||||
times[e] = `${hours}:${minutes}`;
|
lessonTimes[e] = `${hours}:${minutes}`;
|
||||||
});
|
});
|
||||||
return times;
|
return lessonTimes;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
@ -8,6 +8,7 @@ import SettingsView from "@/views/SettingsView.vue";
|
|||||||
import LoginView from "@/views/LoginView.vue";
|
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 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";
|
||||||
@ -53,6 +54,11 @@ const router = createRouter({
|
|||||||
name: "title.settings.filtering",
|
name: "title.settings.filtering",
|
||||||
component: FilteringSettings,
|
component: FilteringSettings,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "timetable",
|
||||||
|
name: "title.settings.timetable",
|
||||||
|
component: TimetableSettings,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "groups",
|
path: "groups",
|
||||||
name: "title.settings.groups",
|
name: "title.settings.groups",
|
||||||
|
24
src/store.js
24
src/store.js
@ -10,6 +10,7 @@ export const loadingFailed = ref(false);
|
|||||||
|
|
||||||
/* Preferences */
|
/* Preferences */
|
||||||
export const classFilter = ref(localStorage.getItem("classFilter") || "none");
|
export const classFilter = ref(localStorage.getItem("classFilter") || "none");
|
||||||
|
export const timetableId = ref(localStorage.getItem("timetableId") || "none");
|
||||||
export const timetableGroups = ref(
|
export const timetableGroups = ref(
|
||||||
JSON.parse(localStorage.getItem("timetableGroups") || "[]")
|
JSON.parse(localStorage.getItem("timetableGroups") || "[]")
|
||||||
);
|
);
|
||||||
@ -20,6 +21,9 @@ watch(classFilter, (newValue) => {
|
|||||||
localStorage.setItem("classFilter", newValue);
|
localStorage.setItem("classFilter", newValue);
|
||||||
fetchData(getNextAndPrevDay(selectedDate.value), false);
|
fetchData(getNextAndPrevDay(selectedDate.value), false);
|
||||||
});
|
});
|
||||||
|
watch(timetableId, (newValue) => {
|
||||||
|
localStorage.setItem("timetableId", newValue);
|
||||||
|
});
|
||||||
watch(
|
watch(
|
||||||
timetableGroups,
|
timetableGroups,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
@ -47,7 +51,14 @@ export const changeDay = ref(0);
|
|||||||
export const changeDate = ref(new Date());
|
export const changeDate = ref(new Date());
|
||||||
|
|
||||||
/* Data store */
|
/* Data store */
|
||||||
export const timetable = ref({ trusted: true });
|
export const timetable = computed(() => {
|
||||||
|
const selectedTimetable = timetables.value.find(
|
||||||
|
(e) => e.id == timetableId.value
|
||||||
|
);
|
||||||
|
return selectedTimetable || { trusted: true, source: "", data: [] };
|
||||||
|
});
|
||||||
|
export const timetables = ref([]);
|
||||||
|
export const times = ref([]);
|
||||||
export const substitutions = ref({});
|
export const substitutions = ref({});
|
||||||
export const history = ref({});
|
export const history = ref({});
|
||||||
export const classList = ref([]);
|
export const classList = ref([]);
|
||||||
@ -92,7 +103,7 @@ export async function fetchData(days, partial) {
|
|||||||
if (!partial) {
|
if (!partial) {
|
||||||
await fetchClassList();
|
await fetchClassList();
|
||||||
loadingProgress.value = step++ / steps;
|
loadingProgress.value = step++ / steps;
|
||||||
await fetchTimetable();
|
await fetchTimetables();
|
||||||
loadingProgress.value = step++ / steps;
|
loadingProgress.value = step++ / steps;
|
||||||
}
|
}
|
||||||
for (const day of days) {
|
for (const day of days) {
|
||||||
@ -117,15 +128,18 @@ export async function fetchClassList() {
|
|||||||
classList.value = classListData;
|
classList.value = classListData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTimetable() {
|
export async function fetchTimetables() {
|
||||||
const timetableResponse = await fetch(
|
const timetableResponse = await fetch(
|
||||||
`${baseUrl}/timetable?class=${classFilter.value}`
|
`${baseUrl}/timetable?class=${classFilter.value}`
|
||||||
);
|
);
|
||||||
const timetableData = await timetableResponse.json();
|
const timetableData = await timetableResponse.json();
|
||||||
if (timetableData.error) {
|
if (timetableData.error) {
|
||||||
console.warn("API Error: " + timetableData.error);
|
console.warn("API Error: " + timetableData.error);
|
||||||
timetable.value = { trusted: true, source: "", data: [] };
|
timetables.value = [];
|
||||||
} else timetable.value = timetableData;
|
} else {
|
||||||
|
timetables.value = timetableData.timetables;
|
||||||
|
times.value = timetableData.times;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSubstitutions(day) {
|
export async function fetchSubstitutions(day) {
|
||||||
|
@ -9,6 +9,7 @@ export const strings = {
|
|||||||
settings: {
|
settings: {
|
||||||
main: "Settings",
|
main: "Settings",
|
||||||
filtering: "Filtering",
|
filtering: "Filtering",
|
||||||
|
timetable: "Manage Timetables",
|
||||||
groups: "Timetable Groups",
|
groups: "Timetable Groups",
|
||||||
appearance: "Appearance",
|
appearance: "Appearance",
|
||||||
about: "About",
|
about: "About",
|
||||||
@ -18,6 +19,7 @@ export const strings = {
|
|||||||
heading: {
|
heading: {
|
||||||
filtering: "Filtering",
|
filtering: "Filtering",
|
||||||
timetableGroups: "Timetable Groups",
|
timetableGroups: "Timetable Groups",
|
||||||
|
remoteTimetables: "Remote Timetables",
|
||||||
language: "Language",
|
language: "Language",
|
||||||
about: "About",
|
about: "About",
|
||||||
theme: "Theme",
|
theme: "Theme",
|
||||||
@ -37,6 +39,7 @@ export const strings = {
|
|||||||
back: "Back",
|
back: "Back",
|
||||||
none: "None",
|
none: "None",
|
||||||
version: "Version",
|
version: "Version",
|
||||||
|
source: "Source",
|
||||||
theme: {
|
theme: {
|
||||||
auto: "Auto",
|
auto: "Auto",
|
||||||
dark: "Dark",
|
dark: "Dark",
|
||||||
@ -88,6 +91,7 @@ export const strings = {
|
|||||||
titles: {
|
titles: {
|
||||||
loading: "Loading data",
|
loading: "Loading data",
|
||||||
loadingFailed: "Loading failed",
|
loadingFailed: "Loading failed",
|
||||||
|
noTimetable: "No timetable",
|
||||||
noEntries: "No Substitutions",
|
noEntries: "No Substitutions",
|
||||||
noHistory: "No History",
|
noHistory: "No History",
|
||||||
},
|
},
|
||||||
@ -96,6 +100,8 @@ export const strings = {
|
|||||||
loadingTimetable: "The timetable is still being loaded",
|
loadingTimetable: "The timetable is still being loaded",
|
||||||
loadingFailed:
|
loadingFailed:
|
||||||
"The data could not be loaded. Plase check your internet connection!",
|
"The data could not be loaded. Plase check your internet connection!",
|
||||||
|
noTimetable:
|
||||||
|
"No timetable active. You can manage your timetables in the settings!",
|
||||||
noEntries: "There are no substitutions for this day yet",
|
noEntries: "There are no substitutions for this day yet",
|
||||||
noHistory: "No substitutions for this day have changed yet",
|
noHistory: "No substitutions for this day have changed yet",
|
||||||
},
|
},
|
||||||
@ -113,6 +119,7 @@ export const strings = {
|
|||||||
settings: {
|
settings: {
|
||||||
main: "Einstellungen",
|
main: "Einstellungen",
|
||||||
filtering: "Filter",
|
filtering: "Filter",
|
||||||
|
timetable: "Stundenpläne Verwalten",
|
||||||
groups: "Stundenplan-Gruppen",
|
groups: "Stundenplan-Gruppen",
|
||||||
appearance: "Aussehen",
|
appearance: "Aussehen",
|
||||||
about: "Über",
|
about: "Über",
|
||||||
@ -121,6 +128,7 @@ export const strings = {
|
|||||||
settings: {
|
settings: {
|
||||||
heading: {
|
heading: {
|
||||||
filtering: "Filter",
|
filtering: "Filter",
|
||||||
|
remoteTimetables: "Online Stundenpläne",
|
||||||
timetableGroups: "Stundenplan-Gruppen",
|
timetableGroups: "Stundenplan-Gruppen",
|
||||||
language: "Sprache",
|
language: "Sprache",
|
||||||
about: "Über diese Anwendung",
|
about: "Über diese Anwendung",
|
||||||
@ -141,6 +149,7 @@ export const strings = {
|
|||||||
back: "Zurück",
|
back: "Zurück",
|
||||||
none: "Keine",
|
none: "Keine",
|
||||||
version: "Version",
|
version: "Version",
|
||||||
|
source: "Quelle",
|
||||||
theme: {
|
theme: {
|
||||||
auto: "Automatisch",
|
auto: "Automatisch",
|
||||||
dark: "Dunkel",
|
dark: "Dunkel",
|
||||||
@ -193,6 +202,7 @@ export const strings = {
|
|||||||
titles: {
|
titles: {
|
||||||
loading: "Läd noch",
|
loading: "Läd noch",
|
||||||
loadingFailed: "Fehler",
|
loadingFailed: "Fehler",
|
||||||
|
noTimetable: "Kein Stundenplan",
|
||||||
noEntries: "Keine Vertretungen",
|
noEntries: "Keine Vertretungen",
|
||||||
noHistory: "Noch keine Änderungen",
|
noHistory: "Noch keine Änderungen",
|
||||||
},
|
},
|
||||||
@ -201,6 +211,8 @@ export const strings = {
|
|||||||
loadingTimetable: "Der Stundenplan wird noch geladen",
|
loadingTimetable: "Der Stundenplan wird noch geladen",
|
||||||
loadingFailed:
|
loadingFailed:
|
||||||
"Die Daten konnten nicht geladen werden. Bitte überprüfe deine Internetverbindung!",
|
"Die Daten konnten nicht geladen werden. Bitte überprüfe deine Internetverbindung!",
|
||||||
|
noTimetable:
|
||||||
|
"Kein Stundenplan ausgewählt. Du kannst deine Stundenpläne in den Einstellungen verwalten!",
|
||||||
noEntries: "Es gibt noch keine Vertretungen für diesen Tag",
|
noEntries: "Es gibt noch keine Vertretungen für diesen Tag",
|
||||||
noHistory:
|
noHistory:
|
||||||
"An den Vertretungen für diesen Tag wurde noch nichts geändert",
|
"An den Vertretungen für diesen Tag wurde noch nichts geändert",
|
||||||
|
@ -3,6 +3,7 @@ import ScrollableContainer from "@/components/scrollable-container.vue";
|
|||||||
import PageCard from "@/components/settings/page-card.vue";
|
import PageCard from "@/components/settings/page-card.vue";
|
||||||
import {
|
import {
|
||||||
FilterIcon,
|
FilterIcon,
|
||||||
|
CalendarIcon,
|
||||||
CopyCheckIcon,
|
CopyCheckIcon,
|
||||||
PaletteIcon,
|
PaletteIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
@ -19,6 +20,11 @@ import {
|
|||||||
:icon="FilterIcon"
|
:icon="FilterIcon"
|
||||||
route="settings/filtering"
|
route="settings/filtering"
|
||||||
/>
|
/>
|
||||||
|
<PageCard
|
||||||
|
:name="$t('title.settings.timetable')"
|
||||||
|
:icon="CalendarIcon"
|
||||||
|
route="settings/timetable"
|
||||||
|
/>
|
||||||
<PageCard
|
<PageCard
|
||||||
:name="$t('title.settings.groups')"
|
:name="$t('title.settings.groups')"
|
||||||
:icon="CopyCheckIcon"
|
:icon="CopyCheckIcon"
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import TimetableSetup from "@/components/timetable-setup.vue";
|
import TimetableSetup from "@/components/timetable-setup.vue";
|
||||||
import { classList, classFilter, timetable, loadingFailed } from "@/store";
|
import {
|
||||||
|
classList,
|
||||||
|
classFilter,
|
||||||
|
timetable,
|
||||||
|
loadingFailed,
|
||||||
|
loading,
|
||||||
|
} from "@/store";
|
||||||
import DayCarousel from "@/components/day-carousel.vue";
|
import DayCarousel from "@/components/day-carousel.vue";
|
||||||
import TimetableList from "@/components/timetable-list.vue";
|
import TimetableList from "@/components/timetable-list.vue";
|
||||||
import InfoCard from "@/components/info-card.vue";
|
import InfoCard from "@/components/info-card.vue";
|
||||||
import { ClockIcon } from "lucide-vue-next";
|
import { ClockIcon, CloudOffIcon, CalendarOffIcon } from "lucide-vue-next";
|
||||||
import { CloudOffIcon } from "lucide-vue-next";
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -20,11 +25,18 @@ import { CloudOffIcon } from "lucide-vue-next";
|
|||||||
>
|
>
|
||||||
<InfoCard
|
<InfoCard
|
||||||
class="card"
|
class="card"
|
||||||
v-if="!loadingFailed"
|
v-if="!loadingFailed && loading"
|
||||||
:icon="ClockIcon"
|
:icon="ClockIcon"
|
||||||
:title="$t('infoCard.titles.loading')"
|
:title="$t('infoCard.titles.loading')"
|
||||||
:text="$t('infoCard.texts.loadingTimetable')"
|
:text="$t('infoCard.texts.loadingTimetable')"
|
||||||
/>
|
/>
|
||||||
|
<InfoCard
|
||||||
|
class="card"
|
||||||
|
v-else-if="!loadingFailed && !loading"
|
||||||
|
:icon="CalendarOffIcon"
|
||||||
|
:title="$t('infoCard.titles.noTimetable')"
|
||||||
|
:text="$t('infoCard.texts.noTimetable')"
|
||||||
|
/>
|
||||||
<InfoCard
|
<InfoCard
|
||||||
class="card"
|
class="card"
|
||||||
v-else
|
v-else
|
||||||
|
33
src/views/settings/TimetableSettings.vue
Normal file
33
src/views/settings/TimetableSettings.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<script setup>
|
||||||
|
import TimetableCard from "@/components/settings/timetable-card.vue";
|
||||||
|
import { timetables, timetableId } from "@/store";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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="true"
|
||||||
|
@click="timetableId = timetable.id"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h2 {
|
||||||
|
margin: 5px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 5px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
Reference in New Issue
Block a user