built create event UI
This commit is contained in:
42
ui/src/api/calendar.ts
Normal file
42
ui/src/api/calendar.ts
Normal 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) {
|
||||||
|
|
||||||
|
}
|
||||||
257
ui/src/components/calendar/CreateCalendarEvent.vue
Normal file
257
ui/src/components/calendar/CreateCalendarEvent.vue
Normal 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>
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user