aboutsummaryrefslogtreecommitdiffstats
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/Button.vue88
-rw-r--r--components/Dialog.vue96
-rw-r--r--components/EventDetail.vue113
-rw-r--r--components/EventListing.vue151
-rw-r--r--components/Input.vue95
-rw-r--r--components/Nav.vue68
-rw-r--r--components/Panel.vue15
-rw-r--r--components/Spinner.vue30
8 files changed, 656 insertions, 0 deletions
diff --git a/components/Button.vue b/components/Button.vue
new file mode 100644
index 0000000..2bffcec
--- /dev/null
+++ b/components/Button.vue
@@ -0,0 +1,88 @@
+<script setup>
+import { Loader2Icon } from 'lucide-vue-next'
+</script>
+
+<template>
+ <button :type="type" :disabled="disabled || loading">
+ <Loader2Icon v-if="loading" class="icon-loader" />
+ <span>
+ <slot />
+ </span>
+ </button>
+</template>
+
+<script>
+export default {
+ name: "Button",
+ props: {
+ isLoading: {
+ type: Boolean,
+ default: false,
+ },
+ disabled: {
+ type: Boolean,
+ default: false
+ },
+ loading: {
+ type: Boolean,
+ default: false
+ },
+ type: {
+ type: String,
+ default: "",
+ },
+ },
+};
+</script>
+
+<style scoped>
+button {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ padding: 0.5rem 1rem;
+ border: 1px solid transparent;
+ border-radius: 0.375rem;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ font-size: var(--text-small);
+ font-weight: 500;
+ color: white;
+ background-color: var(--color-primary);
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+}
+
+button:hover {
+ background-color: var(--color-primary-dark);
+}
+
+button:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.4);
+}
+
+button:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.icon-loader {
+ animation: spin 1s linear infinite;
+ margin-left: -0.25rem;
+ margin-right: 0.75rem;
+ height: 1.25rem;
+ width: 1.25rem;
+ color: white;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+</style>
diff --git a/components/Dialog.vue b/components/Dialog.vue
new file mode 100644
index 0000000..42ba070
--- /dev/null
+++ b/components/Dialog.vue
@@ -0,0 +1,96 @@
+<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;
+}>();
+
+const showModal = () => {
+ refDialog.value?.showModal();
+ visible.value = true;
+};
+
+const closeModal = () => {
+ refDialog.value?.close();
+};
+
+const emit = defineEmits(['close']);
+
+defineExpose({
+ show: showModal,
+ close: closeModal,
+ visible,
+});
+
+const onClose = () => {
+ visible.value = false;
+ emit('close');
+};
+
+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" :class="[props.kind, { fit: props.fitContents }]">
+ <div @click="onDivClick">
+ <form v-if="visible" method="dialog">
+ <slot />
+ </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-background-error-dark);
+ background-color: var(--color-background-error);
+ color: white;
+}
+
+dialog.fit {
+ width: fit-content;
+ max-width: 1000px;
+}
+
+dialog::backdrop {
+ backdrop-filter: blur(4px);
+ background-color: rgba(0, 0, 0, 0.3);
+}
+
+div.actions {
+ display: flex;
+ margin-top: 12px;
+ gap: 8px;
+ justify-content: flex-end;
+}
+</style> \ No newline at end of file
diff --git a/components/EventDetail.vue b/components/EventDetail.vue
new file mode 100644
index 0000000..b4f7bd9
--- /dev/null
+++ b/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/components/EventListing.vue b/components/EventListing.vue
new file mode 100644
index 0000000..9271692
--- /dev/null
+++ b/components/EventListing.vue
@@ -0,0 +1,151 @@
+<script setup lang="ts">
+import { StarIcon } from 'lucide-vue-next';
+import { add, format } from 'date-fns';
+import { type Event as ScheduledEvent } from '~/stores/schedule';
+import Spinner from './Spinner.vue';
+
+const { event } = defineProps<{
+ event: ScheduledEvent;
+}>();
+
+const selectedEventStore = useSelectedEventStore();
+const favouritesStore = useFavouritesStore();
+const errorStore = useErrorStore();
+const config = useRuntimeConfig();
+
+const addingToFavourite = ref(false);
+
+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">
+ {{ format(event.start, "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>
+ <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-background-muted);
+}
+
+.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;
+}
+
+</style> \ No newline at end of file
diff --git a/components/Input.vue b/components/Input.vue
new file mode 100644
index 0000000..4d95700
--- /dev/null
+++ b/components/Input.vue
@@ -0,0 +1,95 @@
+<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,
+ },
+ 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"
+ />
+</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); */
+ 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/components/Nav.vue b/components/Nav.vue
new file mode 100644
index 0000000..872d791
--- /dev/null
+++ b/components/Nav.vue
@@ -0,0 +1,68 @@
+<script setup lang="ts">
+const route = useRouter();
+
+const navList = ref([
+ { title: "Agenda", path: "/agenda", navigating: false },
+ { title: "Events", path: "/events", navigating: false },
+ { title: "Tracks", path: "/tracks", 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>{{ item.title }}</span> <Spinner v-if="item.navigating" color="var(--color-text-muted)" size="16"/></NuxtLink>
+
+ </li>
+ </ul>
+ </Panel>
+</template>
+
+<style scoped>
+.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-text-muted);
+ padding: 0.4rem 1rem;
+ left: -1rem;
+ width: calc(100%);
+ position: relative;
+}
+
+.nav-list > li.active > a {
+ color: var(--color-text);
+ 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/components/Panel.vue b/components/Panel.vue
new file mode 100644
index 0000000..007d2b6
--- /dev/null
+++ b/components/Panel.vue
@@ -0,0 +1,15 @@
+<template>
+ <div class="card">
+ <slot />
+ </div>
+</template>
+
+<style>
+.card {
+ background-color: white;
+ padding: 1rem;
+ border-radius: 0.5rem;
+ /* box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); */
+ border: 0.1rem solid var(--color-border);
+}
+</style> \ No newline at end of file
diff --git a/components/Spinner.vue b/components/Spinner.vue
new file mode 100644
index 0000000..a58f83d
--- /dev/null
+++ b/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>