diff options
| -rw-r--r-- | assets/css/main.css | 6 | ||||
| -rw-r--r-- | components/Nav.vue | 16 | ||||
| -rw-r--r-- | components/Panel.vue | 58 | ||||
| -rw-r--r-- | components/Sidebar.vue | 5 | ||||
| -rw-r--r-- | components/Version.vue | 26 | ||||
| -rw-r--r-- | composables/fetch-favourites.ts | 23 | ||||
| -rw-r--r-- | composables/fetch-schedule.ts | 24 | ||||
| -rw-r--r-- | layouts/default.vue | 75 | ||||
| -rw-r--r-- | nuxt.config.ts | 16 | ||||
| -rw-r--r-- | pages/agenda.vue | 16 | ||||
| -rw-r--r-- | pages/events.vue | 13 | ||||
| -rw-r--r-- | pages/login.vue | 19 | ||||
| -rw-r--r-- | pages/register.vue | 13 | ||||
| -rw-r--r-- | pages/tracks/[slug].vue | 23 | ||||
| -rw-r--r-- | pages/tracks/index.vue | 5 | ||||
| -rw-r--r-- | stores/error.ts | 1 |
16 files changed, 243 insertions, 96 deletions
diff --git a/assets/css/main.css b/assets/css/main.css index 1ece5c0..4af7ef0 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -8,11 +8,11 @@ --color-error: #f8d7da; --color-success: #d1e7dd; --color-text: #000; + --color-text-error: #cf2f27; --color-text-muted: #4b5563; + --color-text-muted-light: #6b7280; --color-background-muted: #f3f4f6; --color-background: #fff; - --color-background-error: #cf2f27; - --color-background-error-dark: #c12820; --color-border: #d1d9e0; --color-border-error: #f1aeb5; --color-border-success: #a3cfbb; @@ -84,6 +84,6 @@ ul { span.text-icon { display: flex; - align-items: centerg; + align-items: center; gap: 0.4em; }
\ No newline at end of file diff --git a/components/Nav.vue b/components/Nav.vue index 06a0295..280af02 100644 --- a/components/Nav.vue +++ b/components/Nav.vue @@ -1,10 +1,12 @@ <script setup lang="ts"> +import { Calendar, Icon, SquareGanttChart, TrainTrack } from 'lucide-vue-next'; + const route = useRouter(); const navList = ref([ - { title: "Agenda", path: "/agenda", navigating: false }, - { title: "Events", path: "/events", navigating: false }, - { title: "Tracks", path: "/tracks", navigating: false }, + { 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) => { @@ -26,14 +28,18 @@ route.afterEach(() => { <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> - + <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; diff --git a/components/Panel.vue b/components/Panel.vue index 8e72414..1e86ed1 100644 --- a/components/Panel.vue +++ b/components/Panel.vue @@ -1,4 +1,5 @@ <script setup lang="ts"> +import { ArrowRight } from 'lucide-vue-next'; import { defineProps, type PropType } from 'vue'; defineProps({ @@ -7,10 +8,27 @@ defineProps({ required: false, default: 'normal', }, + title: { + type: String, + required: false, + default: '', + }, + breadcrumbs: { + type: Array as PropType<Array<{ text: string, to: string }>>, + required: false, + default: () => [], + }, }); </script> <template> <div class="card" :class="kind"> + <div v-if="title" class="card-title-container"> + <span v-for="(breadcrumb, index) in breadcrumbs" class="breadcrumb" :key="index"> + <NuxtLink :to="breadcrumb.to">{{ breadcrumb.text }}</NuxtLink> + <ArrowRight /> + </span> + <span class="card-title"> {{ title }} </span> + </div> <slot /> </div> </template> @@ -21,6 +39,46 @@ defineProps({ border-radius: 0.5rem; } +.card-title-container { + line-height: 1; + background-color: var(--color-background-muted); + padding: 1rem 1rem; + margin: -1rem -1rem 1rem -1rem; + border-top-left-radius: 0.5rem; + border-top-right-radius: 0.5rem;; + border-bottom: 1px solid var(--color-border); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.card-title { + font-size: 1.5rem; + font-weight: 700; +} + +.breadcrumb { + font-size: 1.2rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.breadcrumb > svg { + width: 1.2rem; + height: 1.2rem; + color: var(--color-text-muted); +} + +.breadcrumb > a { + color: var(--color-text-muted); +} + +.breadcrumb > a:hover { + color: var(--color-primary); +} + .normal { background-color: white; border: 0.1rem solid var(--color-border); diff --git a/components/Sidebar.vue b/components/Sidebar.vue index 0dca27a..3d33586 100644 --- a/components/Sidebar.vue +++ b/components/Sidebar.vue @@ -45,6 +45,8 @@ const timeUntilConferenceStart = computed(() => { <div class="info"> <span>Times listed are in local time ({{ scheduleStore.schedule?.conference.timeZoneName }})</span> + + <Version /> </div> </div> </template> @@ -91,6 +93,9 @@ const timeUntilConferenceStart = computed(() => { 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/components/Version.vue b/components/Version.vue new file mode 100644 index 0000000..a5bca5e --- /dev/null +++ b/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 v{{ config.public.version }} ({{ config.public.gitSha }})</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> diff --git a/composables/fetch-favourites.ts b/composables/fetch-favourites.ts new file mode 100644 index 0000000..97b443a --- /dev/null +++ b/composables/fetch-favourites.ts @@ -0,0 +1,23 @@ +export default function useFetchFavourites() { + const favouritesStore = useFavouritesStore(); + const errorStore = useErrorStore(); + const config = useRuntimeConfig(); + + favouritesStore.setStatus('pending') + + useFetch(config.public.baseURL + '/favourites', { + method: 'GET', + server: false, + lazy: true, + onResponseError: ({ response }) => { + favouritesStore.setStatus('idle') + errorStore.setError(response._data.message || 'An unknown error occurred'); + }, + onResponse: ({ response }) => { + if (response._data) { + favouritesStore.setFavourites((response._data as any).data); + } + favouritesStore.setStatus('idle') + }, + }); +}
\ No newline at end of file diff --git a/composables/fetch-schedule.ts b/composables/fetch-schedule.ts new file mode 100644 index 0000000..09d0a25 --- /dev/null +++ b/composables/fetch-schedule.ts @@ -0,0 +1,24 @@ +export default function useFetchFavourites() { + const scheduleStore = useScheduleStore(); + const errorStore = useErrorStore(); + const config = useRuntimeConfig(); + + useFetch(config.public.baseURL + '/schedule', { + method: 'GET', + server: false, + lazy: true, + onResponse: ({ response }) => { + if (!response.ok) { + if (response.status === 401) { + navigateTo({ name: 'login', state: { error: 'Sorry, your session has expired' } }); + } else { + errorStore.setError(response._data.message || 'An unknown error occurred'); + } + } + + if (response._data) { + scheduleStore.setSchedule((response._data as any).data.schedule); + } + }, + }); +}
\ No newline at end of file diff --git a/layouts/default.vue b/layouts/default.vue index 92641a0..9c477aa 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -1,5 +1,5 @@ <script setup lang="ts"> -import { LucideMenu, LucideX } from "lucide-vue-next"; +import { BookHeart, LucideMenu, LucideX } from "lucide-vue-next"; import Dialog from "~/components/Dialog.vue"; import EventDetail from "~/components/EventDetail.vue"; import Sidebar from "~/components/Sidebar.vue"; @@ -9,9 +9,7 @@ definePageMeta({ }) const scheduleStore = useScheduleStore(); -const config = useRuntimeConfig() const selectedEventStore = useSelectedEventStore(); -const favouritesStore = useFavouritesStore(); const errorStore = useErrorStore(); const router = useRouter(); @@ -23,43 +21,8 @@ const refErrorDialog = ref<typeof Dialog>(); const showHamburger = ref(false); -useFetch(config.public.baseURL + '/schedule', { - method: 'GET', - server: false, - lazy: true, - onResponse: ({ response }) => { - if (!response.ok) { - if (response.status === 401) { - navigateTo('/login'); - } else { - errorStore.setError(response._data.message || 'An unknown error occurred'); - } - } - - if (response._data) { - scheduleStore.setSchedule((response._data as any).data.schedule); - } - }, -}); - -favouritesStore.setStatus('pending') - -await useFetch(config.public.baseURL + '/favourites', { - method: 'GET', - server: false, - lazy: true, - onResponseError: ({ response }) => { - favouritesStore.setStatus('idle') - errorStore.setError(response._data.message || 'An unknown error occurred'); - }, - onResponse: ({ response }) => { - if (response._data) { - favouritesStore.setFavourites((response._data as any).data); - } - favouritesStore.setStatus('idle') - }, -}); - +fetchSchedule(); +fetchFavourites(); watch(selectedEvent, () => { if (selectedEvent.value != null) { @@ -88,7 +51,7 @@ router.afterEach(() => { <div class="planner-container"> <header> <div class="planner-header"> - <h1 class="planner-title">Conference Planner</h1> + <span class="text-icon planner-title" @click="navigateTo('/')"><BookHeart /> confplanner</span> <span class="hamburger" @click="showHamburger = !showHamburger"> <LucideMenu :size="24" v-if="!showHamburger"/> <LucideX :size="24" v-else /> @@ -143,30 +106,38 @@ header { z-index: 9999; } -.planner-header { - background-color: var(--color-primary); - color: white; - padding: 1rem; +div.planner-header { + background-color: var(--color-background-muted); + color: var(--color-text-muted); + border-bottom: 2px solid var(--color-border); + height: 3.5rem; display: flex; justify-content: space-between; + align-items: center; z-index: 9999; - position: relative; + padding: 0 1rem; } -.planner-title { +span.planner-title { font-size: 1.5rem; font-weight: 700; } -.planner-content { +span.planner-title:hover { + cursor: pointer; + color: var(--color-primary); +} + +main.planner-content { max-width: 1000px; width: 100%; display: flex; flex-direction: column; gap: 1rem; + flex-grow: 1; } -.planner-layout { +div.planner-layout { display: flex; flex-direction: row; gap: 1rem; @@ -174,13 +145,15 @@ header { box-sizing: border-box; padding: 1rem; justify-content: center; + width: 100%; } -.planner-sidebar { +aside.planner-sidebar { width: 100%; max-width: 300px; position: sticky; - top: 0; + align-self: flex-start; + top: calc(4.5rem + 2px); } .loading-text { diff --git a/nuxt.config.ts b/nuxt.config.ts index 724f521..bb1bfce 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,4 +1,14 @@ -// https://nuxt.com/docs/api/configuration/nuxt-config +import { execSync } from "child_process"; +let gitSha: string | null = null; +let version: string | null = null; +try { + gitSha = execSync("git rev-parse HEAD").toString().trim().substring(0, 7); + version = execSync("git log -1 --format=%cd --date=format:'%Y.%m.%d'").toString().trim(); +} catch (e) { + gitSha = "git unknown"; + version = new Date().toISOString().slice(0, 10).replace(/-/g, "."); +} + export default defineNuxtConfig({ compatibilityDate: "2024-11-01", devtools: { enabled: true }, @@ -8,6 +18,8 @@ export default defineNuxtConfig({ runtimeConfig: { public: { baseURL: process.env.BASE_URL || "/api", + gitSha: gitSha, + version: version, }, }, @@ -24,4 +36,4 @@ export default defineNuxtConfig({ }, modules: ["@pinia/nuxt", "@vite-pwa/nuxt"], -});
\ No newline at end of file +}); diff --git a/pages/agenda.vue b/pages/agenda.vue index 97fdbad..7ef5f44 100644 --- a/pages/agenda.vue +++ b/pages/agenda.vue @@ -82,32 +82,26 @@ function deleteCalendar() { <template v-else-if="favouriteEvents.length > 0"> <div class="page"> - <Panel> - <h2 class="agenda-title">Agenda</h2> + <Panel title="Agenda"> <ul class="agenda-list"> - <li - v-for="event in favouriteEvents" - :key="event.id" - class="agenda-item" - > + <li v-for="event in favouriteEvents" :key="event.id" class="agenda-item" > <EventListing :event="event" /> </li> </ul> </Panel> <Panel> - <h2 class="calendar-title">Calendar</h2> <template v-if="calendarStatus === 'pending'"> <span>Fetching calendar status...</span> </template> <div v-else-if="calendarStatus === 'idle'" class="calendar"> <template v-if="calendarLink"> - <span>You can add your agenda to your own calendar using the iCal link below.</span> + <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> </template> <template v-else> - <span>You do not have a calendar link yet. Use the button below to request a calendar link synchronise with your own calendar app.</span> + <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> <Button @click="generateCalendar" :loading="calendarAction">Request calendar</Button> </template> </div> @@ -123,7 +117,7 @@ function deleteCalendar() { <style scoped> .agenda-list { list-style: none; - margin: 0; + margin: -1rem 0; padding: 0; display: grid; } diff --git a/pages/events.vue b/pages/events.vue index 080bd8f..bfbe553 100644 --- a/pages/events.vue +++ b/pages/events.vue @@ -6,9 +6,8 @@ const scheduleStore = useScheduleStore(); </script> <template> - <Panel v-if="scheduleStore.schedule"> - <h2 class="events-title">Events</h2> - <div v-for="[day, events] of Object.entries(scheduleStore.eventsPerDay)" :key="day"> + <Panel v-if="scheduleStore.schedule" title="Events"> + <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" /> @@ -19,6 +18,10 @@ const scheduleStore = useScheduleStore(); </template> <style> +.events-container { + margin: -1rem 0; +} + .events-list { list-style: none; margin: 0; @@ -30,10 +33,6 @@ const scheduleStore = useScheduleStore(); border-bottom: 1px solid var(--color-background-muted); } -.events-title { - margin-bottom: 1rem; -} - .event-item:last-child { border-bottom: none; } diff --git a/pages/login.vue b/pages/login.vue index 2c38f61..06d1b77 100644 --- a/pages/login.vue +++ b/pages/login.vue @@ -40,6 +40,12 @@ const handleSubmit = async (e: Event) => { isLoading.value = false } +onMounted(() => { + if (history.state.error) { + error.value = history.state.error as string + } +}) + </script> <template> @@ -79,8 +85,11 @@ const handleSubmit = async (e: Event) => { Sign in </Button> </div> + + <Version class="version" /> </form> </Panel> + </div> <div class="form-footer"> @@ -88,6 +97,7 @@ const handleSubmit = async (e: Event) => { Register </NuxtLink> </div> + </div> </template> @@ -131,7 +141,8 @@ const handleSubmit = async (e: Event) => { } .auth-error { - color: var(--color-error); + color: var(--color-text-error); + font-style: oblique; } .form-group { @@ -157,6 +168,12 @@ const handleSubmit = async (e: Event) => { max-width: 28rem; } +.version { + font-size: var(--text-smaller); + margin: 0 auto; + color: var(--color-text-muted-light); +} + .register-link { font-size: var(--text-small); font-weight: 500; diff --git a/pages/register.vue b/pages/register.vue index ad9a041..b06578f 100644 --- a/pages/register.vue +++ b/pages/register.vue @@ -79,6 +79,8 @@ const handleSubmit = async (e: Event) => { Register </Button> </div> + + <Version class="version" /> </form> </Panel> </div> @@ -166,6 +168,17 @@ const handleSubmit = async (e: Event) => { display: flex; } +.auth-error { + color: var(--color-text-error); + font-style: oblique; +} + +.version { + font-size: var(--text-smaller); + margin: 0 auto; + color: var(--color-text-muted-light); +} + input[name="username"] { text-transform: lowercase; } diff --git a/pages/tracks/[slug].vue b/pages/tracks/[slug].vue index 38bce61..85f00b6 100644 --- a/pages/tracks/[slug].vue +++ b/pages/tracks/[slug].vue @@ -8,24 +8,23 @@ const track = scheduleStore.schedule?.tracks.find((track) => track.slug === rout </script> <template> - <Panel v-if="track"> - <h2 class="events-title">{{ track.name }}</h2> - <ul class="events-list"> - <li - v-for="event in scheduleStore.eventsPerTrack[track.name]" - :key="event.id" - class="event-item" - > - <EventListing :event="event" /> - </li> - </ul> + <Panel v-if="track" :title="track.name" :breadcrumbs="[{ text: 'Tracks', to: '/tracks' }]"> + <ul class="events-list"> + <li + v-for="event in scheduleStore.eventsPerTrack[track.name]" + :key="event.id" + class="event-item" + > + <EventListing :event="event" /> + </li> + </ul> </Panel> </template> <style> .events-list { list-style: none; - margin: 0; + margin: -1rem 0; padding: 0; display: grid; } diff --git a/pages/tracks/index.vue b/pages/tracks/index.vue index 35641ab..544847b 100644 --- a/pages/tracks/index.vue +++ b/pages/tracks/index.vue @@ -5,8 +5,7 @@ const scheduleStore = useScheduleStore(); </script> <template> - <Panel v-if="scheduleStore.schedule"> - <h2 class="tracks-title">Tracks</h2> + <Panel v-if="scheduleStore.schedule" title="Tracks"> <ul class="tracks-list"> <li v-for="track in scheduleStore.schedule.tracks" @@ -24,7 +23,7 @@ const scheduleStore = useScheduleStore(); <style scoped> .tracks-list { list-style: none; - margin: 0.5rem 0 0 0; + margin: -0.5rem 0 0 0; padding: 0; display: grid; } diff --git a/stores/error.ts b/stores/error.ts index dfb5850..2c234f4 100644 --- a/stores/error.ts +++ b/stores/error.ts @@ -1,4 +1,3 @@ -import { type Event } from "./schedule"; import { defineStore } from "pinia"; export const useErrorStore = defineStore('error', () => { |
