diff options
| author | Leonardo Bishop <me@leonardobishop.com> | 2025-08-14 18:07:12 +0100 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.com> | 2025-08-14 18:07:12 +0100 |
| commit | 4697556cac819c47d068819b9fc9c3b4ea84e279 (patch) | |
| tree | b832d8fc6b643a8b9d0eeca35c1268e1649da731 /web/components | |
| parent | dd49c9205bb04844b686b9c3396c40eb49d25826 (diff) | |
Merge confplanner-web and replace fiber with native net/http
Diffstat (limited to 'web/components')
| -rw-r--r-- | web/components/Button.vue | 120 | ||||
| -rw-r--r-- | web/components/Dialog.vue | 122 | ||||
| -rw-r--r-- | web/components/EventDetail.vue | 113 | ||||
| -rw-r--r-- | web/components/EventListing.vue | 179 | ||||
| -rw-r--r-- | web/components/Input.vue | 102 | ||||
| -rw-r--r-- | web/components/Nav.vue | 74 | ||||
| -rw-r--r-- | web/components/Panel.vue | 127 | ||||
| -rw-r--r-- | web/components/Sidebar.vue | 115 | ||||
| -rw-r--r-- | web/components/Spinner.vue | 30 | ||||
| -rw-r--r-- | web/components/Version.vue | 26 |
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> •</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> |
