aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--assets/css/main.css14
-rw-r--r--components/Button.vue33
-rw-r--r--components/Dialog.vue41
-rw-r--r--components/EventListing.vue34
-rw-r--r--components/Nav.vue2
-rw-r--r--components/Panel.vue8
-rw-r--r--components/Sidebar.vue36
-rw-r--r--pages/agenda.vue20
-rw-r--r--pages/events.vue2
-rw-r--r--pages/live.vue32
-rw-r--r--pages/register.vue2
-rw-r--r--stores/schedule.ts4
12 files changed, 179 insertions, 49 deletions
diff --git a/assets/css/main.css b/assets/css/main.css
index 4af7ef0..c93c22d 100644
--- a/assets/css/main.css
+++ b/assets/css/main.css
@@ -5,18 +5,22 @@
--color-accent: #6366f1;
--color-primary: #4f46e5;
--color-primary-dark: #4338ca;
- --color-error: #f8d7da;
- --color-success: #d1e7dd;
+ --color-favourite: #f6e05e;
+ --color-error: #cf2f27;
+ --color-error-light: #f8d7da;
+ --color-error-dark: #ad1f2b;
+ --color-success-light: #d1e7dd;
+
--color-text: #000;
--color-text-error: #cf2f27;
+ --color-text-success: #3d9970;
--color-text-muted: #4b5563;
--color-text-muted-light: #6b7280;
--color-background-muted: #f3f4f6;
--color-background: #fff;
--color-border: #d1d9e0;
- --color-border-error: #f1aeb5;
- --color-border-success: #a3cfbb;
- --color-favourite: #f6e05e;
+ --color-border-error-light: #f1aeb5;
+ --color-border-success-light: #a3cfbb;
--text-smaller: 0.75rem;
--text-small: 0.875rem;
diff --git a/components/Button.vue b/components/Button.vue
index 97ff928..2d24bad 100644
--- a/components/Button.vue
+++ b/components/Button.vue
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Loader2Icon } from 'lucide-vue-next'
-import { defineProps } from 'vue';
+import { defineProps, type FunctionalComponent } from 'vue';
defineProps({
isLoading: {
@@ -20,15 +20,20 @@ defineProps({
default: "",
},
kind: {
- type: String as PropType<"primary" | "secondary">,
+ 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>
@@ -40,6 +45,8 @@ button {
width: 100%;
display: flex;
justify-content: center;
+ align-items: center;
+ gap: 0.4rem;
padding: 0.5rem 1rem;
border: 1px solid transparent;
border-radius: 0.375rem;
@@ -51,6 +58,11 @@ button {
transition: background-color 0.2s ease;
}
+button svg {
+ height: var(--text-small);
+ width: var(--text-small);
+}
+
button:hover {
background-color: var(--color-primary-dark);
}
@@ -67,16 +79,15 @@ button:disabled {
.icon-loader {
animation: spin 1s linear infinite;
- margin-left: -0.25rem;
- margin-right: 0.75rem;
- height: 1.25rem;
- width: 1.25rem;
- color: white;
}
button.primary {
background-color: var(--color-primary);
}
+
+button.primary:hover {
+ background-color: var(--color-primary-dark);
+}
button.secondary {
background-color: var(--color-background);
@@ -89,6 +100,14 @@ button.secondary:hover {
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% {
diff --git a/components/Dialog.vue b/components/Dialog.vue
index 3d91de0..7772f23 100644
--- a/components/Dialog.vue
+++ b/components/Dialog.vue
@@ -7,6 +7,7 @@ const refDialog = ref<HTMLDialogElement | null>(null);
const props = defineProps<{
kind?: 'normal' | 'error';
fitContents?: boolean;
+ title?: string;
}>();
const showModal = () => {
@@ -18,7 +19,7 @@ const closeModal = () => {
refDialog.value?.close();
};
-const emit = defineEmits(['close']);
+const emit = defineEmits(['close', 'submit']);
defineExpose({
show: showModal,
@@ -31,6 +32,15 @@ const onClose = () => {
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()
};
@@ -43,10 +53,16 @@ const onDialogClick = (e: MouseEvent) => {
</script>
<template>
- <dialog ref="refDialog" @click="onDialogClick" @close="onClose" :class="[props.kind, { fit: props.fitContents }]">
+ <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>
@@ -72,8 +88,8 @@ dialog.normal {
}
dialog.error {
- border: 2px solid var(--color-border-error);
- background-color: var(--color-error);
+ border: 2px solid var(--color-border-error-light);
+ background-color: var(--color-error-light);
}
dialog.fit {
@@ -86,10 +102,21 @@ dialog::backdrop {
background-color: rgba(0, 0, 0, 0.1);
}
-div.actions {
+form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+div.title {
+ font-size: var(--text-larger);
+ font-weight: 700;
+}
+
+.actions {
display: flex;
- margin-top: 12px;
- gap: 8px;
+ gap: 1rem;
+ align-self: flex-end;
justify-content: flex-end;
}
</style> \ No newline at end of file
diff --git a/components/EventListing.vue b/components/EventListing.vue
index 9271692..287fbfc 100644
--- a/components/EventListing.vue
+++ b/components/EventListing.vue
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { StarIcon } from 'lucide-vue-next';
-import { add, format } from 'date-fns';
+import { format, formatDistanceToNow } from 'date-fns';
import { type Event as ScheduledEvent } from '~/stores/schedule';
import Spinner from './Spinner.vue';
-const { event } = defineProps<{
+const { event, showRelativeTime } = defineProps<{
event: ScheduledEvent;
+ showRelativeTime?: boolean;
}>();
const selectedEventStore = useSelectedEventStore();
@@ -14,6 +15,29 @@ 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;
@@ -74,7 +98,7 @@ const removeFavourite = async () => {
<div class="event">
<div class="event-details" @click="selectedEventStore.setSelectedEvent(event)">
<span class="event-info">
- {{ format(event.start, "kk:mm") }} - {{ format(event.end, "kk:mm") }}, {{ event.room }}
+ <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>
@@ -147,5 +171,9 @@ const removeFavourite = async () => {
.event-button-loading {
cursor: progress;
}
+
+.relative-time {
+ color: var(--color-text-success);
+}
</style> \ No newline at end of file
diff --git a/components/Nav.vue b/components/Nav.vue
index 280af02..1e08e54 100644
--- a/components/Nav.vue
+++ b/components/Nav.vue
@@ -1,5 +1,5 @@
<script setup lang="ts">
-import { Calendar, Icon, SquareGanttChart, TrainTrack } from 'lucide-vue-next';
+import { Calendar, SquareGanttChart, TrainTrack } from 'lucide-vue-next';
const route = useRouter();
diff --git a/components/Panel.vue b/components/Panel.vue
index 1e86ed1..285db57 100644
--- a/components/Panel.vue
+++ b/components/Panel.vue
@@ -85,12 +85,12 @@ defineProps({
}
.error {
- background-color: var(--color-error);
- border: 0.1rem solid var(--color-border-error);
+ background-color: var(--color-error-light);
+ border: 0.1rem solid var(--color-border-error-light);
}
.success {
- background-color: var(--color-success);
- border: 0.1rem solid var(--color-border-success);
+ background-color: var(--color-success-light);
+ border: 0.1rem solid var(--color-border-success-light);
}
</style> \ No newline at end of file
diff --git a/components/Sidebar.vue b/components/Sidebar.vue
index 3d33586..bdbcd51 100644
--- a/components/Sidebar.vue
+++ b/components/Sidebar.vue
@@ -5,17 +5,27 @@ import { LucideClock, LucideRadio } from "lucide-vue-next";
const scheduleStore = useScheduleStore();
const errorStore = useErrorStore();
-const timeUntilConferenceStart = computed(() => {
- if (!scheduleStore.schedule) {
- return 0;
- }
+const timer = ref();
+const startsIn = ref();
+const ongoing = ref(false);
+const finished = ref(false);
- const now = new Date();
- const conferenceStart = new Date(scheduleStore.schedule.conference.start);
- const diff = conferenceStart.getTime() - now.getTime();
+onMounted(() => {
+ startsIn.value = formatDistanceToNow(scheduleStore.getStartDate());
+ ongoing.value = scheduleStore.isConferenceOngoing();
+ finished.value = scheduleStore.isConferenceFinished();
- return diff;
+ timer.value = setInterval(() => {
+ startsIn.value = formatDistanceToNow(scheduleStore.getStartDate());
+ ongoing.value = scheduleStore.isConferenceOngoing();
+ finished.value = scheduleStore.isConferenceFinished();
+ }, 1000);
});
+
+onBeforeUnmount(() => {
+ clearInterval(timer.value);
+});
+
</script>
<template>
@@ -28,17 +38,17 @@ const timeUntilConferenceStart = computed(() => {
<Button kind="secondary" @click="errorStore.setError('This doesn\'t do anything yet :-)')">Change conference</Button>
</Panel>
- <Panel kind="success" class="ongoing" v-if="scheduleStore.isConferenceOngoing()">
- <span class="text-icon"><LucideRadio /> <span>This conference is ongoing</span></span>
- <Button kind="primary" @click="navigateTo('/live')">View live</Button>
+ <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="scheduleStore.isConferenceFinished()">
+ <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 {{ formatDistanceToNow(scheduleStore.getStartDate()) }}</span></span>
+ <span class="text-icon"><LucideClock /> <span>Starts in {{ startsIn }}</span></span>
</Panel>
<Nav />
diff --git a/pages/agenda.vue b/pages/agenda.vue
index 7ef5f44..9f83210 100644
--- a/pages/agenda.vue
+++ b/pages/agenda.vue
@@ -1,6 +1,6 @@
<script setup lang="ts">
import Panel from '~/components/Panel.vue';
-import { LucideRadio } from "lucide-vue-next";
+import Dialog from '~/components/Dialog.vue';
const favouritesStore = useFavouritesStore();
const scheduleStore = useScheduleStore();
@@ -17,15 +17,14 @@ const calendarLinkWithPageProtocol = computed(() => {
return window.location.protocol + '//' + calendarLink.value;
});
+const refConfirmDeleteDialog = ref<typeof Dialog>();
+
const calendarAction = ref(false);
useFetch(config.public.baseURL + '/calendar', {
method: 'GET',
server: false,
lazy: true,
- onResponseError: ({ response }) => {
- calendarStatus.value = 'idle';
- },
onResponse: ({ response }) => {
if (!response.ok) {
if (response.status !== 404) {
@@ -43,7 +42,7 @@ function generateCalendar() {
useFetch(config.public.baseURL + '/calendar', {
method: 'POST',
server: false,
- lazy: true,
+ lazy: true,
onResponseError: ({ response }) => {
errorStore.setError(response._data.message || 'An unknown error occurred');
calendarAction.value = false;
@@ -98,7 +97,7 @@ function deleteCalendar() {
<template v-if="calendarLink">
<span>You can add your agenda to your own calendar using the iCal link below</span>
<Input :value="calendarLinkWithPageProtocol" readonly/>
- <Button @click="deleteCalendar" :loading="calendarAction">Delete calendar</Button>
+ <Button @click="refConfirmDeleteDialog!.show()" :loading="calendarAction">Delete calendar</Button>
</template>
<template v-else>
<span>You do not have a calendar link yet. Use the button below to request a calendar link to subscribe to on your own calendar app.</span>
@@ -112,6 +111,15 @@ function deleteCalendar() {
<Panel v-else>
<span>You have not added any favourites yet.</span>
</Panel>
+
+ <Dialog ref="refConfirmDeleteDialog" title="Delete calendar" :confirmation="true" @submit="deleteCalendar" :fit-contents="true">
+ <span>Are you sure you want to delete your calendar?</span>
+ <span>Your unique link cannot be recovered if you do so.</span>
+ <template v-slot:actions>
+ <Button kind="secondary" type="button" @click="refConfirmDeleteDialog!.close()">Cancel</Button>
+ <Button kind="danger" type="submit" :loading="calendarAction">Delete</Button>
+ </template>
+ </Dialog>
</template>
<style scoped>
diff --git a/pages/events.vue b/pages/events.vue
index bfbe553..9ae35ed 100644
--- a/pages/events.vue
+++ b/pages/events.vue
@@ -10,7 +10,7 @@ const scheduleStore = useScheduleStore();
<div v-for="[day, events] of Object.entries(scheduleStore.eventsPerDay)" :key="day" class="events-container">
<ul class="events-list">
<li v-for="event in events" :key="event.id" class="event-item" :data-index="event.id">
- <EventListing :event="event" />
+ <EventListing :event="event" :show-relative-time="true" />
</li>
</ul>
</div>
diff --git a/pages/live.vue b/pages/live.vue
index b181ff0..1c5679f 100644
--- a/pages/live.vue
+++ b/pages/live.vue
@@ -1,10 +1,40 @@
<script setup lang="ts">
import Panel from '~/components/Panel.vue';
+import { type Event } from '~/stores/schedule';
+const favouritesStore = useFavouritesStore();
+const scheduleStore = useScheduleStore();
+const errorStore = useErrorStore();
+
+const favouriteEvents = computed(() => {
+ return scheduleStore.events.filter((event) => favouritesStore.isFavourite(event));
+});
+const todayEvents = computed(() => {
+ return scheduleStore.events.filter((event) => isToday(new Date(event.start)));
+});
+const happeningNow = computed(() => {
+ return todayEvents.value.filter((event) => isEventHappeningNow(event));
+});
+const favouritesHappeningNow = computed(() => {
+ return happeningNow.value.filter((event) => favouritesStore.isFavourite(event));
+});
+
+function isEventHappeningNow(event: Event): boolean {
+ const now = new Date();
+ return event.start <= now && event.end >= now;
+}
+
+function isToday(date: Date): boolean {
+ const today = new Date();
+ return date.getDate() === today.getDate() &&
+ date.getMonth() === today.getMonth() &&
+ date.getFullYear() === today.getFullYear();
+}
</script>
<template>
- <Panel>
+ <Panel kind="success" class="ongoing" v-if="happeningNow.length > 0">
+
</Panel>
</template>
diff --git a/pages/register.vue b/pages/register.vue
index b06578f..35e77dd 100644
--- a/pages/register.vue
+++ b/pages/register.vue
@@ -133,7 +133,7 @@ const handleSubmit = async (e: Event) => {
}
.auth-error {
- color: var(--color-error);
+ color: var(--color-error-light);
}
.form-group {
diff --git a/stores/schedule.ts b/stores/schedule.ts
index df2c1ec..e81055e 100644
--- a/stores/schedule.ts
+++ b/stores/schedule.ts
@@ -1,4 +1,5 @@
import { TZDate } from "@date-fns/tz";
+import { addDays } from "date-fns";
import { defineStore } from "pinia";
interface Schedule {
@@ -87,6 +88,9 @@ export const useScheduleStore = defineStore('schedule', () => {
tracks.value[track.name] = track
});
+ newSchedule.conference.start = new TZDate(newSchedule.conference.start, newSchedule.conference.timeZoneName)
+ newSchedule.conference.end = addDays(new TZDate(newSchedule.conference.end, newSchedule.conference.timeZoneName), 1)
+
events.value = []
newSchedule.days.forEach(day => {
day.start = new TZDate(day.start, newSchedule.conference.timeZoneName)