split event view into seperate component
This commit is contained in:
105
ui/src/components/calendar/ViewCalendarEvent.vue
Normal file
105
ui/src/components/calendar/ViewCalendarEvent.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CalendarEvent } from '@shared/types/calendar'
|
||||||
|
import { Clock, MapPin, User, X } from 'lucide-vue-next';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
|
||||||
|
import Button from '../ui/button/Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
event: CalendarEvent | null
|
||||||
|
onClose?: () => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const activeEvent = computed(() => props.event)
|
||||||
|
|
||||||
|
const startFmt = new Intl.DateTimeFormat(undefined, {
|
||||||
|
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
|
||||||
|
hour: 'numeric', minute: '2-digit'
|
||||||
|
})
|
||||||
|
const endFmt = new Intl.DateTimeFormat(undefined, {
|
||||||
|
hour: 'numeric', minute: '2-digit'
|
||||||
|
})
|
||||||
|
|
||||||
|
const whenText = computed(() => {
|
||||||
|
if (!activeEvent.value?.start) return ''
|
||||||
|
const s = new Date(activeEvent.value.start)
|
||||||
|
const e = activeEvent.value?.end ? new Date(activeEvent.value.end) : null
|
||||||
|
return e
|
||||||
|
? `${startFmt.format(s)} – ${endFmt.format(e)}`
|
||||||
|
: `${startFmt.format(s)}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between gap-3 border-b px-4 py-3">
|
||||||
|
<h2 class="text-lg font-semibold break-all">
|
||||||
|
{{ activeEvent?.name || 'Event' }}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
class="inline-flex flex-none size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition"
|
||||||
|
aria-label="Close" @click="emit('close')">
|
||||||
|
<X class="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-6">
|
||||||
|
<section>
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button variant="outline">Going</Button>
|
||||||
|
<Button variant="outline">Maybe</Button>
|
||||||
|
<Button variant="outline">Declined</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</section>
|
||||||
|
<!-- When -->
|
||||||
|
<section v-if="whenText" class="space-y-2">
|
||||||
|
<div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
|
||||||
|
<Clock class="size-4 opacity-80" />
|
||||||
|
<span class="font-medium">{{ whenText }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- Quick meta chips -->
|
||||||
|
<section class="flex flex-wrap gap-2">
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
|
||||||
|
<MapPin class="size-3.5 opacity-80" />
|
||||||
|
<span class="font-medium">{{ activeEvent.location || "Unknown" }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
|
||||||
|
<User class="size-3.5 opacity-80" />
|
||||||
|
<span class="font-medium">Created by: {{ activeEvent.creator_name || "Unknown User"
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
<!-- Description -->
|
||||||
|
<section class="space-y-2">
|
||||||
|
<p class="text-lg font-semibold">Description</p>
|
||||||
|
<p class="border bg-muted/50 px-3 py-2 rounded-lg min-h-24 my-2">
|
||||||
|
{{ activeEvent.description }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<!-- Attendance -->
|
||||||
|
<section class="space-y-2">
|
||||||
|
<p class="text-lg font-semibold">Attendance</p>
|
||||||
|
<!-- <p class="border bg-muted/50 px-3 py-2 rounded-lg min-h-24 my-2">
|
||||||
|
{{ activeEvent.description }}
|
||||||
|
</p> -->
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<!-- Footer (optional actions) -->
|
||||||
|
<div class="border-t px-4 py-3 flex items-center justify-end gap-2">
|
||||||
|
<button class="rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40 transition">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm hover:opacity-90 transition">
|
||||||
|
Open details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
22
ui/src/components/ui/button-group/ButtonGroup.vue
Normal file
22
ui/src/components/ui/button-group/ButtonGroup.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup>
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonGroupVariants } from ".";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
orientation: { type: null, required: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
:data-orientation="props.orientation"
|
||||||
|
:class="
|
||||||
|
cn(buttonGroupVariants({ orientation: props.orientation }), props.class)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
28
ui/src/components/ui/button-group/ButtonGroupSeparator.vue
Normal file
28
ui/src/components/ui/button-group/ButtonGroupSeparator.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
orientation: { type: String, required: false, default: "vertical" },
|
||||||
|
decorative: { type: Boolean, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Separator
|
||||||
|
data-slot="button-group-separator"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:orientation="props.orientation"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
29
ui/src/components/ui/button-group/ButtonGroupText.vue
Normal file
29
ui/src/components/ui/button-group/ButtonGroupText.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup>
|
||||||
|
import { Primitive } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
class: { type: null, required: false },
|
||||||
|
orientation: { type: null, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false, default: "div" },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
role="group"
|
||||||
|
data-slot="button-group"
|
||||||
|
:data-orientation="props.orientation"
|
||||||
|
:as="as"
|
||||||
|
:as-child="asChild"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
22
ui/src/components/ui/button-group/index.js
Normal file
22
ui/src/components/ui/button-group/index.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
export { default as ButtonGroup } from "./ButtonGroup.vue";
|
||||||
|
export { default as ButtonGroupSeparator } from "./ButtonGroupSeparator.vue";
|
||||||
|
export { default as ButtonGroupText } from "./ButtonGroupText.vue";
|
||||||
|
|
||||||
|
export const buttonGroupVariants = cva(
|
||||||
|
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
orientation: {
|
||||||
|
horizontal:
|
||||||
|
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||||
|
vertical:
|
||||||
|
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
orientation: "horizontal",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -8,6 +8,7 @@ import CreateCalendarEvent from '@/components/calendar/CreateCalendarEvent.vue'
|
|||||||
import { getCalendarEvent, getMonthCalendarEvents } from '@/api/calendar'
|
import { getCalendarEvent, getMonthCalendarEvents } from '@/api/calendar'
|
||||||
import { CalendarEvent, CalendarEventShort } from '@shared/types/calendar'
|
import { CalendarEvent, CalendarEventShort } from '@shared/types/calendar'
|
||||||
import { Calendar } from '@fullcalendar/core'
|
import { Calendar } from '@fullcalendar/core'
|
||||||
|
import ViewCalendarEvent from '@/components/calendar/ViewCalendarEvent.vue'
|
||||||
|
|
||||||
const monthLabels = [
|
const monthLabels = [
|
||||||
'January', 'February', 'March', 'April', 'May', 'June',
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
@@ -87,7 +88,7 @@ const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null)
|
|||||||
async function onEventClick(arg: any) {
|
async function onEventClick(arg: any) {
|
||||||
const targetEvent = arg.event.id;
|
const targetEvent = arg.event.id;
|
||||||
activeEvent.value = await getCalendarEvent(targetEvent);
|
activeEvent.value = await getCalendarEvent(targetEvent);
|
||||||
console.log(activeEvent.value);
|
console.log(activeEvent.value);
|
||||||
// activeEvent.value = {
|
// activeEvent.value = {
|
||||||
// id: arg.event.id,
|
// id: arg.event.id,
|
||||||
// title: arg.event.title,
|
// title: arg.event.title,
|
||||||
@@ -176,24 +177,6 @@ watch(panelOpen, async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const startFmt = new Intl.DateTimeFormat(undefined, {
|
|
||||||
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
|
|
||||||
hour: 'numeric', minute: '2-digit'
|
|
||||||
})
|
|
||||||
const endFmt = new Intl.DateTimeFormat(undefined, {
|
|
||||||
hour: 'numeric', minute: '2-digit'
|
|
||||||
})
|
|
||||||
|
|
||||||
const whenText = computed(() => {
|
|
||||||
if (!activeEvent.value?.start) return ''
|
|
||||||
const s = new Date(activeEvent.value.start)
|
|
||||||
const e = activeEvent.value?.end ? new Date(activeEvent.value.end) : null
|
|
||||||
return e
|
|
||||||
? `${startFmt.format(s)} – ${endFmt.format(e)}`
|
|
||||||
: `${startFmt.format(s)}`
|
|
||||||
})
|
|
||||||
|
|
||||||
function onCreateEvent() {
|
function onCreateEvent() {
|
||||||
const iso = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
|
const iso = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
|
||||||
onDateClick({ dateStr: iso })
|
onDateClick({ dateStr: iso })
|
||||||
@@ -262,82 +245,10 @@ onMounted(() => {
|
|||||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<aside v-if="panelOpen && activeEvent" class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col"
|
<aside v-if="panelOpen && activeEvent"
|
||||||
|
class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col"
|
||||||
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
|
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
|
||||||
<!-- Header -->
|
<ViewCalendarEvent @close="() => {panelOpen = false; activeEvent = null;}" :event="activeEvent"></ViewCalendarEvent>
|
||||||
<div class="flex items-center justify-between gap-3 border-b px-4 py-3">
|
|
||||||
<h2 class="text-lg font-semibold break-all">
|
|
||||||
{{ activeEvent?.name || 'Event' }}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
class="inline-flex flex-none size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition"
|
|
||||||
aria-label="Close" @click="panelOpen = false">
|
|
||||||
<X class="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Body -->
|
|
||||||
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-6">
|
|
||||||
<!-- When -->
|
|
||||||
<section v-if="whenText" class="space-y-2">
|
|
||||||
<div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
|
|
||||||
<Clock class="size-4 opacity-80" />
|
|
||||||
<span class="font-medium">{{ whenText }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<!-- Quick meta chips -->
|
|
||||||
<section class="flex flex-wrap gap-2">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
|
|
||||||
<MapPin class="size-3.5 opacity-80" />
|
|
||||||
<span class="font-medium">{{ activeEvent.location || "Unknown" }}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
|
|
||||||
<User class="size-3.5 opacity-80" />
|
|
||||||
<span class="font-medium">Created by: {{ activeEvent.creator_name || "Unknown User" }}</span>
|
|
||||||
</span>
|
|
||||||
</section>
|
|
||||||
<!-- Agenda (special-cased array) -->
|
|
||||||
<!-- <section v-if="Array.isArray(ext.agenda) && ext.agenda.length" class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<ListTodo class="size-4 opacity-80" />
|
|
||||||
Agenda
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-1.5">
|
|
||||||
<li v-for="(item, i) in ext.agenda" :key="i" class="flex items-start gap-2 text-sm">
|
|
||||||
<span class="mt-1.5 size-1.5 rounded-full bg-foreground/50"></span>
|
|
||||||
<span>{{ item }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section> -->
|
|
||||||
<!-- Generic details (extendedProps minus the ones above) -->
|
|
||||||
<!-- <section v-if="ext && Object.keys(ext).length" class="space-y-3">
|
|
||||||
<div class="text-sm font-medium opacity-80">Details</div>
|
|
||||||
<dl class="grid grid-cols-1 gap-y-3">
|
|
||||||
<template v-for="(val, key) in ext" :key="key">
|
|
||||||
<template v-if="!['agenda', 'owner', 'trainer', 'location'].includes(String(key))">
|
|
||||||
<div class="grid grid-cols-[120px_1fr] items-start gap-3">
|
|
||||||
<dt class="text-xs uppercase tracking-wide opacity-60">{{ key }}</dt>
|
|
||||||
<dd class="text-sm">
|
|
||||||
<span v-if="Array.isArray(val)">{{ val.join(', ') }}</span>
|
|
||||||
<span v-else>{{ String(val) }}</span>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</dl>
|
|
||||||
</section> -->
|
|
||||||
</div>
|
|
||||||
<!-- Footer (optional actions) -->
|
|
||||||
<div class="border-t px-4 py-3 flex items-center justify-end gap-2">
|
|
||||||
<button class="rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40 transition">
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm hover:opacity-90 transition">
|
|
||||||
Open details
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user