built create event UI

This commit is contained in:
2025-10-11 14:04:27 -04:00
parent e158427c93
commit b268ee46e1
3 changed files with 318 additions and 11 deletions

42
ui/src/api/calendar.ts Normal file
View File

@@ -0,0 +1,42 @@
export interface CalendarEvent {
name: string,
start: Date,
end: Date,
location: string,
color: string,
description: string,
creator: any | null, // user object
id: number | null
}
export enum CalendarAttendance {
Attending = "attending",
NotAttending = "not_attending",
Maybe = "maybe"
}
export interface CalendarSignup {
memberID: number,
eventID: number,
state: CalendarAttendance
}
export async function createCalendarEvent(eventData: CalendarEvent) {
}
export async function editCalendarEvent(eventData: CalendarEvent) {
}
export async function cancelCalendarEvent(eventID: number) {
}
export async function adminCancelCalendarEvent(eventID: number) {
}
export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) {
}

View File

@@ -0,0 +1,257 @@
<script setup lang="ts">
import { toTypedSchema } from "@vee-validate/zod"
import { ref, defineExpose, watch } from "vue"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import Textarea from "../ui/textarea/Textarea.vue"
import { CalendarEvent } from "@/api/calendar"
// ---------- 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
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, "0")
const day = String(d.getDate()).padStart(2, "0")
return `${y}-${m}-${day}`
}
function toLocalTimeString(d: Date) {
const hh = String(d.getHours()).padStart(2, "0")
const mm = String(d.getMinutes()).padStart(2, "0")
return `${hh}:${mm}`
}
function roundToNextHour(d = new Date()) {
const t = new Date(d)
t.setMinutes(0, 0, 0)
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)
// ---------- dialog state & defaults ----------
const dialogOpen = ref(false)
function openDialog() { dialogOpen.value = true }
defineExpose({ openDialog })
function makeInitialValues() {
const start = roundToNextHour()
const end = new Date(start.getTime() + 60 * 60 * 1000)
return {
name: "",
startDate: toLocalDateString(start),
startTime: toLocalTimeString(start),
endDate: toLocalDateString(end),
endTime: toLocalTimeString(end),
location: "",
color: "#3b82f6",
description: "",
id: null as number | null,
}
}
const initialValues = ref(makeInitialValues())
const formKey = ref(0)
watch(dialogOpen, (isOpen) => {
if (!isOpen) {
formKey.value++ // remounts the form -> picks up fresh initialValues
}
})
// ---------- submit ----------
function onSubmit(vals: z.infer<typeof zEvent>) {
const start = parseLocalDateTime(vals.startDate, vals.startTime)
const end = parseLocalDateTime(vals.endDate, vals.endTime)
const event: CalendarEvent = {
name: vals.name,
start,
end,
location: vals.location,
color: vals.color,
description: vals.description,
id: null,
creator: null
}
console.log("Submitting CalendarEvent:", event)
// close after success
dialogOpen.value = false
}
</script>
<template>
<Form :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>
</DialogHeader>
<form id="dialogForm" class="grid grid-cols-1 gap-4"
@submit="handleSubmit($event, (vals) => { onSubmit(vals); resetForm({ values: initialValues }); })">
<div class="flex gap-3 items-start w-full">
<!-- Name -->
<div class="flex-1">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Event Name</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Color -->
<div class="w-[60px]">
<FormField v-slot="{ componentField }" name="color">
<FormItem>
<FormLabel>Color</FormLabel>
<FormControl>
<Input type="color" class="h-[38px] p-1 cursor-pointer"
v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</div>
<!-- Start: date + time -->
<div class="grid grid-cols-2 gap-3">
<FormField v-slot="{ componentField }" name="startDate">
<FormItem>
<FormLabel>Start Date</FormLabel>
<FormControl>
<Input type="date" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="startTime">
<FormItem>
<FormLabel>Start Time</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
<!-- If you ever want native picker: type="time" -->
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- End: date + time -->
<div class="grid grid-cols-2 gap-3">
<FormField v-slot="{ componentField }" name="endDate">
<FormItem>
<FormLabel>End Date</FormLabel>
<FormControl>
<Input type="date" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="endTime">
<FormItem>
<FormLabel>End Time</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Location -->
<FormField v-slot="{ componentField }" name="location">
<FormItem>
<FormLabel>Location</FormLabel>
<FormControl>
<Input type="text" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Description -->
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea class="resize-none h-32" v-bind="componentField" />
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<!-- Hidden id -->
<FormField v-slot="{ componentField }" name="id">
<input type="hidden" v-bind="componentField" />
</FormField>
</form>
<DialogFooter>
<Button type="submit" form="dialogForm">Create</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Form>
</template>

View File

@@ -4,6 +4,7 @@ import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid' import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import { X, Clock, MapPin, User, ListTodo, ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next' import { X, Clock, MapPin, User, ListTodo, ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next'
import CreateCalendarEvent from '@/components/calendar/CreateCalendarEvent.vue'
const monthLabels = [ const monthLabels = [
'January', 'February', 'March', 'April', 'May', 'June', 'January', 'February', 'March', 'April', 'May', 'June',
@@ -63,16 +64,19 @@ function onEventClick(arg: any) {
panelOpen.value = true panelOpen.value = true
} }
const dialogRef = ref<any>(null)
// NEW: handle day/time slot clicks to start creating an event // NEW: handle day/time slot clicks to start creating an event
function onDateClick(arg: { dateStr: string }) { function onDateClick(arg: { dateStr: string }) {
dialogRef.value?.openDialog();
// For now, just open the panel with a draft payload. // For now, just open the panel with a draft payload.
activeEvent.value = { // activeEvent.value = {
id: '__draft__', // id: '__draft__',
title: 'New event', // title: 'New event',
start: arg.dateStr, // start: arg.dateStr,
extendedProps: { draft: true } // extendedProps: { draft: true }
} // }
panelOpen.value = true // panelOpen.value = true
} }
const calendarOptions = ref({ const calendarOptions = ref({
@@ -127,6 +131,8 @@ const calendarOptions = ref({
}, },
}) })
//@ts-ignore (shhh)
calendarOptions.value.datesSet = onDatesSet
watch(panelOpen, async () => { watch(panelOpen, async () => {
@@ -165,6 +171,8 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
</script> </script>
<template> <template>
<CreateCalendarEvent ref="dialogRef"></CreateCalendarEvent>
<div class="flex"> <div class="flex">
<div class="flex-1 min-h-0 mt-5"> <div class="flex-1 min-h-0 mt-5">
<div class="h-[80vh] min-h-0"> <div class="h-[80vh] min-h-0">
@@ -196,20 +204,20 @@ const ext = computed(() => activeEvent.value?.extendedProps ?? {})
<!-- Right: nav + today + create --> <!-- Right: nav + today + create -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
class="inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40" class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Previous month" @click="goPrev"> aria-label="Previous month" @click="goPrev">
<ChevronLeft class="h-4 w-4" /> <ChevronLeft class="h-4 w-4" />
</button> </button>
<button <button
class="inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40" class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Next month" @click="goNext"> aria-label="Next month" @click="goNext">
<ChevronRight class="h-4 w-4" /> <ChevronRight class="h-4 w-4" />
</button> </button>
<button class="ml-1 rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40" @click="goToday"> <button class="cursor-pointer ml-1 rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40" @click="goToday">
Today Today
</button> </button>
<button <button
class="ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90" class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
@click="onCreateEvent"> @click="onCreateEvent">
<Plus class="h-4 w-4" /> <Plus class="h-4 w-4" />
Create Create