diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/Button.vue | 88 | ||||
| -rw-r--r-- | components/Dialog.vue | 96 | ||||
| -rw-r--r-- | components/EventDetail.vue | 113 | ||||
| -rw-r--r-- | components/EventListing.vue | 151 | ||||
| -rw-r--r-- | components/Input.vue | 95 | ||||
| -rw-r--r-- | components/Nav.vue | 68 | ||||
| -rw-r--r-- | components/Panel.vue | 15 | ||||
| -rw-r--r-- | components/Spinner.vue | 30 |
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> •</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> |
