diff options
Diffstat (limited to 'web/pages')
| -rw-r--r-- | web/pages/agenda.vue | 152 | ||||
| -rw-r--r-- | web/pages/events.vue | 40 | ||||
| -rw-r--r-- | web/pages/index.vue | 22 | ||||
| -rw-r--r-- | web/pages/live.vue | 139 | ||||
| -rw-r--r-- | web/pages/login.vue | 193 | ||||
| -rw-r--r-- | web/pages/register.vue | 190 | ||||
| -rw-r--r-- | web/pages/tracks/[slug].vue | 45 | ||||
| -rw-r--r-- | web/pages/tracks/index.vue | 49 |
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 |
