diff options
Diffstat (limited to 'web')
34 files changed, 602 insertions, 213 deletions
diff --git a/web/assets/css/main.css b/web/assets/css/main.css index 320bedb..5b88e8c 100644 --- a/web/assets/css/main.css +++ b/web/assets/css/main.css @@ -99,4 +99,48 @@ span.text-icon { display: flex; align-items: center; gap: 0.4em; +} + +form { + display: grid; + gap: 1.5rem; +} + +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-submit { + display: flex; + justify-content: flex-end; +} + +.loading-text { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + font-size: var(--text-normal); + color: var(--color-text-muted); +} + +table { + width: 100%; +} + +th { + font-weight: bold; + text-align: left; }
\ No newline at end of file diff --git a/web/components/AddConference.vue b/web/components/AddConference.vue new file mode 100644 index 0000000..95154d4 --- /dev/null +++ b/web/components/AddConference.vue @@ -0,0 +1,54 @@ +<script setup lang="ts"> +import { format } from 'date-fns'; +import { type Event as ScheduledEvent } from '~/stores/schedule'; + +const errorStore = useErrorStore(); +const config = useRuntimeConfig(); +const loading = ref(false) + +const emit = defineEmits(['update']); + +const addConference = async (e: Event) => { + const target = e.target as HTMLFormElement; + const formData = new FormData(target); + loading.value = true + + $api(config.public.baseURL + '/conference', { + method: 'POST', + body: JSON.stringify(Object.fromEntries(formData)), + onResponse: ({ response }) => { + loading.value = false + if (!response.ok) { + errorStore.setError(response._data?.message || 'An unknown error occurred'); + return + } + emit('update') + }, + }); +} + +</script> + +<template> + <div> + <form @submit.prevent="(e) => addConference(e)"> + <div class="form-group"> + <label for="url" class="form-label"> + Schedule data URL + </label> + <div class="form-input-container"> + <Input id="url" name="url" required /> + </div> + </div> + + <div class="form-submit"> + <Button type="submit" :loading="loading"> + Add + </Button> + </div> + </form> + </div> +</template> + +<style scoped> +</style>
\ No newline at end of file diff --git a/web/components/Button.vue b/web/components/Button.vue index ce9eefc..27cfa4e 100644 --- a/web/components/Button.vue +++ b/web/components/Button.vue @@ -48,7 +48,7 @@ button { gap: 0.4rem; padding: 0.5rem 1rem; border: 1px solid transparent; - border-radius: 0.375rem; + border-radius: 2px; font-size: var(--text-small); font-family: var(--font-family); font-weight: 500; diff --git a/web/components/Dialog.vue b/web/components/Dialog.vue index 7772f23..04e1461 100644 --- a/web/components/Dialog.vue +++ b/web/components/Dialog.vue @@ -72,7 +72,7 @@ const onDialogClick = (e: MouseEvent) => { <style scoped> dialog { outline: none; - border-radius: 0.5rem; + border-radius: 2px; padding: 1rem; width: 1000px; margin: 0; diff --git a/web/components/EventDetail.vue b/web/components/EventDetail.vue index b4f7bd9..8fd5b03 100644 --- a/web/components/EventDetail.vue +++ b/web/components/EventDetail.vue @@ -43,7 +43,7 @@ const getHostname = (url: string) => new URL(url).hostname; </div> <div> - <span class="event-track"><NuxtLink :to="'/tracks/' + event.track.slug">{{ event.track.name }}</NuxtLink> •</span> <a v-if="event.url" class="event-url" :href="event.url" target="_blank">view on {{ getHostname(event.url)}}</a> + <span v-if="event.track" class="event-track"><NuxtLink :to="'/tracks/' + event.track.slug">{{ event.track.name }}</NuxtLink> •</span> <a v-if="event.url" class="event-url" :href="event.url" target="_blank">view on {{ getHostname(event.url)}}</a> </div> </div> </template> diff --git a/web/components/EventListing.vue b/web/components/EventListing.vue index 5c04189..0cc546c 100644 --- a/web/components/EventListing.vue +++ b/web/components/EventListing.vue @@ -10,6 +10,7 @@ const { event, showRelativeTime } = defineProps<{ }>(); const selectedEventStore = useSelectedEventStore(); +const conferenceStore = useConferenceStore(); const favouritesStore = useFavouritesStore(); const errorStore = useErrorStore(); const config = useRuntimeConfig(); @@ -43,9 +44,10 @@ const addFavourite = async () => { addingToFavourite.value = true; try { - const res = await $fetch(config.public.baseURL + '/favourites', { + const res = await $api(config.public.baseURL + '/favourites', { method: 'POST', body: JSON.stringify({ + conferenceId: conferenceStore.id, eventGuid: event.guid, eventId: event.id, }), @@ -70,7 +72,7 @@ const removeFavourite = async () => { addingToFavourite.value = true; try { - await $fetch(config.public.baseURL + '/favourites', { + await $api(config.public.baseURL + '/favourites', { method: 'DELETE', body: JSON.stringify({ eventGuid: event.guid, @@ -102,9 +104,9 @@ const removeFavourite = async () => { </span> <span class="event-title">{{ event.title }}</span> <span class="event-speaker">{{ event.persons.map(p => p.name).join(", ") }}</span> - <span class="event-track">{{ event.track.name }}</span> + <span class="event-track">{{ event.track?.name }}</span> </div> - <template v-if="!addingToFavourite" class="event-button"> + <template v-if="!addingToFavourite && favouritesStore.status !== 'pending'" class="event-button"> <StarIcon v-if="favouritesStore.isFavourite(event)" color="var(--color-favourite)" fill="var(--color-favourite)" class="event-button" @click="removeFavourite" /> <StarIcon v-else color="var(--color-text-muted)" class="event-button" @click="addFavourite" /> </template> diff --git a/web/components/Input.vue b/web/components/Input.vue index b541566..aebece6 100644 --- a/web/components/Input.vue +++ b/web/components/Input.vue @@ -82,7 +82,7 @@ input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; - border-radius: 0.375rem; + border-radius: 2px; box-sizing: border-box; /* box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); */ font-family: var(--font-family); diff --git a/web/components/Panel.vue b/web/components/Panel.vue index 1f2d22e..d417691 100644 --- a/web/components/Panel.vue +++ b/web/components/Panel.vue @@ -4,7 +4,7 @@ import { defineProps, type FunctionalComponent, type PropType } from 'vue'; defineProps({ kind: { - type: String as PropType<"normal" | "error" | "success" | "emphasis">, + type: String as PropType<"normal" | "top" | "error" | "success" | "emphasis">, required: false, default: 'normal', }, @@ -47,14 +47,11 @@ defineProps({ <style> div.card { padding: 1rem; - border-radius: 0.5rem; } div.card-header { padding: 1rem 1rem; margin: -1rem -1rem 0 -1rem; - border-top-left-radius: 0.5rem; - border-top-right-radius: 0.5rem;; display: flex; justify-content: space-between; align-items: center; @@ -73,7 +70,7 @@ div.header-left > svg { } span.card-title { - font-size: 1.5rem; + font-size: 1.3rem; font-weight: 700; } @@ -99,17 +96,21 @@ span.breadcrumb > a:hover { color: var(--color-primary); } -div.normal { +div.normal, div.top { background-color: white; border: 0.1rem solid var(--color-border); } -div.normal .card-header { +div.normal .card-header, div.top .card-header { background-color: var(--color-background-muted); border-bottom: 1px solid var(--color-border); margin: -1rem -1rem 1rem -1rem; } +div.top { + border-top: 3px solid var(--color-primary) +} + div.error { background-color: var(--color-error-light); border: 0.1rem solid var(--color-border-error-light); diff --git a/web/components/Sidebar.vue b/web/components/Sidebar.vue index 5fc42d3..fd64434 100644 --- a/web/components/Sidebar.vue +++ b/web/components/Sidebar.vue @@ -3,6 +3,7 @@ import { formatDistanceToNow } from "date-fns"; import { LucideClock, LucideRadio } from "lucide-vue-next"; const scheduleStore = useScheduleStore(); +const conferenceStore = useConferenceStore(); const errorStore = useErrorStore(); const timer = ref(); @@ -30,31 +31,35 @@ onBeforeUnmount(() => { <template> <div class="sidebar"> - <Panel class="conference"> - <span class="conference-title">{{ scheduleStore.schedule?.conference.title }}</span> - <span class="conference-venue">{{ scheduleStore.schedule?.conference.venue }}</span> - <span class="conference-city">{{ scheduleStore.schedule?.conference.city }}</span> + <Panel class="conference" kind="top"> + <template v-if="conferenceStore.id && conferenceStore.title"> + <span class="conference-title">{{ conferenceStore.title }}</span> + <span class="conference-venue">{{ conferenceStore.venue }}</span> + <span class="conference-city">{{ conferenceStore.city }}</span> + </template> - <Button kind="secondary" @click="errorStore.setError('This doesn\'t do anything yet :-)')">Change conference</Button> + <Button kind="secondary" @click="navigateTo('/conferences')">Change conference</Button> </Panel> - <Panel kind="success" class="ongoing" v-if="ongoing"> - <span>This conference is ongoing</span> - <Button kind="primary" :icon="LucideRadio" @click="navigateTo('/live')">View live</Button> - </Panel> + <template v-if="scheduleStore.schedule != null"> + <Panel kind="success" class="ongoing" v-if="ongoing"> + <span>This conference is ongoing</span> + <Button kind="primary" :icon="LucideRadio" @click="navigateTo('/live')">View live</Button> + </Panel> - <Panel kind="error" class="finished" v-else-if="finished"> - <span>This conference has finished</span> - </Panel> - - <Panel class="upcoming" v-else> - <span class="text-icon"><LucideClock /> <span>Starts in {{ startsIn }}</span></span> - </Panel> - - <Nav /> + <Panel kind="error" class="finished" v-else-if="finished"> + <span>This conference has finished</span> + </Panel> + + <Panel class="upcoming" v-else> + <span class="text-icon"><LucideClock /> <span>Starts in {{ startsIn }}</span></span> + </Panel> + + <Nav /> + </template> <div class="info"> - <span>Times listed are in local time ({{ scheduleStore.schedule?.conference.timeZoneName }})</span> + <span v-if="scheduleStore.schedule != null">Times listed are in local time ({{ scheduleStore.schedule?.conference.timeZoneName }})</span> <Version /> </div> @@ -74,7 +79,7 @@ onBeforeUnmount(() => { align-items: center; gap: 1rem; font-size: var(--text-small); - font-style: oblique; + font-style: italic; text-align: center; } diff --git a/web/composables/api.ts b/web/composables/api.ts new file mode 100644 index 0000000..7881aff --- /dev/null +++ b/web/composables/api.ts @@ -0,0 +1,14 @@ +export function $api<T>( + request: Parameters<typeof $fetch<T>>[0], + opts?: Parameters<typeof $fetch<T>>[1], +) { + const authStore = useAuthStore() + + return $fetch<T>(request, { + ...opts, + headers: { + Authorization: authStore.isLoggedIn() ? `Bearer ${authStore.token}` : '', + ...opts?.headers, + }, + }) +} diff --git a/web/composables/expire-auth.ts b/web/composables/expire-auth.ts new file mode 100644 index 0000000..b0e277e --- /dev/null +++ b/web/composables/expire-auth.ts @@ -0,0 +1,8 @@ +export function expireAuth() { + const authStore = useAuthStore() + + authStore.admin = false; + authStore.username = null; + authStore.token = null; + navigateTo({ path: '/login', state: { error: 'Sorry, your session has expired' } }); +}
\ No newline at end of file diff --git a/web/composables/fetch-favourites.ts b/web/composables/fetch-favourites.ts index e586d5b..965120d 100644 --- a/web/composables/fetch-favourites.ts +++ b/web/composables/fetch-favourites.ts @@ -1,23 +1,21 @@ export default function() { - const favouritesStore = useFavouritesStore(); - const errorStore = useErrorStore(); - const config = useRuntimeConfig(); - - favouritesStore.setStatus('pending') + const conferenceStore = useConferenceStore() + const favouritesStore = useFavouritesStore(); + const errorStore = useErrorStore(); + const config = useRuntimeConfig(); + + favouritesStore.status = 'pending' - useFetch(config.public.baseURL + '/favourites', { - method: 'GET', - server: false, - lazy: true, - onResponseError: ({ response }) => { - favouritesStore.setStatus('idle') + return $api(config.public.baseURL + '/favourites/' + conferenceStore.id, { + method: 'GET', + onResponse: ({ response }) => { + favouritesStore.status = 'idle' + if (!response.ok) { errorStore.setError(response._data.message || 'An unknown error occurred'); - }, - onResponse: ({ response }) => { - if (response._data) { - favouritesStore.setFavourites((response._data as any).data); - } - favouritesStore.setStatus('idle') - }, - }); + } + if (response._data) { + favouritesStore.setFavourites((response._data as any).data); + } + }, + }); }
\ No newline at end of file diff --git a/web/composables/fetch-login.ts b/web/composables/fetch-login.ts index 707a04f..b0b04ba 100644 --- a/web/composables/fetch-login.ts +++ b/web/composables/fetch-login.ts @@ -5,7 +5,7 @@ export default function() { const errorStore = useErrorStore(); const config = useRuntimeConfig(); - loginOptionsStore.setStatus('pending') + loginOptionsStore.status = 'pending' $fetch(config.public.baseURL + '/login', { method: 'GET', @@ -17,8 +17,8 @@ export default function() { } if (response._data) { - loginOptionsStore.setLoginOptions((response._data as any).data.options); - loginOptionsStore.setStatus('idle') + loginOptionsStore.loginOptions = (response._data as any).data.options; + loginOptionsStore.status = 'idle' } }, }); diff --git a/web/composables/fetch-schedule.ts b/web/composables/fetch-schedule.ts index a0e6fec..5e91954 100644 --- a/web/composables/fetch-schedule.ts +++ b/web/composables/fetch-schedule.ts @@ -1,24 +1,39 @@ -export default function() { - const scheduleStore = useScheduleStore(); - const errorStore = useErrorStore(); - const config = useRuntimeConfig(); - - useFetch(config.public.baseURL + '/schedule', { - method: 'GET', - server: false, - lazy: true, - onResponse: ({ response }) => { - if (!response.ok) { - if (response.status === 401) { - navigateTo({ path: '/login', state: { error: 'Sorry, your session has expired' } }); - } else { - errorStore.setError(response._data.message || 'An unknown error occurred'); - } - } +import { useConferenceStore } from "~/stores/conference"; +import { expireAuth } from "./expire-auth"; + +export default async function() { + const conferenceStore = useConferenceStore() + const scheduleStore = useScheduleStore(); + const errorStore = useErrorStore(); + const config = useRuntimeConfig(); - if (response._data) { - scheduleStore.setSchedule((response._data as any).data.schedule); + scheduleStore.status = 'pending' + + return $api(config.public.baseURL + '/conference/' + conferenceStore.id, { + method: 'GET', + onResponse: ({ response }) => { + if (!response.ok) { + if (response.status === 401) { + expireAuth() + return + } else { + errorStore.setError(response._data.message || 'An unknown error occurred'); } - }, - }); + } + + if (response._data) { + let schedule = (response._data as any).data.schedule + scheduleStore.setSchedule(schedule); + + conferenceStore.venue = schedule.conference.venue + conferenceStore.title = schedule.conference.title + conferenceStore.city = schedule.conference.city + + scheduleStore.status = 'idle' + } + }, + }).catch(() => { + // todo do this better + errorStore.setError('An unknown error occurred'); + }); }
\ No newline at end of file diff --git a/web/composables/logout.ts b/web/composables/logout.ts new file mode 100644 index 0000000..35b0511 --- /dev/null +++ b/web/composables/logout.ts @@ -0,0 +1,15 @@ +export function logout() { + const authStore = useAuthStore() + const conferenceStore = useConferenceStore() + const config = useRuntimeConfig(); + + $api(config.public.baseURL + '/logout', { method: 'POST' }).finally(() => { + authStore.admin = false; + authStore.username = null; + authStore.token = null; + + conferenceStore.clear() + + navigateTo({ path: '/login', state: { error: 'You have logged out' } }); + }) +}
\ No newline at end of file diff --git a/web/layouts/default.vue b/web/layouts/default.vue index 2baf5f1..5f85c38 100644 --- a/web/layouts/default.vue +++ b/web/layouts/default.vue @@ -8,7 +8,7 @@ definePageMeta({ middleware: ["logged-in"] }) -const scheduleStore = useScheduleStore(); +const authStore = useAuthStore(); const selectedEventStore = useSelectedEventStore(); const errorStore = useErrorStore(); const router = useRouter(); @@ -21,9 +21,6 @@ const refErrorDialog = ref<typeof Dialog>(); const showHamburger = ref(false); -fetchSchedule(); -fetchFavourites(); - watch(selectedEvent, () => { if (selectedEvent.value != null) { refSelectedDialog.value?.show(); @@ -52,6 +49,7 @@ router.afterEach(() => { <header> <div class="planner-header"> <span class="text-icon planner-title" @click="navigateTo('/')"><BookHeart /> confplanner</span> + <NuxtLink class="logout logout-header" @click="logout">Log out {{ authStore.username }} {{ authStore.admin ? '(admin)' : ''}}</NuxtLink> <span class="hamburger" @click="showHamburger = !showHamburger"> <LucideMenu :size="24" v-if="!showHamburger"/> <LucideX :size="24" v-else /> @@ -59,25 +57,20 @@ router.afterEach(() => { </div> <div class="hamburger-content" v-if="showHamburger"> <Sidebar /> + + <div class="logout-hamburger"> + <NuxtLink class="logout" @click="logout">Log out {{ authStore.username }} {{ authStore.admin ? '(admin)' : ''}}</NuxtLink> + </div> </div> </header> <div class="planner-layout"> - <template v-if="scheduleStore.schedule"> - <aside class="planner-sidebar"> - <Sidebar /> - </aside> + <aside class="planner-sidebar"> + <Sidebar /> + </aside> - <main class="planner-content"> - <slot /> - </main> - </template> - <template v-else> - <div class="loading"> - <span class="loading-text"> - <Spinner color="var(--color-text-muted)" />Updating schedule... - </span> - </div> - </template> + <main class="planner-content"> + <slot /> + </main> </div> </div> @@ -109,7 +102,8 @@ header { div.planner-header { background-color: var(--color-background-muted); color: var(--color-text-muted); - border-bottom: 2px solid var(--color-border); + border-top: 3px solid var(--color-primary); + border-bottom: 1px solid var(--color-border); height: 3.5rem; display: flex; justify-content: space-between; @@ -164,6 +158,15 @@ aside.planner-sidebar { font-size: var(--text-normal); color: var(--color-text-muted); } + +.logout { + cursor: pointer; +} + +.logout-hamburger { + margin-top: 0.5rem; + text-align: right; +} .loading { margin-top: 1rem; @@ -184,6 +187,10 @@ aside.planner-sidebar { flex-direction: column; padding: 0.5rem; } + + .logout-header { + display: none; + } .hamburger { display: block; diff --git a/web/middleware/conference-selected.ts b/web/middleware/conference-selected.ts new file mode 100644 index 0000000..c6415bb --- /dev/null +++ b/web/middleware/conference-selected.ts @@ -0,0 +1,15 @@ +import { useConferenceStore } from "~/stores/conference"; + +const conferenceStore = useConferenceStore(); +const scheduleStore = useScheduleStore() + +export default defineNuxtRouteMiddleware((to, from) => { + if (conferenceStore.id === null) { + return navigateTo("/conferences"); + } + + if (scheduleStore.schedule === null) { + fetchSchedule(); + fetchFavourites(); + } +});
\ No newline at end of file diff --git a/web/middleware/logged-in.ts b/web/middleware/logged-in.ts index 1ddd3ce..97db606 100644 --- a/web/middleware/logged-in.ts +++ b/web/middleware/logged-in.ts @@ -1,21 +1,8 @@ +const authStore = useAuthStore() + export default defineNuxtRouteMiddleware((to, from) => { - if ("" === getCookie("fosdem_planner_session")) { + if (!authStore.isLoggedIn()) { return navigateTo("/login"); } }); -function getCookie(cname: string) { - let name = cname + "="; - let decodedCookie = decodeURIComponent(document.cookie); - let ca = decodedCookie.split(";"); - for (let i = 0; i < ca.length; i++) { - let c = ca[i]; - while (c.charAt(0) == " ") { - c = c.substring(1); - } - if (c.indexOf(name) == 0) { - return c.substring(name.length, c.length); - } - } - return ""; -} diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index d5762e7..1dfc34e 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -14,7 +14,7 @@ try { export default defineNuxtConfig({ compatibilityDate: "2024-11-01", - devtools: { enabled: true }, + devtools: { enabled: false }, ssr: false, css: ["~/assets/css/main.css"], diff --git a/web/package-lock.json b/web/package-lock.json index e1b6ef0..d034eec 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,6 +10,7 @@ "@date-fns/tz": "^1.2.0", "@pinia/nuxt": "^0.9.0", "@vite-pwa/nuxt": "^0.10.6", + "@vueuse/core": "^13.7.0", "date-fns": "^4.1.0", "lucide-vue-next": "^0.471.0", "nuxt": "^3.15.1", @@ -3339,6 +3340,11 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==" + }, "node_modules/@unhead/dom": { "version": "1.11.18", "resolved": "https://registry.npmjs.org/@unhead/dom/-/dom-1.11.18.tgz", @@ -3708,6 +3714,41 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==" }, + "node_modules/@vueuse/core": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.7.0.tgz", + "integrity": "sha512-myagn09+c6BmS6yHc1gTwwsdZilAovHslMjyykmZH3JNyzI5HoWhv114IIdytXiPipdHJ2gDUx0PB93jRduJYg==", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "13.7.0", + "@vueuse/shared": "13.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.7.0.tgz", + "integrity": "sha512-8okFhS/1ite8EwUdZZfvTYowNTfXmVCOrBFlA31O0HD8HKXhY+WtTRyF0LwbpJfoFPc+s9anNJIXMVrvP7UTZg==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.7.0.tgz", + "integrity": "sha512-Wi2LpJi4UA9kM0OZ0FCZslACp92HlVNw1KPaDY6RAzvQ+J1s7seOtcOpmkfbD5aBSmMn9NvOakc8ZxMxmDXTIg==", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", diff --git a/web/package.json b/web/package.json index 50c00ed..a2378f6 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ "@date-fns/tz": "^1.2.0", "@pinia/nuxt": "^0.9.0", "@vite-pwa/nuxt": "^0.10.6", + "@vueuse/core": "^13.7.0", "date-fns": "^4.1.0", "lucide-vue-next": "^0.471.0", "nuxt": "^3.15.1", diff --git a/web/pages/agenda.vue b/web/pages/agenda.vue index 9b55c9b..5126d78 100644 --- a/web/pages/agenda.vue +++ b/web/pages/agenda.vue @@ -8,6 +8,10 @@ const scheduleStore = useScheduleStore(); const errorStore = useErrorStore(); const config = useRuntimeConfig(); +definePageMeta({ + middleware: ['logged-in', 'conference-selected'] +}) + const favouriteEvents = computed(() => { return scheduleStore.events.filter((event) => favouritesStore.isFavourite(event)); }); @@ -19,44 +23,28 @@ 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', { + $api(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 }) => { + calendarAction.value = false; + if (!response.ok) { + errorStore.setError(response._data.message || 'An unknown error occurred'); + return + } if (response._data) { calendarLink.value = (response._data as any).data.url; } - calendarAction.value = false; }, }); } function deleteCalendar() { calendarAction.value = true; - useFetch(config.public.baseURL + '/calendar', { + $api(config.public.baseURL + '/calendar', { method: 'DELETE', server: false, onResponseError: ({ response }) => { @@ -70,6 +58,24 @@ function deleteCalendar() { }); } +onMounted(() => { + $api(config.public.baseURL + '/calendar', { + method: 'GET', + server: false, + lazy: true, + onResponse: ({ response }) => { + calendarStatus.value = 'idle'; + if (!response.ok) { + if (response.status !== 404) { + errorStore.setError(response._data.message || 'Could not fetch calendar'); + } + } else if (response._data) { + calendarLink.value = (response._data as any).data.url; + } + }, + }); +}) + </script> <template> diff --git a/web/pages/conferences.vue b/web/pages/conferences.vue new file mode 100644 index 0000000..993bc66 --- /dev/null +++ b/web/pages/conferences.vue @@ -0,0 +1,124 @@ +<script setup lang="ts"> +import { MapPinPlus, MapPin } from 'lucide-vue-next'; +import { expireAuth } from '~/composables/expire-auth'; + +definePageMeta({ + middleware: ['logged-in'] +}) + +interface Conference { + id: number, + url: string, + title: string, + venue: string, + city: string, +} + +const setting = ref(false) +const conferences = ref([] as Conference[]) + +const conferenceStore = useConferenceStore(); +const errorStore = useErrorStore(); +const authStore = useAuthStore(); +const config = useRuntimeConfig(); + +const status = ref('idle' as 'loading' | 'idle') + +const fetchConferences = () => { + status.value = 'loading' + $api(config.public.baseURL + '/conference', { + method: 'GET', + onResponse: ({ response }) => { + if (!response.ok) { + if (response.status === 401) { + expireAuth() + return + } + errorStore.setError(response._data.message || 'An unknown error occurred'); + } + status.value = 'idle' + conferences.value = response._data.data + }, + }) +} + +const deleteConference = (id: number) => { + // todo make this better + $api(config.public.baseURL + '/conference', { + method: 'DELETE', + body: { + id: id + }, + onResponse: ({ response }) => { + if (!response.ok) { + errorStore.setError(response._data.message || 'An unknown error occurred'); + } + }, + }) +} + +const selectConference = async (c) => { + setting.value = true + conferenceStore.id = c.id + try { + await fetchSchedule() + await fetchFavourites() + navigateTo({ path: "/events" }) + } catch (e) { + conferenceStore.clear() + setting.value = false + } +} + +onMounted(fetchConferences) +</script> + +<template> + <template v-if="!setting"> + <Panel title="Conferences" :icon="MapPin"> + <span class="loading-text" v-if="status === 'loading'"><Spinner color="var(--color-text-muted)" />Fetching conferences...</span> + <div class="conference-list" v-if="conferences.length > 0 && status !== 'loading'"> + <template v-for="conference of conferences"> + <span class="title">{{ conference.title }}</span> + <span>{{ conference.city }}</span> + <span>{{ conference.venue }}</span> + <span class="actions"> + <Button v-if="authStore.admin" kind="secondary" @click="() => { deleteConference(conference.id) }">Delete</Button> + <Button @click="() => { selectConference(conference) }">Select</Button> + </span> + </template> + </div> + <p v-if="conferences.length == 0 && status !== 'loading'"> + There are no conferences to display. + </p> + </Panel> + + <Panel v-if="authStore.admin" title="Add conference" :icon="MapPinPlus"> + <AddConference @update="fetchConferences" /> + </Panel> + </template> + <template v-else> + <div class="loading"> + <span class="loading-text"><Spinner color="var(--color-text-muted)" />Setting conference...</span> + </div> + </template> +</template> + +<style> +.conference-list { + display: grid; + grid-template-columns: 1fr 1fr 2fr 1fr; + align-items: center; + gap: 0.5rem; +} + +.conference-list > .title { + font-weight: bold; +} + +.conference-list > .actions { + display: flex; + gap: 0.5rem; + justify-self: flex-end; +} +</style>
\ No newline at end of file diff --git a/web/pages/events.vue b/web/pages/events.vue index 093e959..d369bdc 100644 --- a/web/pages/events.vue +++ b/web/pages/events.vue @@ -2,12 +2,21 @@ import { Calendar, SquareGanttChart } from 'lucide-vue-next'; import { useScheduleStore } from '~/stores/schedule'; +definePageMeta({ + middleware: ['logged-in', 'conference-selected'] +}) + const scheduleStore = useScheduleStore(); </script> <template> - <Panel title="Events" :icon="SquareGanttChart" v-if="scheduleStore.schedule"> + <div v-if="scheduleStore.status === 'pending'" class="loading"> + <span class="loading-text"> + <Spinner color="var(--color-text-muted)" />Updating schedule... + </span> + </div> + <Panel title="Events" :icon="SquareGanttChart" v-else> <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"> diff --git a/web/pages/index.vue b/web/pages/index.vue index c455678..e662bc7 100644 --- a/web/pages/index.vue +++ b/web/pages/index.vue @@ -1,4 +1,8 @@ <script setup lang="ts"> +definePageMeta({ + middleware: ['logged-in', 'conference-selected'] +}) + const scheduleStore = useScheduleStore(); const destination = ref() @@ -13,6 +17,11 @@ if (scheduleStore.isConferenceOngoing()) { </script> <template> + <div v-if="scheduleStore.status === 'pending'" class="loading"> + <span class="loading-text"> + <Spinner color="var(--color-text-muted)" />Updating schedule... + </span> + </div> <Panel kind="success"> <span class="text-icon"> <Spinner /> diff --git a/web/pages/live.vue b/web/pages/live.vue index d69dce5..638910e 100644 --- a/web/pages/live.vue +++ b/web/pages/live.vue @@ -4,6 +4,10 @@ import EventListing from '~/components/EventListing.vue'; import Panel from '~/components/Panel.vue'; import { type Event } from '~/stores/schedule'; +definePageMeta({ + middleware: ['logged-in', 'conference-selected'] +}) + const favouritesStore = useFavouritesStore(); const scheduleStore = useScheduleStore(); @@ -56,6 +60,11 @@ function isToday(date: Date): boolean { </script> <template> + <div v-if="scheduleStore.status === 'pending'" class="loading"> + <span class="loading-text"> + <Spinner color="var(--color-text-muted)" />Updating schedule... + </span> + </div> <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"> diff --git a/web/pages/login/[[provider]].vue b/web/pages/login/[[provider]].vue index bfc7e69..58cf2b1 100644 --- a/web/pages/login/[[provider]].vue +++ b/web/pages/login/[[provider]].vue @@ -16,6 +16,7 @@ const basicAuthEnabled = ref(false) const route = useRoute() const config = useRuntimeConfig() +const authStore = useAuthStore() const loginOptionsStore = useLoginOptionsStore() const headers = useRequestHeaders(['cookie']) @@ -25,6 +26,17 @@ watch(loginOptions, (options) => { basicAuthEnabled.value = options.some(o => o.type === 'basic') }) +const authFail = (e: any) => { + if ((e as FetchError).data) { + error.value = e.data.message + } else { + error.value = "An unknown error occurred" + } + + authenticating.value = false + authenticatingProvider.value = '' +} + const handleBasicAuth = async (e: Event, providerName: string) => { const target = e.target as HTMLFormElement; const formData = new FormData(target); @@ -32,48 +44,43 @@ const handleBasicAuth = async (e: Event, providerName: string) => { authenticating.value = true authenticatingProvider.value = providerName - try { - await $fetch(config.public.baseURL + '/login/' + providerName, { - 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" - } + $fetch(config.public.baseURL + '/login/' + providerName, { + method: 'POST', + body: JSON.stringify(Object.fromEntries(formData)), + headers: headers, + server: false, + onResponse: ({ response }) => { + authStore.token = response._data.data.token + authStore.username = response._data.data.username + authStore.admin = response._data.data.admin - authenticating.value = false - authenticatingProvider.value = '' - } + navigateTo("/"); + }, + onResponseError: authFail + }); + } const handleOIDCAuth = async (providerName: string) => { authenticating.value = true authenticatingProvider.value = providerName - try { - let response: any = await $fetch(config.public.baseURL + '/login/' + providerName, { - method: 'POST', - headers: headers, - server: false, - }); - navigateTo(response.data.url, { external: true }) - } catch (e: any) { - if ((e as FetchError).data) { - error.value = e.data.message - } else { - error.value = "An unknown error occurred" - } + $fetch(config.public.baseURL + '/login/' + providerName, { + method: 'POST', + headers: headers, + server: false, + onResponse: ({ response }) => { + if (response._data.data.url) { + navigateTo(response._data.data.url, { external: true }) + } else { + authStore.token = response._data.data.token + authStore.admin = response._data.data.admin - authenticating.value = false - authenticatingProvider.value = '' - } + navigateTo("/"); + } + }, + onResponseError: authFail + }); } onMounted(async () => { @@ -230,30 +237,9 @@ div.auth-form { gap: 1.5rem; } -form.basic-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; + font-style: italic; } div.form-footer { diff --git a/web/pages/tracks/[slug].vue b/web/pages/tracks/[slug].vue index 27fb97d..9e1881d 100644 --- a/web/pages/tracks/[slug].vue +++ b/web/pages/tracks/[slug].vue @@ -2,6 +2,10 @@ import { TrainTrack } from 'lucide-vue-next'; import { useScheduleStore } from '~/stores/schedule'; +definePageMeta({ + middleware: ['logged-in', 'conference-selected'] +}) + const route = useRoute(); const scheduleStore = useScheduleStore(); @@ -9,6 +13,11 @@ const track = scheduleStore.schedule?.tracks.find((track) => track.slug === rout </script> <template> + <div v-if="scheduleStore.status === 'pending'" class="loading"> + <span class="loading-text"> + <Spinner color="var(--color-text-muted)" />Updating schedule... + </span> + </div> <Panel v-if="track" :title="track.name" :breadcrumbs="[{ text: 'Tracks', to: '/tracks' }]" :icon="TrainTrack"> <ul class="events-list"> <li diff --git a/web/pages/tracks/index.vue b/web/pages/tracks/index.vue index 8d7534e..c3ec883 100644 --- a/web/pages/tracks/index.vue +++ b/web/pages/tracks/index.vue @@ -2,14 +2,22 @@ import { TrainTrack } from 'lucide-vue-next'; import Panel from '~/components/Panel.vue'; +definePageMeta({ + middleware: ['logged-in', 'conference-selected'] +}) const scheduleStore = useScheduleStore(); </script> <template> - <Panel v-if="scheduleStore.schedule" title="Tracks" :icon="TrainTrack"> + <div v-if="scheduleStore.status === 'pending'" class="loading"> + <span class="loading-text"> + <Spinner color="var(--color-text-muted)" />Updating schedule... + </span> + </div> + <Panel v-else title="Tracks" :icon="TrainTrack"> <ul class="tracks-list"> <li - v-for="track in scheduleStore.schedule.tracks" + v-for="track in scheduleStore.schedule?.tracks" :key="track.name" class="tracks-item" > diff --git a/web/stores/auth.ts b/web/stores/auth.ts new file mode 100644 index 0000000..71ba5c0 --- /dev/null +++ b/web/stores/auth.ts @@ -0,0 +1,12 @@ +import { useLocalStorage } from "@vueuse/core"; +import { defineStore } from "pinia"; + +export const useAuthStore = defineStore('auth', () => { + const token = useLocalStorage('auth/token', null) + const username = useLocalStorage('auth/username', null) + const admin = useLocalStorage('auth/admin', false) + + const isLoggedIn = () => token.value != null + + return {token, username, admin, isLoggedIn} +}) diff --git a/web/stores/conference.ts b/web/stores/conference.ts new file mode 100644 index 0000000..2dbe82a --- /dev/null +++ b/web/stores/conference.ts @@ -0,0 +1,18 @@ +import { useLocalStorage } from "@vueuse/core"; +import { defineStore } from "pinia"; + +export const useConferenceStore = defineStore('conference', () => { + const id = useLocalStorage('conference/id', null) + const title = useLocalStorage('conference/title', null) + const venue = useLocalStorage('conference/venue', null) + const city = useLocalStorage('conference/city', null) + + const clear = () => { + id.value = null + title.value = null + venue.value = null + city.value = null + } + + return {id, title, venue, city, clear} +}) diff --git a/web/stores/favourites.ts b/web/stores/favourites.ts index 2bf7257..d502788 100644 --- a/web/stores/favourites.ts +++ b/web/stores/favourites.ts @@ -35,9 +35,5 @@ export const useFavouritesStore = defineStore('favourites', () => { }) } - const setStatus = (newStatus: 'idle' | 'pending') => { - status.value = newStatus - } - - return {favourites, status, setFavourites, addFavourite, removeFavourite, isFavourite, setStatus} + return {favourites, status, setFavourites, addFavourite, removeFavourite, isFavourite} }) diff --git a/web/stores/login-options.ts b/web/stores/login-options.ts index fd97a75..d66c833 100644 --- a/web/stores/login-options.ts +++ b/web/stores/login-options.ts @@ -10,13 +10,5 @@ export const useLoginOptionsStore = defineStore('loginOptions', () => { const loginOptions = ref([] as LoginOption[]) const status = ref('idle' as 'idle' | 'pending') - const setLoginOptions = (newLoginOptions: LoginOption[]) => { - loginOptions.value = newLoginOptions - } - - const setStatus = (newStatus: 'idle' | 'pending') => { - status.value = newStatus - } - - return {loginOptions, status, setLoginOptions, setStatus} + return {loginOptions, status} }) diff --git a/web/stores/schedule.ts b/web/stores/schedule.ts index d5f4b4c..83f274d 100644 --- a/web/stores/schedule.ts +++ b/web/stores/schedule.ts @@ -74,6 +74,7 @@ interface Link { export const useScheduleStore = defineStore('schedule', () => { const schedule = ref(null as Schedule | null) + const status = ref('idle' as 'idle' | 'pending') const events = ref([] as Event[]) const eventsPerDay = ref({} as { [key: string]: Event[] }) @@ -101,7 +102,9 @@ export const useScheduleStore = defineStore('schedule', () => { events.value.push(event) - event.track = tracks.value[event.track as unknown as string] + if (event.track) { + event.track = tracks.value[event.track as unknown as string] + } }) }) }) @@ -122,6 +125,7 @@ export const useScheduleStore = defineStore('schedule', () => { eventsPerTrack.value = {} events.value.forEach(event => { + if (!event.track) return if (!eventsPerTrack.value[event.track.name]) { eventsPerTrack.value[event.track.name] = [] } @@ -147,7 +151,7 @@ export const useScheduleStore = defineStore('schedule', () => { return schedule.value?.conference.start || 0 } - return {schedule, events, eventsPerDay, eventsPerTrack, setSchedule, isConferenceOngoing, isConferenceFinished, getStartDate} + return {schedule, events, eventsPerDay, eventsPerTrack, status, setSchedule, isConferenceOngoing, isConferenceFinished, getStartDate} }) function normalizeDates(event: Event, timeZone: string) { |
