aboutsummaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
authorLeonardo Bishop <me@leonardobishop.com>2025-08-23 22:29:28 +0100
committerLeonardo Bishop <me@leonardobishop.com>2025-08-23 22:29:28 +0100
commitecc6a55aba7bb35fc778e7a53848396b88214151 (patch)
tree1b37a2dc5f4594155114da1ae0c4529d20a4c548 /web
parent8f7dec8ba6b2f9bde01afd0a110596ebbd43e0ed (diff)
Add multiple conferences feature
Diffstat (limited to 'web')
-rw-r--r--web/assets/css/main.css44
-rw-r--r--web/components/AddConference.vue54
-rw-r--r--web/components/Button.vue2
-rw-r--r--web/components/Dialog.vue2
-rw-r--r--web/components/EventDetail.vue2
-rw-r--r--web/components/EventListing.vue10
-rw-r--r--web/components/Input.vue2
-rw-r--r--web/components/Panel.vue15
-rw-r--r--web/components/Sidebar.vue45
-rw-r--r--web/composables/api.ts14
-rw-r--r--web/composables/expire-auth.ts8
-rw-r--r--web/composables/fetch-favourites.ts36
-rw-r--r--web/composables/fetch-login.ts6
-rw-r--r--web/composables/fetch-schedule.ts57
-rw-r--r--web/composables/logout.ts15
-rw-r--r--web/layouts/default.vue47
-rw-r--r--web/middleware/conference-selected.ts15
-rw-r--r--web/middleware/logged-in.ts19
-rw-r--r--web/nuxt.config.ts2
-rw-r--r--web/package-lock.json41
-rw-r--r--web/package.json1
-rw-r--r--web/pages/agenda.vue52
-rw-r--r--web/pages/conferences.vue124
-rw-r--r--web/pages/events.vue11
-rw-r--r--web/pages/index.vue9
-rw-r--r--web/pages/live.vue9
-rw-r--r--web/pages/login/[[provider]].vue98
-rw-r--r--web/pages/tracks/[slug].vue9
-rw-r--r--web/pages/tracks/index.vue12
-rw-r--r--web/stores/auth.ts12
-rw-r--r--web/stores/conference.ts18
-rw-r--r--web/stores/favourites.ts6
-rw-r--r--web/stores/login-options.ts10
-rw-r--r--web/stores/schedule.ts8
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> &bull;</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> &bull;</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) {