aboutsummaryrefslogtreecommitdiffstats
path: root/web/pages
diff options
context:
space:
mode:
Diffstat (limited to 'web/pages')
-rw-r--r--web/pages/agenda.vue152
-rw-r--r--web/pages/events.vue40
-rw-r--r--web/pages/index.vue22
-rw-r--r--web/pages/live.vue139
-rw-r--r--web/pages/login.vue193
-rw-r--r--web/pages/register.vue190
-rw-r--r--web/pages/tracks/[slug].vue45
-rw-r--r--web/pages/tracks/index.vue49
8 files changed, 830 insertions, 0 deletions
diff --git a/web/pages/agenda.vue b/web/pages/agenda.vue
new file mode 100644
index 0000000..5e0c643
--- /dev/null
+++ b/web/pages/agenda.vue
@@ -0,0 +1,152 @@
+<script setup lang="ts">
+import Panel from '~/components/Panel.vue';
+import Dialog from '~/components/Dialog.vue';
+import { Calendar } from 'lucide-vue-next';
+
+const favouritesStore = useFavouritesStore();
+const scheduleStore = useScheduleStore();
+const errorStore = useErrorStore();
+const config = useRuntimeConfig();
+
+const favouriteEvents = computed(() => {
+ return scheduleStore.events.filter((event) => favouritesStore.isFavourite(event));
+});
+
+const calendarStatus = ref('pending' as 'pending' | 'idle');
+const calendarLink = ref('')
+const calendarLinkWithPageProtocol = computed(() => {
+ return window.location.protocol + '//' + calendarLink.value;
+});
+
+const refConfirmDeleteDialog = ref<typeof Dialog>();
+
+const calendarAction = ref(false);
+
+useFetch(config.public.baseURL + '/calendar', {
+ method: 'GET',
+ server: false,
+ lazy: true,
+ onResponse: ({ response }) => {
+ if (!response.ok) {
+ if (response.status !== 404) {
+ errorStore.setError(response._data.message || 'An unknown error occurred');
+ }
+ } else if (response._data) {
+ calendarLink.value = (response._data as any).data.url;
+ }
+ calendarStatus.value = 'idle';
+ },
+});
+
+function generateCalendar() {
+ calendarAction.value = true;
+ useFetch(config.public.baseURL + '/calendar', {
+ method: 'POST',
+ server: false,
+ lazy: true,
+ onResponseError: ({ response }) => {
+ errorStore.setError(response._data.message || 'An unknown error occurred');
+ calendarAction.value = false;
+ },
+ onResponse: ({ response }) => {
+ if (response._data) {
+ calendarLink.value = (response._data as any).data.url;
+ }
+ calendarAction.value = false;
+ },
+ });
+}
+
+function deleteCalendar() {
+ calendarAction.value = true;
+ useFetch(config.public.baseURL + '/calendar', {
+ method: 'DELETE',
+ server: false,
+ onResponseError: ({ response }) => {
+ errorStore.setError(response._data.message || 'An unknown error occurred');
+ calendarAction.value = false;
+ },
+ onResponse: () => {
+ calendarLink.value = '';
+ calendarAction.value = false;
+ },
+ });
+}
+
+</script>
+
+<template>
+ <Panel v-if="favouritesStore.status === 'pending'">
+ <span>Updating favourites...</span>
+ </Panel>
+
+ <template v-else-if="favouriteEvents.length > 0" >
+ <div class="page">
+ <Panel title="Agenda" :icon="Calendar">
+ <ul class="agenda-list">
+ <li v-for="event in favouriteEvents" :key="event.id" class="agenda-item" >
+ <EventListing :event="event" />
+ </li>
+ </ul>
+ </Panel>
+ <Panel>
+ <template v-if="calendarStatus === 'pending'">
+ <span>Fetching calendar status...</span>
+ </template>
+
+ <div v-else-if="calendarStatus === 'idle'" class="calendar">
+ <template v-if="calendarLink">
+ <span>You can add your agenda to your own calendar using the iCal link below</span>
+ <Input :value="calendarLinkWithPageProtocol" readonly/>
+ <Button @click="refConfirmDeleteDialog!.show()" :loading="calendarAction">Delete calendar</Button>
+ </template>
+ <template v-else>
+ <span>You do not have a calendar link yet. Use the button below to request a calendar link to subscribe to on your own calendar app.</span>
+ <Button @click="generateCalendar" :loading="calendarAction">Request calendar</Button>
+ </template>
+ </div>
+ </Panel>
+ </div>
+ </template>
+
+ <Panel v-else>
+ <span>You have not added any favourites yet.</span>
+ </Panel>
+
+ <Dialog ref="refConfirmDeleteDialog" title="Delete calendar" :confirmation="true" @submit="deleteCalendar" :fit-contents="true">
+ <span>Are you sure you want to delete your calendar?</span>
+ <span>Your unique link cannot be recovered if you do so.</span>
+ <template v-slot:actions>
+ <Button kind="secondary" type="button" @click="refConfirmDeleteDialog!.close()">Cancel</Button>
+ <Button kind="danger" type="submit" :loading="calendarAction">Delete</Button>
+ </template>
+ </Dialog>
+</template>
+
+<style scoped>
+.agenda-list {
+ list-style: none;
+ margin: -1rem 0;
+ padding: 0;
+ display: grid;
+}
+
+.agenda-item {
+ border-bottom: 1px solid var(--color-background-muted);
+}
+
+.agenda-title, .calendar-title {
+ margin-bottom: 1rem;
+}
+
+.agenda-item:last-child {
+ border-bottom: none;
+}
+
+.page, .calendar {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ line-height: 1.3;
+}
+</style> \ No newline at end of file
diff --git a/web/pages/events.vue b/web/pages/events.vue
new file mode 100644
index 0000000..093e959
--- /dev/null
+++ b/web/pages/events.vue
@@ -0,0 +1,40 @@
+<script setup lang="ts">
+import { Calendar, SquareGanttChart } from 'lucide-vue-next';
+import { useScheduleStore } from '~/stores/schedule';
+
+const scheduleStore = useScheduleStore();
+
+</script>
+
+<template>
+ <Panel title="Events" :icon="SquareGanttChart" v-if="scheduleStore.schedule">
+ <div v-for="[day, events] of Object.entries(scheduleStore.eventsPerDay)" :key="day" class="events-container">
+ <ul class="events-list">
+ <li v-for="event in events" :key="event.id" class="event-item" :data-index="event.id">
+ <EventListing :event="event" />
+ </li>
+ </ul>
+ </div>
+ </Panel>
+</template>
+
+<style>
+.events-container {
+ margin: -1rem 0;
+}
+
+.events-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+}
+
+.event-item {
+ border-bottom: 1px solid var(--color-background-muted);
+}
+
+.event-item:last-child {
+ border-bottom: none;
+}
+</style> \ No newline at end of file
diff --git a/web/pages/index.vue b/web/pages/index.vue
new file mode 100644
index 0000000..c455678
--- /dev/null
+++ b/web/pages/index.vue
@@ -0,0 +1,22 @@
+<script setup lang="ts">
+const scheduleStore = useScheduleStore();
+
+const destination = ref()
+
+if (scheduleStore.isConferenceOngoing()) {
+ destination.value = "/live";
+ navigateTo('/live');
+} else {
+ destination.value = "/events";
+ navigateTo('/events');
+}
+</script>
+
+<template>
+ <Panel kind="success">
+ <span class="text-icon">
+ <Spinner />
+ <span>Successfully logged in. Navigating to {{ destination }}...</span>
+ </span>
+ </Panel>
+</template>
diff --git a/web/pages/live.vue b/web/pages/live.vue
new file mode 100644
index 0000000..d69dce5
--- /dev/null
+++ b/web/pages/live.vue
@@ -0,0 +1,139 @@
+<script setup lang="ts">
+import { Radio } from 'lucide-vue-next';
+import EventListing from '~/components/EventListing.vue';
+import Panel from '~/components/Panel.vue';
+import { type Event } from '~/stores/schedule';
+
+const favouritesStore = useFavouritesStore();
+const scheduleStore = useScheduleStore();
+
+const showAllHappeningNow = ref(false);
+const showAllUpcoming = ref(false);
+const timer = ref();
+const refreshKey = ref(0);
+
+const todayEvents = computed(() => {
+ return scheduleStore.events.filter((event) => isToday(new Date(event.start)));
+});
+const happeningNow = computed(() => {
+ refreshKey.value;
+ return todayEvents.value.filter((event) => isEventHappeningNow(event));
+});
+const favouritesHappeningNow = computed(() => {
+ refreshKey.value;
+ return happeningNow.value.filter((event) => favouritesStore.isFavourite(event));
+});
+const upcomingToday = computed(() => {
+ refreshKey.value;
+ return todayEvents.value.filter((event) => !isEventHappeningNow(event) && isInFuture(event));
+});
+const favouritesUpcomingToday = computed(() => {
+ refreshKey.value;
+ return upcomingToday.value.filter((event) => favouritesStore.isFavourite(event));
+});
+
+onMounted(() => {
+ timer.value = setInterval(() => {
+ refreshKey.value++;
+ }, 1000);
+});
+
+function isEventHappeningNow(event: Event): boolean {
+ const now = new Date();
+ return event.start <= now && event.end >= now;
+}
+
+function isInFuture(event: Event): boolean {
+ return event.start > new Date();
+}
+
+function isToday(date: Date): boolean {
+ const today = new Date();
+ return date.getDate() === today.getDate() &&
+ date.getMonth() === today.getMonth() &&
+ date.getFullYear() === today.getFullYear();
+}
+</script>
+
+<template>
+ <Panel kind="emphasis" class="ongoing" v-if="happeningNow.length > 0" title="Now" :icon="Radio">
+ <ul class="events-list">
+ <li v-for="event in showAllHappeningNow ? happeningNow : favouritesHappeningNow" :key="event.id">
+ <EventListing :event="event" />
+ </li>
+ <span class="other" v-if="!showAllHappeningNow && happeningNow.length != favouritesHappeningNow.length">{{ happeningNow.length }} other events happening right now</span>
+ </ul>
+
+ <template #actions>
+ <div class="actions">
+ <span class="status" v-if="!showAllHappeningNow">Showing only agenda items</span>
+ <Button @click="showAllHappeningNow = !showAllHappeningNow" :kind="showAllHappeningNow ? 'secondary' : 'primary'">
+ {{ showAllHappeningNow ? 'Show only agenda items' : 'Show all' }}
+ </Button>
+ </div>
+ </template>
+ </Panel>
+
+ <Panel class="upcoming" v-if="upcomingToday.length > 0" title="Upcoming">
+ <ul class="events-list">
+ <li v-for="event in showAllUpcoming ? upcomingToday : favouritesUpcomingToday" :key="event.id">
+ <EventListing :event="event" :show-relative-time="true" />
+ </li>
+ <span class="other" v-if="!showAllUpcoming">{{ upcomingToday.length }} other upcoming events today</span>
+ </ul>
+
+ <template #actions>
+ <div class="actions">
+ <span class="status" v-if="!showAllUpcoming">Showing only agenda items</span>
+ <Button @click="showAllUpcoming = !showAllUpcoming" :kind="showAllUpcoming ? 'secondary' : 'primary'">
+ {{ showAllUpcoming ? 'Show only agenda items' : 'Show all' }}
+ </Button>
+ </div>
+ </template>
+ </Panel>
+
+ <Panel kind="error" v-if="todayEvents.length === 0">
+ <span>There are no more events today</span>
+ </Panel>
+
+</template>
+
+<style scoped>
+ul.events-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ margin-top: -1rem;
+}
+
+ul.events-list > .other {
+ font-style: italic;
+}
+
+ul.events-list > li {
+ border-bottom: 1px solid var(--color-hover);
+}
+
+ul.events-list > li:last-of-type {
+ border-bottom: none;
+}
+
+div.actions {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+div.actions > span.status {
+ color: var(--color-text);
+ font-style: italic;
+ font-size: var(--text-small);
+}
+
+@media (max-width: 800px) {
+ div.actions > span.status {
+ display: none;
+ }
+}
+</style> \ No newline at end of file
diff --git a/web/pages/login.vue b/web/pages/login.vue
new file mode 100644
index 0000000..2900a5e
--- /dev/null
+++ b/web/pages/login.vue
@@ -0,0 +1,193 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import { FetchError } from 'ofetch'
+import Input from '~/components/Input.vue'
+
+definePageMeta({
+ layout: 'none'
+})
+
+const isLoading = ref(false)
+const error = ref("")
+
+const config = useRuntimeConfig()
+const headers = useRequestHeaders(['cookie'])
+
+const handleSubmit = async (e: Event) => {
+ const target = e.target as HTMLFormElement;
+ const formData = new FormData(target);
+
+ isLoading.value = true
+ error.value = ""
+
+ try {
+ await $fetch(config.public.baseURL + '/login', {
+ method: 'POST',
+ body: JSON.stringify(Object.fromEntries(formData)),
+ headers: headers,
+ server: false,
+ });
+
+ navigateTo("/");
+ } catch (e: any) {
+ if ((e as FetchError).data) {
+ error.value = e.data.message
+ } else {
+ error.value = "An unknown error occurred"
+ }
+ }
+
+ isLoading.value = false
+}
+
+onMounted(() => {
+ if (history.state.error) {
+ error.value = history.state.error as string
+ }
+})
+
+</script>
+
+<template>
+ <div class="auth-container">
+ <div class="auth-header">
+ <h2 class="auth-title">Sign in</h2>
+
+ <div v-if="error" class="auth-error">
+ {{ error }}
+ </div>
+ </div>
+
+ <div class="auth-body">
+ <Panel>
+ <form class="auth-form" @submit.prevent="handleSubmit">
+ <div class="form-group">
+ <label for="username" class="form-label">
+ Username
+ </label>
+ <div class="form-input-container">
+ <Input id="username" name="username" required />
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label for="password" class="form-label">
+ Password
+ </label>
+ <div class="form-input-container">
+ <Input id="password" name="password" type="password" autocomplete="current-password" required />
+ </div>
+ </div>
+
+
+ <div class="form-submit">
+ <Button type="submit" :loading="isLoading">
+ Sign in
+ </Button>
+ </div>
+
+ <Version class="version" />
+ </form>
+ </Panel>
+
+ </div>
+
+ <div class="form-footer">
+ <NuxtLink to="/register" class="register-link">
+ Register
+ </NuxtLink>
+ </div>
+
+ </div>
+</template>
+
+<style scoped>
+div.auth-container {
+ min-height: 100vh;
+ background-color: var(--color-background-muted);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 1rem;
+}
+
+div.auth-header {
+ margin: 0 auto;
+ width: 100%;
+ max-width: 28rem;
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ flex-direction: column;
+}
+
+h2.auth-title {
+ margin-top: 1.5rem;
+ font-size: 1.875rem;
+ font-weight: 800;
+ color: #1f2937;
+}
+
+div.auth-body {
+ margin-top: 2rem;
+ margin: 0 auto;
+ width: 100%;
+ max-width: 28rem;
+}
+
+form.auth-form {
+ display: grid;
+ gap: 1.5rem;
+}
+
+div.auth-error {
+ color: var(--color-text-error);
+ font-style: oblique;
+}
+
+div.form-group {
+ display: flex;
+ flex-direction: column;
+}
+
+label.form-label {
+ display: block;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #374151;
+}
+
+div.form-input-container {
+ margin-top: 0.25rem;
+}
+
+div.form-footer {
+ display: flex;
+ justify-content: flex-end;
+ margin: 0 auto;
+ max-width: 28rem;
+}
+
+div.form-submit {
+ display: flex;
+}
+
+div.form-submit button {
+ width: 100%;
+}
+
+.version {
+ font-size: var(--text-smaller);
+ margin: 0 auto;
+ color: var(--color-text-muted-light);
+}
+
+.register-link {
+ font-size: var(--text-small);
+ font-weight: 500;
+}
+
+input[name="username"] {
+ text-transform: lowercase;
+}
+</style>
diff --git a/web/pages/register.vue b/web/pages/register.vue
new file mode 100644
index 0000000..33a02a7
--- /dev/null
+++ b/web/pages/register.vue
@@ -0,0 +1,190 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import { FetchError } from 'ofetch'
+import Input from '~/components/Input.vue'
+
+definePageMeta({
+ layout: 'none'
+})
+
+const isLoading = ref(false)
+const error = ref("")
+
+const config = useRuntimeConfig()
+const headers = useRequestHeaders(['cookie'])
+
+const handleSubmit = async (e: Event) => {
+ const target = e.target as HTMLFormElement;
+ const formData = new FormData(target);
+
+ isLoading.value = true
+ error.value = ""
+
+ try {
+ await $fetch(config.public.baseURL + '/register', {
+ method: 'POST',
+ body: JSON.stringify(Object.fromEntries(formData)),
+ headers: headers,
+ server: false,
+ });
+
+ navigateTo("/login");
+ } catch (e: any) {
+ if ((e as FetchError).data) {
+ error.value = e.data.message
+ } else {
+ error.value = "An unknown error occurred"
+ }
+ }
+
+ isLoading.value = false
+}
+
+</script>
+
+<template>
+ <div class="auth-container">
+ <div class="auth-header">
+ <h2 class="auth-title">Register</h2>
+
+ <div v-if="error" class="auth-error">
+ {{ error }}
+ </div>
+ </div>
+
+ <div class="auth-body">
+ <Panel>
+ <form class="auth-form" @submit.prevent="handleSubmit">
+ <div class="form-group">
+ <label for="username" class="form-label">
+ Username
+ </label>
+ <div class="form-input-container">
+ <Input id="username" name="username" required />
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label for="password" class="form-label">
+ Password
+ </label>
+ <div class="form-input-container">
+ <Input id="password" name="password" type="password" autocomplete="current-password" required />
+ </div>
+ </div>
+
+
+ <div class="form-submit">
+ <Button type="submit" :loading="isLoading">
+ Register
+ </Button>
+ </div>
+
+ <Version class="version" />
+ </form>
+ </Panel>
+ </div>
+
+ <div class="form-footer">
+ <NuxtLink to="/login" class="register-link">
+ Sign in
+ </NuxtLink>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+div.auth-container {
+ min-height: 100vh;
+ background-color: var(--color-background-muted);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 1rem;
+}
+
+div.auth-header {
+ margin: 0 auto;
+ width: 100%;
+ max-width: 28rem;
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+ flex-direction: column;
+}
+
+h2.auth-title {
+ margin-top: 1.5rem;
+ font-size: 1.875rem;
+ font-weight: 800;
+ color: #1f2937;
+}
+
+div.auth-body {
+ margin-top: 2rem;
+ margin: 0 auto;
+ width: 100%;
+ max-width: 28rem;
+}
+
+form.auth-form {
+ display: grid;
+ gap: 1.5rem;
+}
+
+div.auth-error {
+ color: var(--color-text-error);
+ font-style: oblique;
+}
+
+div.form-group {
+ display: flex;
+ flex-direction: column;
+}
+
+label.form-label {
+ display: block;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #374151;
+}
+
+div.form-input-container {
+ margin-top: 0.25rem;
+}
+
+div.form-footer {
+ display: flex;
+ justify-content: flex-end;
+ margin: 0 auto;
+ max-width: 28rem;
+}
+
+div.form-submit {
+ display: flex;
+}
+
+div.form-submit button {
+ width: 100%;
+}
+
+.register-link {
+ font-size: var(--text-small);
+ font-weight: 500;
+}
+
+.auth-error {
+ color: var(--color-text-error);
+ font-style: oblique;
+}
+
+.version {
+ font-size: var(--text-smaller);
+ margin: 0 auto;
+ color: var(--color-text-muted-light);
+}
+
+input[name="username"] {
+ text-transform: lowercase;
+}
+</style>
diff --git a/web/pages/tracks/[slug].vue b/web/pages/tracks/[slug].vue
new file mode 100644
index 0000000..27fb97d
--- /dev/null
+++ b/web/pages/tracks/[slug].vue
@@ -0,0 +1,45 @@
+<script setup lang="ts">
+import { TrainTrack } from 'lucide-vue-next';
+import { useScheduleStore } from '~/stores/schedule';
+
+const route = useRoute();
+const scheduleStore = useScheduleStore();
+
+const track = scheduleStore.schedule?.tracks.find((track) => track.slug === route.params.slug);
+</script>
+
+<template>
+ <Panel v-if="track" :title="track.name" :breadcrumbs="[{ text: 'Tracks', to: '/tracks' }]" :icon="TrainTrack">
+ <ul class="events-list">
+ <li
+ v-for="event in scheduleStore.eventsPerTrack[track.name]"
+ :key="event.id"
+ class="event-item"
+ >
+ <EventListing :event="event" />
+ </li>
+ </ul>
+ </Panel>
+</template>
+
+<style>
+.events-list {
+ list-style: none;
+ margin: -1rem 0;
+ padding: 0;
+ display: grid;
+}
+
+.event-item {
+ border-bottom: 1px solid var(--color-background-muted);
+}
+
+.events-title {
+ margin-bottom: 1rem;
+}
+
+.event-item:last-child {
+ border-bottom: none;
+}
+
+</style> \ No newline at end of file
diff --git a/web/pages/tracks/index.vue b/web/pages/tracks/index.vue
new file mode 100644
index 0000000..8d7534e
--- /dev/null
+++ b/web/pages/tracks/index.vue
@@ -0,0 +1,49 @@
+<script setup lang="ts">
+import { TrainTrack } from 'lucide-vue-next';
+import Panel from '~/components/Panel.vue';
+
+const scheduleStore = useScheduleStore();
+</script>
+
+<template>
+ <Panel v-if="scheduleStore.schedule" title="Tracks" :icon="TrainTrack">
+ <ul class="tracks-list">
+ <li
+ v-for="track in scheduleStore.schedule.tracks"
+ :key="track.name"
+ class="tracks-item"
+ >
+ <NuxtLink :to="'/tracks/' + track.slug" class="track-item">
+ {{ track.name }}
+ </NuxtLink>
+ </li>
+ </ul>
+ </Panel>
+</template>
+
+<style scoped>
+.tracks-list {
+ list-style: none;
+ margin: -0.5rem 0 0 0;
+ padding: 0;
+ display: grid;
+}
+
+.track-item {
+ position: relative;
+ border-bottom: 1px solid var(--color-background-muted);
+ padding: 0.5rem 1rem;
+ left: -1rem;
+ width: calc(100%);
+ display: block;
+ text-decoration: none;
+}
+
+.track-item:last-child {
+ border-bottom: none;
+}
+
+.track-item:hover {
+ background-color: var(--color-background-muted);
+}
+</style> \ No newline at end of file