aboutsummaryrefslogtreecommitdiffstats
path: root/web/components
diff options
context:
space:
mode:
Diffstat (limited to 'web/components')
-rw-r--r--web/components/Button.vue120
-rw-r--r--web/components/Dialog.vue122
-rw-r--r--web/components/EventDetail.vue113
-rw-r--r--web/components/EventListing.vue179
-rw-r--r--web/components/Input.vue102
-rw-r--r--web/components/Nav.vue74
-rw-r--r--web/components/Panel.vue127
-rw-r--r--web/components/Sidebar.vue115
-rw-r--r--web/components/Spinner.vue30
-rw-r--r--web/components/Version.vue26
10 files changed, 1008 insertions, 0 deletions
diff --git a/web/components/Button.vue b/web/components/Button.vue
new file mode 100644
index 0000000..ce9eefc
--- /dev/null
+++ b/web/components/Button.vue
@@ -0,0 +1,120 @@
+<script setup lang="ts">
+import { Loader2Icon } from 'lucide-vue-next'
+import { defineProps, type FunctionalComponent } from 'vue';
+
+defineProps({
+ isLoading: {
+ type: Boolean,
+ default: false,
+ },
+ disabled: {
+ type: Boolean,
+ default: false
+ },
+ loading: {
+ type: Boolean,
+ default: false
+ },
+ type: {
+ type: String as PropType<"button" | "submit" | "reset">,
+ default: "",
+ },
+ kind: {
+ type: String as PropType<"primary" | "secondary" | "danger">,
+ default: "primary",
+ },
+ icon: {
+ type: Object as PropType<FunctionalComponent>,
+ default: null
+ }
+});
+</script>
+
+<template>
+ <button :type="type" :disabled="disabled || loading" :class="kind">
+ <Loader2Icon v-if="loading" class="icon-loader" />
+ <component :is="icon" v-else-if="icon" />
+ <span>
+ <slot />
+ </span>
+ </button>
+</template>
+
+<style scoped>
+button {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.5rem 1rem;
+ border: 1px solid transparent;
+ border-radius: 0.375rem;
+ font-size: var(--text-small);
+ font-family: var(--font-family);
+ font-weight: 500;
+ color: white;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+}
+
+button svg {
+ height: var(--text-small);
+ width: var(--text-small);
+}
+
+button:hover {
+ background-color: var(--color-primary-dark);
+}
+
+button:focus {
+ outline: none;
+ border-color: var(--color-accent);
+}
+
+button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.icon-loader {
+ animation: spin 1s linear infinite;
+}
+
+button.primary {
+ background-color: var(--color-primary);
+}
+
+button.primary:hover {
+ background-color: var(--color-primary-dark);
+}
+
+button.secondary {
+ background-color: unset;
+ border: 1px solid var(--color-primary);
+ color: var(--color-primary);
+}
+
+button.secondary:hover {
+ background-color: var(--color-background-muted);
+ border: 1px solid var(--color-primary-dark);
+ color: var(--color-primary-dark);
+}
+
+button.danger {
+ background-color: var(--color-error);
+}
+
+button.danger:hover {
+ background-color: var(--color-error-dark);
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+</style>
diff --git a/web/components/Dialog.vue b/web/components/Dialog.vue
new file mode 100644
index 0000000..7772f23
--- /dev/null
+++ b/web/components/Dialog.vue
@@ -0,0 +1,122 @@
+<script setup lang="ts">
+import { ref } from 'vue';
+
+const visible = ref(false);
+const refDialog = ref<HTMLDialogElement | null>(null);
+
+const props = defineProps<{
+ kind?: 'normal' | 'error';
+ fitContents?: boolean;
+ title?: string;
+}>();
+
+const showModal = () => {
+ refDialog.value?.showModal();
+ visible.value = true;
+};
+
+const closeModal = () => {
+ refDialog.value?.close();
+};
+
+const emit = defineEmits(['close', 'submit']);
+
+defineExpose({
+ show: showModal,
+ close: closeModal,
+ visible,
+});
+
+const onClose = () => {
+ visible.value = false;
+ emit('close');
+};
+
+const onSubmit = (e: Event) => {
+ e.preventDefault();
+ const formData = new FormData(e.target as HTMLFormElement);
+ const formValue = Object.fromEntries(formData.entries());
+ emit('submit', formValue);
+
+ closeModal();
+};
+
+const onDivClick = (e: MouseEvent) => {
+ e.stopPropagation()
+};
+
+const onDialogClick = (e: MouseEvent) => {
+ if (e.target === refDialog.value) {
+ refDialog.value?.close();
+ }
+};
+</script>
+
+<template>
+ <dialog ref="refDialog" @click="onDialogClick" @close="onClose" @submit="onSubmit" :class="[props.kind ?? 'normal', { fit: props.fitContents }]">
+ <div @click="onDivClick">
+ <form v-if="visible" method="dialog">
+ <div class="title" v-if="title">{{ props.title }}</div>
+
+ <slot />
+
+ <div class="actions" v-if="$slots.actions">
+ <slot name="actions" class="actions" />
+ </div>
+ </form>
+ </div>
+ </dialog>
+</template>
+
+
+<style scoped>
+dialog {
+ outline: none;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ width: 1000px;
+ margin: 0;
+ top: 80px;
+ max-height: calc(100vh - 160px);
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+dialog.normal {
+ border: 2px solid var(--color-border);
+ background-color: var(--color-background);
+}
+
+dialog.error {
+ border: 2px solid var(--color-border-error-light);
+ background-color: var(--color-error-light);
+}
+
+dialog.fit {
+ width: fit-content;
+ max-width: 1000px;
+}
+
+dialog::backdrop {
+ backdrop-filter: blur(4px);
+ background-color: rgba(0, 0, 0, 0.1);
+}
+
+form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+div.title {
+ font-size: var(--text-larger);
+ font-weight: 700;
+}
+
+.actions {
+ display: flex;
+ gap: 1rem;
+ align-self: flex-end;
+ justify-content: flex-end;
+}
+</style> \ No newline at end of file
diff --git a/web/components/EventDetail.vue b/web/components/EventDetail.vue
new file mode 100644
index 0000000..b4f7bd9
--- /dev/null
+++ b/web/components/EventDetail.vue
@@ -0,0 +1,113 @@
+<script setup lang="ts">
+import { format } from 'date-fns';
+import { type Event as ScheduledEvent } from '~/stores/schedule';
+
+const { event } = defineProps<{
+ event: ScheduledEvent;
+}>();
+
+const getHostname = (url: string) => new URL(url).hostname;
+
+</script>
+
+<template>
+ <div class="event">
+ <div class="event-details">
+ <span class="event-info">
+ {{ format(event.start, "eeee kk:mm") }} - {{ format(event.end, "kk:mm") }}, {{ event.room }}
+ </span>
+ <span class="event-title">{{ event.title }}</span>
+ <span class="event-speaker">{{ event.persons.map(p => p.name).join(", ") }}</span>
+ </div>
+
+ <div class="event-abstract" v-html="event.abstract" />
+
+ <div v-if="event.links.length > 0 || event.attachments.length > 0" class="event-supplementary">
+ <div class="event-links" v-if="event.links.length > 0">
+ <span class="event-links-header">Links</span>
+ <ul>
+ <li v-for="link in event.links" :key="link.href" class="event-link">
+ <a :href="link.href" target="_blank">{{ link.name }}</a>
+ </li>
+ </ul>
+ </div>
+
+ <div class="event-attachments" v-if="event.attachments.length > 0">
+ <span class="event-attachments-header">Attachments</span>
+ <ul>
+ <li v-for="attachment in event.attachments" :key="attachment.href" class="event-attachment">
+ <a :href="attachment.href" target="_blank">{{ attachment.name }}</a>
+ </li>
+ </ul>
+ </div>
+ </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>
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.event {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.event-details {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ flex-direction: column;
+ line-height: 1.3;
+}
+
+.event-info {
+ font-size: var(--text-normal);
+ color: var(--color-text);
+ margin: 0;
+}
+
+.event-title {
+ font-size: var(--text-larger);
+ font-weight: 600;
+ margin: 0;
+}
+
+.event-supplementary {
+ display: flex;
+ flex-direction: row;
+ gap: 1rem;
+}
+
+.event-links-header, .event-attachments-header, .event-track-header {
+ font-weight: 600;
+}
+
+.event-speaker {
+ font-size: var(--text-small);
+ color: var(--color-text-muted);
+}
+
+.event-track, .event-url {
+ font-size: var(--text-small);
+}
+
+.event-button {
+ background: none;
+ border: none;
+ color: var(--color-primary);
+ cursor: pointer;
+ outline: none;
+}
+
+.event-button:hover {
+ color: var(--color-primary-dark);
+}
+
+.event-icon {
+ height: 1.5rem;
+ width: 1.5rem;
+}
+</style> \ No newline at end of file
diff --git a/web/components/EventListing.vue b/web/components/EventListing.vue
new file mode 100644
index 0000000..5c04189
--- /dev/null
+++ b/web/components/EventListing.vue
@@ -0,0 +1,179 @@
+<script setup lang="ts">
+import { StarIcon } from 'lucide-vue-next';
+import { format, formatDistanceToNow } from 'date-fns';
+import { type Event as ScheduledEvent } from '~/stores/schedule';
+import Spinner from './Spinner.vue';
+
+const { event, showRelativeTime } = defineProps<{
+ event: ScheduledEvent;
+ showRelativeTime?: boolean;
+}>();
+
+const selectedEventStore = useSelectedEventStore();
+const favouritesStore = useFavouritesStore();
+const errorStore = useErrorStore();
+const config = useRuntimeConfig();
+
+const addingToFavourite = ref(false);
+const relativeTime = ref();
+const timer = ref();
+
+const updateRelativeTime = () => {
+ if (event.start < new Date() && event.end > new Date()) {
+ relativeTime.value = 'now';
+ } else {
+ relativeTime.value = `in ${formatDistanceToNow(event.start)}`
+ }
+};
+
+onMounted(() => {
+ if (showRelativeTime) {
+ updateRelativeTime();
+ timer.value = setInterval(updateRelativeTime, 1000);
+ }
+});
+
+onUnmounted(() => {
+ if (timer.value) {
+ clearInterval(timer.value);
+ }
+});
+
+const addFavourite = async () => {
+ addingToFavourite.value = true;
+
+ try {
+ const res = await $fetch(config.public.baseURL + '/favourites', {
+ method: 'POST',
+ body: JSON.stringify({
+ eventGuid: event.guid,
+ eventId: event.id,
+ }),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ favouritesStore.addFavourite({
+ id: (res as any).data.id,
+ eventId: event.id,
+ eventGuid: event.guid,
+ });
+ } catch(e: any) {
+ errorStore.setError(e.data.message ?? 'An unknown error occurred');
+ } finally {
+ addingToFavourite.value = false;
+ }
+};
+
+const removeFavourite = async () => {
+ addingToFavourite.value = true;
+
+ try {
+ await $fetch(config.public.baseURL + '/favourites', {
+ method: 'DELETE',
+ body: JSON.stringify({
+ eventGuid: event.guid,
+ eventId: event.id,
+ }),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ favouritesStore.removeFavourite({
+ eventId: event.id,
+ eventGuid: event.guid,
+ });
+ } catch(e: any) {
+ errorStore.setError(e.data.message ?? 'An unknown error occurred');
+ } finally {
+ addingToFavourite.value = false;
+ }
+
+};
+</script>
+
+<template>
+ <div class="event">
+ <div class="event-details" @click="selectedEventStore.setSelectedEvent(event)">
+ <span class="event-info">
+ <span>{{ format(event.start, "kk:mm") }} - {{ format(event.end, "kk:mm") }},</span> <span>{{ event.room }}</span> <span v-if="showRelativeTime">-</span> <span v-if="showRelativeTime" class="relative-time">{{ relativeTime }}</span>
+ </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>
+ </div>
+ <template v-if="!addingToFavourite" 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>
+ <template v-else>
+ <Spinner color="var(--color-text-muted)" class="event-button-loading" />
+ </template>
+ </div>
+</template>
+
+<style scoped>
+.event-details {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ flex-direction: column;
+ line-height: 1.3;
+ cursor: pointer;
+ flex-grow: 1;
+ padding: 0.825rem 1rem;
+}
+
+.event-details:hover {
+ background-color: var(--color-hover);
+}
+
+.event {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ position: relative;
+ left: -1rem;
+ width: calc(100% + 2 * 1rem);
+}
+
+.event-title {
+ font-size: var(--text-large);
+ font-weight: 600;
+ color: var(--color-primary);
+ margin: 0;
+}
+
+.event-speaker, .event-track {
+ font-size: var(--text-small);
+ color: var(--color-text-muted);
+}
+
+.event-info {
+ font-size: var(--text-normal);
+ color: var(--color-text);
+ margin: 0;
+}
+
+.event-button, .event-button-loading {
+ height: 1.5rem;
+ width: 1.5rem;
+ padding: 0 1rem;
+ flex-shrink: 0;
+}
+
+.event-button {
+ cursor: pointer;
+}
+
+.event-button-loading {
+ cursor: progress;
+}
+
+.relative-time {
+ color: var(--color-text-success);
+}
+
+</style> \ No newline at end of file
diff --git a/web/components/Input.vue b/web/components/Input.vue
new file mode 100644
index 0000000..b541566
--- /dev/null
+++ b/web/components/Input.vue
@@ -0,0 +1,102 @@
+<script lang="ts">
+export default {
+ name: 'CustomInput',
+ props: {
+ id: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ label: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ autocomplete: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ type: {
+ type: String,
+ required: false,
+ default: 'text',
+ },
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ required: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ modelValue: {
+ type: [String, Number],
+ required: false,
+ default: '',
+ },
+ placeholder: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ readonly: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ inputClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+
+<template>
+ <input
+ :id="id"
+ :type="type"
+ :name="name"
+ :value="modelValue"
+ @input="$emit('update:modelValue', ($event.target! as any).value)"
+ :placeholder="placeholder"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ />
+</template>
+
+<style>
+input {
+ appearance: none;
+ display: block;
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ box-sizing: border-box;
+ /* box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); */
+ font-family: var(--font-family);
+ color: var(--color-text);
+ font-size: var(--text-small);
+}
+
+input::placeholder {
+ color: #9ca3af;
+}
+
+input:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ /* box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.4); */
+}
+</style> \ No newline at end of file
diff --git a/web/components/Nav.vue b/web/components/Nav.vue
new file mode 100644
index 0000000..1e08e54
--- /dev/null
+++ b/web/components/Nav.vue
@@ -0,0 +1,74 @@
+<script setup lang="ts">
+import { Calendar, SquareGanttChart, TrainTrack } from 'lucide-vue-next';
+
+const route = useRouter();
+
+const navList = ref([
+ { title: "Agenda", path: "/agenda", icon: Calendar, navigating: false },
+ { title: "Events", path: "/events", icon: SquareGanttChart, navigating: false },
+ { title: "Tracks", path: "/tracks", icon: TrainTrack, navigating: false },
+])
+
+route.beforeEach((to, from, next) => {
+ navList.value.forEach((item) => {
+ item.navigating = to.path === item.path;
+ });
+ next();
+});
+
+route.afterEach(() => {
+ navList.value.forEach((item) => {
+ item.navigating = false;
+ });
+});
+</script>
+
+<template>
+ <Panel class="nav">
+ <ul class="nav-list">
+ <li v-for="item in navList" :key="item.title" :class="{ active: $route.path === item.path }">
+ <NuxtLink :to="item.path">
+ <span class="text-icon"><component :is="item.icon" /> <span>{{ item.title }}</span></span> <Spinner v-if="item.navigating" color="var(--color-text-muted)" :size="16"/></NuxtLink>
+ </li>
+ </ul>
+ </Panel>
+</template>
+
+<style scoped>
+.text-icon > svg {
+ width: var(--text-normal);
+ height: var(--text-normal);
+}
+
+.nav-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.nav-list > li {
+ width: 100%;
+}
+
+.nav-list > li > a {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--color-accent);
+ padding: 0.4rem 1rem;
+ left: -1rem;
+ width: calc(100%);
+ position: relative;
+}
+
+.nav-list > li.active > a {
+ color: var(--color-primary);
+ font-weight: 600;
+ background-color: var(--color-background-muted);
+}
+
+.nav-list > li > a:hover {
+ background-color: var(--color-background-muted);
+}
+
+</style> \ No newline at end of file
diff --git a/web/components/Panel.vue b/web/components/Panel.vue
new file mode 100644
index 0000000..1f2d22e
--- /dev/null
+++ b/web/components/Panel.vue
@@ -0,0 +1,127 @@
+<script setup lang="ts">
+import { ArrowRight } from 'lucide-vue-next';
+import { defineProps, type FunctionalComponent, type PropType } from 'vue';
+
+defineProps({
+ kind: {
+ type: String as PropType<"normal" | "error" | "success" | "emphasis">,
+ required: false,
+ default: 'normal',
+ },
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ breadcrumbs: {
+ type: Array as PropType<Array<{ text: string, to: string }>>,
+ required: false,
+ default: () => [],
+ },
+ icon: {
+ type: Object as PropType<FunctionalComponent>,
+ default: null
+ }
+});
+</script>
+<template>
+ <div class="card" :class="kind">
+ <div v-if="title" class="card-header">
+ <div class="header-left">
+ <component :is="icon" v-if="icon" />
+ <div class="card-title-container">
+ <span v-for="(breadcrumb, index) in breadcrumbs" class="breadcrumb" :key="index">
+ <NuxtLink :to="breadcrumb.to">{{ breadcrumb.text }}</NuxtLink>
+ <ArrowRight v-if="index != breadcrumbs.length - 1" />
+ </span>
+ <span class="card-title"> {{ title }} </span>
+ </div>
+ </div>
+
+ <slot name="actions" />
+ </div>
+ <slot />
+ </div>
+</template>
+
+<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;
+}
+
+div.header-left {
+ display: flex;
+ line-height: 1;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+div.header-left > svg {
+ width: 1.3rem;
+ height: 1.3rem;
+}
+
+span.card-title {
+ font-size: 1.5rem;
+ font-weight: 700;
+}
+
+span.breadcrumb {
+ font-size: 1.2rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+span.breadcrumb > svg {
+ width: 1.2rem;
+ height: 1.2rem;
+ color: var(--color-text-muted);
+}
+
+span.breadcrumb > a {
+ color: var(--color-text-muted);
+}
+
+span.breadcrumb > a:hover {
+ color: var(--color-primary);
+}
+
+div.normal {
+ background-color: white;
+ border: 0.1rem solid var(--color-border);
+}
+
+div.normal .card-header {
+ background-color: var(--color-background-muted);
+ border-bottom: 1px solid var(--color-border);
+ margin: -1rem -1rem 1rem -1rem;
+}
+
+div.error {
+ background-color: var(--color-error-light);
+ border: 0.1rem solid var(--color-border-error-light);
+}
+
+div.success {
+ background-color: var(--color-success-light);
+ border: 0.1rem solid var(--color-border-success-light);
+}
+
+div.emphasis {
+ background-color: var(--color-background-primary);
+ border: 0.1rem solid var(--color-border-primary-light);
+}
+</style> \ No newline at end of file
diff --git a/web/components/Sidebar.vue b/web/components/Sidebar.vue
new file mode 100644
index 0000000..5fc42d3
--- /dev/null
+++ b/web/components/Sidebar.vue
@@ -0,0 +1,115 @@
+<script setup lang="ts">
+import { formatDistanceToNow } from "date-fns";
+import { LucideClock, LucideRadio } from "lucide-vue-next";
+
+const scheduleStore = useScheduleStore();
+const errorStore = useErrorStore();
+
+const timer = ref();
+const startsIn = ref();
+const ongoing = ref(false);
+const finished = ref(false);
+
+onMounted(() => {
+ startsIn.value = formatDistanceToNow(scheduleStore.getStartDate());
+ ongoing.value = scheduleStore.isConferenceOngoing();
+ finished.value = scheduleStore.isConferenceFinished();
+
+ timer.value = setInterval(() => {
+ startsIn.value = formatDistanceToNow(scheduleStore.getStartDate());
+ ongoing.value = scheduleStore.isConferenceOngoing();
+ finished.value = scheduleStore.isConferenceFinished();
+ }, 1000);
+});
+
+onBeforeUnmount(() => {
+ clearInterval(timer.value);
+});
+
+</script>
+
+<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>
+
+ <Button kind="secondary" @click="errorStore.setError('This doesn\'t do anything yet :-)')">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>
+
+ <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 />
+
+ <div class="info">
+ <span>Times listed are in local time ({{ scheduleStore.schedule?.conference.timeZoneName }})</span>
+
+ <Version />
+ </div>
+ </div>
+</template>
+
+<style scoped>
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.finished, .ongoing, .upcoming {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 1rem;
+ font-size: var(--text-small);
+ font-style: oblique;
+ text-align: center;
+}
+
+.ongoing button {
+ width: 100%;
+}
+
+.finished svg, .ongoing svg, .upcoming svg{
+ height: var(--text-small) ;
+ width: var(--text-small);
+}
+
+.conference {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.conference-title {
+ font-weight: 600;
+ font-size: var(--text-normal);
+}
+
+.conference-venue, .conference-city {
+ font-size: var(--text-small);
+ color: var(--color-text-muted);
+}
+
+.info {
+ font-size: var(--text-smaller);
+ color: var(--color-text-muted);
+ margin: 0 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+</style> \ No newline at end of file
diff --git a/web/components/Spinner.vue b/web/components/Spinner.vue
new file mode 100644
index 0000000..a58f83d
--- /dev/null
+++ b/web/components/Spinner.vue
@@ -0,0 +1,30 @@
+<script setup lang="ts">
+import { Loader2Icon } from 'lucide-vue-next'
+
+const props = defineProps<{
+ color?: string
+ size?: number
+}>()
+
+</script>
+
+<template>
+ <Loader2Icon class="icon-loader" :color="color" :size="size" />
+</template>
+
+<style scoped>
+.icon-loader {
+ animation: spin 1s linear infinite;
+ color: var(--color-text);
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+</style>
diff --git a/web/components/Version.vue b/web/components/Version.vue
new file mode 100644
index 0000000..f5b2bf6
--- /dev/null
+++ b/web/components/Version.vue
@@ -0,0 +1,26 @@
+<script setup lang="ts">
+import { Loader2Icon } from 'lucide-vue-next'
+
+const config = useRuntimeConfig();
+</script>
+
+<template>
+ <span>confplanner-web {{ config.public.versionString }}</span>
+</template>
+
+<style scoped>
+.icon-loader {
+ animation: spin 1s linear infinite;
+ color: var(--color-text);
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+</style>