Merge remote-tracking branch 'Origin/main' into training-report-cbox-reset
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { toTypedSchema } from "@vee-validate/zod"
|
||||
import { ref, defineExpose, watch } from "vue"
|
||||
import { ref, defineExpose, watch, nextTick } from "vue"
|
||||
import * as z from "zod"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -21,11 +21,18 @@ import {
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import Textarea from "../ui/textarea/Textarea.vue"
|
||||
import { CalendarEvent } from "@/api/calendar"
|
||||
import { CalendarEvent } from "@shared/types/calendar"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
|
||||
// ---------- helpers ----------
|
||||
const dateRe = /^\d{4}-\d{2}-\d{2}$/ // YYYY-MM-DD
|
||||
const timeRe = /^(?:[01]\d|2[0-3]):[0-5]\d$/ // HH:mm (24h)
|
||||
|
||||
function toLocalDateString(d: Date) {
|
||||
// yyyy-MM-dd with local time zone
|
||||
@@ -45,45 +52,50 @@ function roundToNextHour(d = new Date()) {
|
||||
t.setHours(t.getHours() + 1)
|
||||
return t
|
||||
}
|
||||
function parseLocalDateTime(dateStr: string, timeStr: string) {
|
||||
// Construct a Date in the user's local timezone
|
||||
const [y, m, d] = dateStr.split("-").map(Number)
|
||||
const [hh, mm] = timeStr.split(":").map(Number)
|
||||
return new Date(y, m - 1, d, hh, mm, 0, 0)
|
||||
}
|
||||
|
||||
// ---------- schema ----------
|
||||
const zEvent = z.object({
|
||||
name: z.string().min(2, "Please enter at least 2 characters").max(100),
|
||||
startDate: z.string().regex(dateRe, "Use YYYY-MM-DD"),
|
||||
startTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
|
||||
endDate: z.string().regex(dateRe, "Use YYYY-MM-DD"),
|
||||
endTime: z.string().regex(timeRe, "Use HH:mm (24h)"),
|
||||
location: z.string().max(200).default(""),
|
||||
color: z.string().regex(/^#([0-9A-Fa-f]{6})$/, "Use a hex color like #AABBCC"),
|
||||
description: z.string().max(2000).default(""),
|
||||
id: z.number().int().nonnegative().nullable().default(null),
|
||||
}).superRefine((vals, ctx) => {
|
||||
const start = parseLocalDateTime(vals.startDate, vals.startTime)
|
||||
const end = parseLocalDateTime(vals.endDate, vals.endTime)
|
||||
if (!(end > start)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "End must be after start",
|
||||
path: ["endTime"], // attach to a visible field
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const formSchema = toTypedSchema(zEvent)
|
||||
import { calendarEventSchema, parseLocalDateTime } from '@shared/schemas/calendarEventSchema'
|
||||
import { createCalendarEvent, editCalendarEvent } from "@/api/calendar"
|
||||
import DialogDescription from "../ui/dialog/DialogDescription.vue"
|
||||
const formSchema = toTypedSchema(calendarEventSchema)
|
||||
|
||||
// ---------- dialog state & defaults ----------
|
||||
const clickedDate = ref<string | null>(null);
|
||||
const dialogOpen = ref(false)
|
||||
function openDialog() { dialogOpen.value = true }
|
||||
const dialogMode = ref<'create' | 'edit'>('create');
|
||||
const editEvent = ref<CalendarEvent | null>();
|
||||
function openDialog(dateStr?: string, mode?: 'create' | 'edit', event?: CalendarEvent) {
|
||||
dialogMode.value = mode ?? 'create';
|
||||
editEvent.value = event ?? null;
|
||||
clickedDate.value = dateStr ?? null;
|
||||
dialogOpen.value = true
|
||||
initialValues.value = makeInitialValues()
|
||||
}
|
||||
defineExpose({ openDialog })
|
||||
|
||||
function makeInitialValues() {
|
||||
const start = roundToNextHour()
|
||||
|
||||
if (dialogMode.value === 'edit' && editEvent.value) {
|
||||
const e = editEvent.value;
|
||||
return {
|
||||
name: e.name,
|
||||
startDate: toLocalDateString(new Date(e.start)),
|
||||
startTime: toLocalTimeString(new Date(e.start)),
|
||||
endDate: toLocalDateString(new Date(e.end)),
|
||||
endTime: toLocalTimeString(new Date(e.end)),
|
||||
location: e.location,
|
||||
color: e.color,
|
||||
description: e.description,
|
||||
id: e.id,
|
||||
}
|
||||
}
|
||||
|
||||
let start: Date;
|
||||
if (clickedDate.value) {
|
||||
const local = new Date(clickedDate.value + "T20:00:00");
|
||||
start = local;
|
||||
} else {
|
||||
start = roundToNextHour();
|
||||
}
|
||||
const end = new Date(start.getTime() + 60 * 60 * 1000)
|
||||
return {
|
||||
name: "",
|
||||
@@ -92,50 +104,77 @@ function makeInitialValues() {
|
||||
endDate: toLocalDateString(end),
|
||||
endTime: toLocalTimeString(end),
|
||||
location: "",
|
||||
color: "#3b82f6",
|
||||
color: "#6890ee",
|
||||
description: "",
|
||||
id: null as number | null,
|
||||
}
|
||||
}
|
||||
const initialValues = ref(makeInitialValues())
|
||||
const initialValues = ref(null)
|
||||
|
||||
const formKey = ref(0)
|
||||
|
||||
watch(dialogOpen, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
formKey.value++ // remounts the form -> picks up fresh initialValues
|
||||
watch(dialogOpen, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
await nextTick();
|
||||
formRef.value?.resetForm({ values: makeInitialValues() })
|
||||
}
|
||||
})
|
||||
// ---------- submit ----------
|
||||
function onSubmit(vals: z.infer<typeof zEvent>) {
|
||||
async function onSubmit(vals: z.infer<typeof calendarEventSchema>) {
|
||||
const start = parseLocalDateTime(vals.startDate, vals.startTime)
|
||||
const end = parseLocalDateTime(vals.endDate, vals.endTime)
|
||||
|
||||
const event: CalendarEvent = {
|
||||
id: vals.id ?? null,
|
||||
name: vals.name,
|
||||
start,
|
||||
end,
|
||||
location: vals.location,
|
||||
color: vals.color,
|
||||
description: vals.description,
|
||||
id: null,
|
||||
creator: null
|
||||
}
|
||||
|
||||
console.log("Submitting CalendarEvent:", event)
|
||||
try {
|
||||
if (dialogMode.value === "edit") {
|
||||
await editCalendarEvent(event);
|
||||
} else {
|
||||
await createCalendarEvent(event);
|
||||
}
|
||||
emit('reload');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// close after success
|
||||
dialogOpen.value = false
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'reload'): void
|
||||
}>()
|
||||
const formRef = ref(null);
|
||||
|
||||
const colorOptions = [
|
||||
{ name: "Blue", hex: "#6890ee" },
|
||||
{ name: "Purple", hex: "#a25fce" },
|
||||
{ name: "Orange", hex: "#fba037" },
|
||||
{ name: "Green", hex: "#6cd265" },
|
||||
{ name: "Red", hex: "#ff5959" },
|
||||
];
|
||||
|
||||
function getColorName(hex: string) {
|
||||
return colorOptions.find(c => c.hex === hex)?.name ?? hex
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form :key="formKey" v-slot="{ handleSubmit, resetForm }" :validation-schema="formSchema"
|
||||
<Form ref="formRef" :key="formKey" v-slot="{ handleSubmit, resetForm }" :validation-schema="formSchema"
|
||||
:initial-values="initialValues" keep-values as="">
|
||||
<Dialog v-model:open="dialogOpen">
|
||||
<DialogContent class="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Event</DialogTitle>
|
||||
<DialogTitle>{{ dialogMode == "edit" ? 'Edit Event' : 'Create Event' }}</DialogTitle>
|
||||
<DialogDescription></DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="dialogForm" class="grid grid-cols-1 gap-4"
|
||||
@@ -150,21 +189,48 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
|
||||
<FormControl>
|
||||
<Input type="text" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div class="h-3">
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="w-[60px]">
|
||||
<div class="w-[120px]">
|
||||
<FormField v-slot="{ componentField }" name="color">
|
||||
<FormItem>
|
||||
<FormLabel>Color</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="color" class="h-[38px] p-1 cursor-pointer"
|
||||
v-bind="componentField" />
|
||||
<Select :modelValue="componentField.modelValue"
|
||||
@update:modelValue="componentField.onChange">
|
||||
<SelectTrigger>
|
||||
<SelectValue asChild>
|
||||
<template #default="{ selected }">
|
||||
<div class="flex items-center gap-2 w-[70px]">
|
||||
<span class="inline-block size-4 rounded"
|
||||
:style="{ background: componentField.modelValue }">
|
||||
</span>
|
||||
<span>{{ getColorName(componentField.modelValue) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="opt in colorOptions" :key="opt.hex" :value="opt.hex">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block size-4 rounded"
|
||||
:style="{ background: opt.hex }"></span>
|
||||
<span>{{ opt.name }}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div class="h-3">
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -180,7 +246,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
|
||||
<FormControl>
|
||||
<Input type="date" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div class="h-3">
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
@@ -191,7 +259,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
|
||||
<Input type="text" v-bind="componentField" />
|
||||
<!-- If you ever want native picker: type="time" -->
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div class="h-3">
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -204,7 +274,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
|
||||
<FormControl>
|
||||
<Input type="date" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div class="h-3">
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
@@ -214,7 +286,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
|
||||
<FormControl>
|
||||
<Input type="text" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div class="h-3">
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -226,7 +300,9 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
|
||||
<FormControl>
|
||||
<Input type="text" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div class="h-3">
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
@@ -236,9 +312,11 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea class="resize-none h-32" v-bind="componentField" />
|
||||
<Textarea class="resize-none h-32 scrollbar-themed" v-bind="componentField" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<div class="h-3">
|
||||
<FormMessage/>
|
||||
</div>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
@@ -249,9 +327,39 @@ function onSubmit(vals: z.infer<typeof zEvent>) {
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" form="dialogForm">Create</Button>
|
||||
<Button type="submit" form="dialogForm">{{ dialogMode == "edit" ? 'Update' : 'Create' }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Firefox */
|
||||
.scrollbar-themed {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #555 #1f1f1f;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
/* Chrome, Edge, Safari */
|
||||
.scrollbar-themed::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
/* slightly wider to allow padding look */
|
||||
}
|
||||
|
||||
.scrollbar-themed::-webkit-scrollbar-track {
|
||||
background: #1f1f1f;
|
||||
margin-left: 6px;
|
||||
/* ❗ adds space between content + scrollbar */
|
||||
}
|
||||
|
||||
.scrollbar-themed::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.scrollbar-themed::-webkit-scrollbar-thumb:hover {
|
||||
background: #777;
|
||||
}
|
||||
</style>
|
||||
226
ui/src/components/calendar/ViewCalendarEvent.vue
Normal file
226
ui/src/components/calendar/ViewCalendarEvent.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import type { CalendarEvent, CalendarSignup } from '@shared/types/calendar'
|
||||
import { CircleAlert, Clock, EllipsisVertical, MapPin, User, X } from 'lucide-vue-next';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
|
||||
import Button from '../ui/button/Button.vue';
|
||||
import { CalendarAttendance, getCalendarEvent, setCalendarEventAttendance, setCancelCalendarEvent } from '@/api/calendar';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useRoute } from 'vue-router';
|
||||
import DropdownMenu from '../ui/dropdown-menu/DropdownMenu.vue';
|
||||
import DropdownMenuTrigger from '../ui/dropdown-menu/DropdownMenuTrigger.vue';
|
||||
import DropdownMenuContent from '../ui/dropdown-menu/DropdownMenuContent.vue';
|
||||
import DropdownMenuItem from '../ui/dropdown-menu/DropdownMenuItem.vue';
|
||||
|
||||
const route = useRoute();
|
||||
// const eventID = computed(() => {
|
||||
// const id = route.params.id;
|
||||
// if (typeof id === 'string') return id;
|
||||
// return undefined;
|
||||
// });
|
||||
|
||||
const loaded = ref<boolean>(false);
|
||||
const activeEvent = ref<CalendarEvent | null>(null);
|
||||
|
||||
// onMounted(async () => {
|
||||
// let eventID = route.params.id;
|
||||
// console.log(eventID);
|
||||
// activeEvent.value = await getCalendarEvent(Number(eventID));
|
||||
// loaded.value = true;
|
||||
// });
|
||||
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async (id) => {
|
||||
if (!id) return;
|
||||
activeEvent.value = await getCalendarEvent(Number(id));
|
||||
loaded.value = true;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'reload'): void
|
||||
(e: 'edit', event: CalendarEvent): 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)}`
|
||||
})
|
||||
|
||||
const attending = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Attending) })
|
||||
const maybe = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.Maybe) })
|
||||
const declined = computed<CalendarSignup[]>(() => { return activeEvent.value.eventSignups.filter((s) => s.status == CalendarAttendance.NotAttending) })
|
||||
const viewedState = ref<CalendarAttendance>(CalendarAttendance.Attending);
|
||||
|
||||
let user = useUserStore();
|
||||
const myAttendance = computed<CalendarSignup | null>(() => {
|
||||
return activeEvent.value.eventSignups.find(
|
||||
(s) => s.member_id === user.user.id
|
||||
) || null;
|
||||
});
|
||||
|
||||
async function setAttendance(state: CalendarAttendance) {
|
||||
await setCalendarEventAttendance(activeEvent.value.id, state);
|
||||
//refresh event data
|
||||
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
|
||||
}
|
||||
|
||||
const canEditEvent = computed(() => {
|
||||
if (user.user.id == activeEvent.value.creator_id)
|
||||
return true;
|
||||
});
|
||||
|
||||
async function setCancel(isCancelled: boolean) {
|
||||
await setCancelCalendarEvent(activeEvent.value.id, isCancelled);
|
||||
emit("reload");
|
||||
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
|
||||
}
|
||||
|
||||
async function forceReload() {
|
||||
activeEvent.value = await getCalendarEvent(activeEvent.value.id);
|
||||
}
|
||||
|
||||
defineExpose({forceReload})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loaded">
|
||||
<!-- 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>
|
||||
<div class="flex gap-4">
|
||||
<DropdownMenu v-if="canEditEvent">
|
||||
<DropdownMenuTrigger>
|
||||
<button
|
||||
class="inline-flex flex-none size-8 items-center justify-center cursor-pointer rounded-md hover:bg-muted/40 transition">
|
||||
<EllipsisVertical class="size-6" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem @click="emit('edit', activeEvent)">
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="activeEvent.cancelled"
|
||||
@click="setCancel(false)">
|
||||
Un-Cancel
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem v-else @click="setCancel(true)">
|
||||
Cancel
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
class="inline-flex flex-none size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition cursor-pointer"
|
||||
aria-label="Close" @click="emit('close')">
|
||||
<X class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Body -->
|
||||
<div class="flex-1 flex flex-col items-center min-h-0 overflow-y-auto px-4 py-4 space-y-6 w-full">
|
||||
<section v-if="activeEvent.cancelled == true" class="w-full">
|
||||
<div class="flex p-2 rounded-md w-full bg-destructive gap-3">
|
||||
<CircleAlert></CircleAlert> This event has been cancelled
|
||||
</div>
|
||||
</section>
|
||||
<section class="w-full">
|
||||
<ButtonGroup class="flex w-full">
|
||||
<Button variant="outline"
|
||||
:class="myAttendance?.status === CalendarAttendance.Attending ? 'border-2 border-primary text-primary' : ''"
|
||||
@click="setAttendance(CalendarAttendance.Attending)">Going</Button>
|
||||
<Button variant="outline"
|
||||
:class="myAttendance?.status === CalendarAttendance.Maybe ? 'border-2 !border-l-2 border-primary text-primary' : ''"
|
||||
@click="setAttendance(CalendarAttendance.Maybe)">Maybe</Button>
|
||||
<Button variant="outline"
|
||||
:class="myAttendance?.status === CalendarAttendance.NotAttending ? 'border-2 !border-l-2 border-primary text-primary' : ''"
|
||||
@click="setAttendance(CalendarAttendance.NotAttending)">Declined</Button>
|
||||
</ButtonGroup>
|
||||
</section>
|
||||
<!-- When -->
|
||||
<section v-if="whenText" class="space-y-2 w-full">
|
||||
<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 w-full">
|
||||
<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 w-full">
|
||||
<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 whitespace-pre-line">
|
||||
{{ activeEvent.description }}
|
||||
</p>
|
||||
</section>
|
||||
<!-- Attendance -->
|
||||
<section class="space-y-2 w-full">
|
||||
<p class="text-lg font-semibold">Attendance</p>
|
||||
<div class="flex flex-col border bg-muted/50 rounded-lg min-h-24 my-2">
|
||||
<div class="flex w-full pt-2 border-b *:w-full *:text-center *:pb-1 *:cursor-pointer">
|
||||
<label
|
||||
:class="viewedState === CalendarAttendance.Attending ? 'border-b-3 border-foreground' : 'mb-[2px]'"
|
||||
@click="viewedState = CalendarAttendance.Attending">Going {{ attending.length }}</label>
|
||||
<label
|
||||
:class="viewedState === CalendarAttendance.Maybe ? 'border-b-3 border-foreground' : 'mb-[2px]'"
|
||||
@click="viewedState = CalendarAttendance.Maybe">Maybe {{ maybe.length }}</label>
|
||||
<label
|
||||
:class="viewedState === CalendarAttendance.NotAttending ? 'border-b-3 border-foreground' : 'mb-[2px]'"
|
||||
@click="viewedState = CalendarAttendance.NotAttending">Declined {{ declined.length
|
||||
}}</label>
|
||||
</div>
|
||||
<div class="px-5 py-4 min-h-28">
|
||||
<div v-if="viewedState === CalendarAttendance.Attending" v-for="person in attending">
|
||||
<p>{{ person.member_name }}</p>
|
||||
</div>
|
||||
<div v-if="viewedState === CalendarAttendance.Maybe" v-for="person in maybe">
|
||||
<p>{{ person.member_name }}</p>
|
||||
</div>
|
||||
<div v-if="viewedState === CalendarAttendance.NotAttending" v-for="person in declined">
|
||||
<p>{{ person.member_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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",
|
||||
},
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user