Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions app/admin/publishNotice/PublishForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,22 @@ export default function NoticeboardForm() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

if (formData.eventTime && formData.eventEndTime) {
const start = new Date(formData.eventTime);
const end = new Date(formData.eventEndTime);
if (end < start) {
toast.error("Event end time cannot be before start time");
return;
}
}

try {
const payload = {
...formData,
title: formData.title,
description: formData.description,
entity: formData.type,
location: formData.location,
body: formData.body,
eventEndTime: formData.eventEndTime
? new Date(formData.eventEndTime).toISOString()
: null,
Expand Down Expand Up @@ -239,6 +252,7 @@ export default function NoticeboardForm() {

function isoToDatetimeLocal(iso: string) {
const date = new Date(iso);
if (isNaN(date.getTime()) || date.getFullYear() < 1970) return "";

const pad = (n: number) => String(n).padStart(2, "0");

Expand Down Expand Up @@ -296,9 +310,22 @@ export default function NoticeboardForm() {
e.preventDefault();
if (!noticeId) return;

if (formData.eventTime && formData.eventEndTime) {
const start = new Date(formData.eventTime);
const end = new Date(formData.eventEndTime);
if (end < start) {
toast.error("Event end time cannot be before start time");
return;
}
}

try {
const payload = {
...formData,
title: formData.title,
description: formData.description,
entity: formData.type,
location: formData.location,
body: formData.body,
eventEndTime: formData.eventEndTime
? new Date(formData.eventEndTime).toISOString()
: null,
Expand Down Expand Up @@ -342,13 +369,13 @@ export default function NoticeboardForm() {
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{["title", "description"].map((field) => (
{[{ name: "title", maxLength: 50 }, { name: "description", maxLength: undefined }].map(({ name: field, maxLength }) => (
<div key={field}>
<Label
htmlFor={field}
className="block text-sm font-medium capitalize"
>
{field}
{field}{field === "title" && <span className="ml-2 text-xs text-muted-foreground font-normal">{formData.title.length}/50</span>}
</Label>
<Input
id={field}
Expand All @@ -358,6 +385,7 @@ export default function NoticeboardForm() {
// TODO: add correct interface NoticeFormData
value={(formData as any)[field]}
onChange={handleChange}
maxLength={maxLength}
className="mt-1 w-full px-4 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 text-gray-900 placeholder:text-gray-400 dark:bg-gray-800 dark:border-gray-600 dark:placeholder-gray-500 dark:text-white"
required
/>
Expand Down
4 changes: 4 additions & 0 deletions app/components/location/EditLocationModal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useState } from "react";
import { useHistoryBack } from "@/app/hooks/use-history-back";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
Expand Down Expand Up @@ -71,6 +72,9 @@ export function EditLocationModal({
const [isSubmitting, setIsSubmitting] = useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");

// Close modal on phone back button instead of navigating away
useHistoryBack(isOpen, () => setIsOpen(false));

const [formData, setFormData] = useState({
name: "",
description: "",
Expand Down
4 changes: 4 additions & 0 deletions app/components/location/ReviewDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState } from "react";
import { useHistoryBack } from "@/app/hooks/use-history-back";
import { Button } from "@/components/ui/button";
import {
Drawer,
Expand Down Expand Up @@ -43,6 +44,9 @@ export function ReviewDrawer({
const [isSubmitting, setIsSubmitting] = useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");

// Close drawer on phone back button instead of navigating away
useHistoryBack(isOpen, () => setIsOpen(false));

const handleSubmit = async () => {
if (!rating) {
toast.error("Please select a rating.");
Expand Down
43 changes: 43 additions & 0 deletions app/hooks/use-history-back.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect, useRef } from "react";

/**
* Intercepts the browser/phone back button to close a drawer/dialog
* instead of navigating away. Essential for PWA UX on mobile.
*
* When `isOpen` becomes true, a dummy history entry is pushed.
* Pressing back pops that entry and calls `onClose` instead of navigating.
*/
export function useHistoryBack(isOpen: boolean, onClose: () => void) {
// Store onClose in a ref so the effect doesn't re-run when the
// parent passes an unstable (inline) callback reference.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;

const didPushRef = useRef(false);

useEffect(() => {
if (isOpen) {
// Push a dummy state so that "back" stays on the same page
window.history.pushState({ drawerOpen: true }, "");
didPushRef.current = true;

const handlePopState = () => {
// Back was pressed — close the drawer instead of navigating
didPushRef.current = false;
onCloseRef.current();
};

window.addEventListener("popstate", handlePopState);

return () => {
window.removeEventListener("popstate", handlePopState);
// If the drawer was closed programmatically (not via back button),
// clean up the dummy history entry we pushed
if (didPushRef.current) {
didPushRef.current = false;
window.history.back();
}
};
}
}, [isOpen]); // Only re-run when open state actually changes
}
27 changes: 21 additions & 6 deletions calendar/components/dialogs/add-event-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) {
<FormItem>
<FormLabel htmlFor="title">Title</FormLabel>
<FormControl>
<Input id="title" placeholder="Event title" data-invalid={fieldState.invalid} {...field} />
<Input id="title" placeholder="Event title" maxLength={50} data-invalid={fieldState.invalid} {...field} />
</FormControl>
<FormMessage />
</FormItem>
Expand All @@ -126,7 +126,10 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) {
<SingleDayPicker
id="startDate"
value={field.value}
onSelect={date => field.onChange(date as Date)}
onSelect={date => {
field.onChange(date as Date);
form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]);
}}
placeholder="Select a date"
data-invalid={fieldState.invalid}
/>
Expand All @@ -143,7 +146,10 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) {
<FormItem className="flex-1">
<FormLabel>Start Time</FormLabel>
<FormControl>
<TimeInput value={field.value as TimeValue} onChange={field.onChange} hourCycle={12} data-invalid={fieldState.invalid} />
<TimeInput value={field.value as TimeValue} onChange={time => {
field.onChange(time);
form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]);
}} hourCycle={12} data-invalid={fieldState.invalid} />
</FormControl>
<FormMessage />
</FormItem>
Expand All @@ -161,7 +167,10 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) {
<FormControl>
<SingleDayPicker
value={field.value}
onSelect={date => field.onChange(date as Date)}
onSelect={date => {
field.onChange(date as Date);
form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]);
}}
placeholder="Select a date"
data-invalid={fieldState.invalid}
/>
Expand All @@ -178,7 +187,10 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) {
<FormItem className="flex-1">
<FormLabel>End Time</FormLabel>
<FormControl>
<TimeInput value={field.value as TimeValue} onChange={field.onChange} hourCycle={12} data-invalid={fieldState.invalid} />
<TimeInput value={field.value as TimeValue} onChange={time => {
field.onChange(time);
form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]);
}} hourCycle={12} data-invalid={fieldState.invalid} />
</FormControl>
<FormMessage />
</FormItem>
Expand Down Expand Up @@ -256,7 +268,10 @@ export function AddEventDialog({ children, startDate, startTime }: IProps) {
<FormControl>
<SingleDayPicker
value={field.value ?? undefined}
onSelect={date => field.onChange(date as Date)}
onSelect={date => {
field.onChange(date as Date);
form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]);
}}
placeholder="No end date (forever)"
data-invalid={fieldState.invalid}
/>
Expand Down
27 changes: 21 additions & 6 deletions calendar/components/dialogs/edit-event-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ export function EditEventDialog({ children, event }: IProps) {
<FormItem>
<FormLabel htmlFor="title">Title</FormLabel>
<FormControl>
<Input id="title" disabled={event.title.startsWith("Lec-") || event.title.startsWith("Tut-") || event.title.startsWith("Prc-")} placeholder="Event title" data-invalid={fieldState.invalid} {...field} />
<Input id="title" disabled={event.title.startsWith("Lec-") || event.title.startsWith("Tut-") || event.title.startsWith("Prc-")} placeholder="Event title" maxLength={50} data-invalid={fieldState.invalid} {...field} />
</FormControl>
<FormMessage />
</FormItem>
Expand All @@ -306,7 +306,10 @@ export function EditEventDialog({ children, event }: IProps) {
<SingleDayPicker
id="startDate"
value={field.value}
onSelect={date => field.onChange(date as Date)}
onSelect={date => {
field.onChange(date as Date);
form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]);
}}
placeholder="Select a date"
data-invalid={fieldState.invalid}
/>
Expand All @@ -323,7 +326,10 @@ export function EditEventDialog({ children, event }: IProps) {
<FormItem className="flex-1">
<FormLabel>Start Time</FormLabel>
<FormControl>
<TimeInput value={field.value as TimeValue} onChange={field.onChange} hourCycle={12} data-invalid={fieldState.invalid} />
<TimeInput value={field.value as TimeValue} onChange={time => {
field.onChange(time);
form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]);
}} hourCycle={12} data-invalid={fieldState.invalid} />
</FormControl>
<FormMessage />
</FormItem>
Expand All @@ -341,7 +347,10 @@ export function EditEventDialog({ children, event }: IProps) {
<FormControl>
<SingleDayPicker
value={field.value}
onSelect={date => field.onChange(date as Date)}
onSelect={date => {
field.onChange(date as Date);
form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]);
}}
placeholder="Select a date"
data-invalid={fieldState.invalid}
/>
Expand All @@ -358,7 +367,10 @@ export function EditEventDialog({ children, event }: IProps) {
<FormItem className="flex-1">
<FormLabel>End Time</FormLabel>
<FormControl>
<TimeInput value={field.value as TimeValue} onChange={field.onChange} hourCycle={12} data-invalid={fieldState.invalid} />
<TimeInput value={field.value as TimeValue} onChange={time => {
field.onChange(time);
form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]);
}} hourCycle={12} data-invalid={fieldState.invalid} />
</FormControl>
<FormMessage />
</FormItem>
Expand Down Expand Up @@ -438,7 +450,10 @@ export function EditEventDialog({ children, event }: IProps) {
<FormControl>
<SingleDayPicker
value={field.value ?? undefined}
onSelect={date => field.onChange(date as Date)}
onSelect={date => {
field.onChange(date as Date);
form.trigger(["startTime", "endTime", "startDate", "endDate", "recurrenceEndDate"]);
}}
placeholder="No end date (forever)"
data-invalid={fieldState.invalid}
/>
Expand Down
11 changes: 4 additions & 7 deletions calendar/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,17 @@ function noticeToEvent(notice: NoticeFromAPI, index: number): IEvent | null {
//const start = new Date(notice.eventTime);
//const end = new Date(notice.eventEndTime);

const cleanStartTime = notice.eventTime.replace(/(Z|[+-]\d{2}:?\d{2})$/, '');
const cleanEndTime = notice.eventEndTime.replace(/(Z|[+-]\d{2}:?\d{2})$/, '');

const start = new Date(cleanStartTime);
const end = new Date(cleanEndTime);
const start = new Date(notice.eventTime);
const end = new Date(notice.eventEndTime || notice.eventTime);

// Skip notices with invalid dates
if (isNaN(start.getTime())) {
if (isNaN(start.getTime()) || start.getFullYear() < 1970) {
console.warn("Invalid date in notice:", notice.title, notice.eventTime);
return null;
}

// If end date is invalid, default to start date + 1 hour
const validEnd = isNaN(end.getTime())
const validEnd = isNaN(end.getTime()) || end.getFullYear() < 1970
? new Date(start.getTime() + 60 * 60 * 1000)
: end;

Expand Down
2 changes: 1 addition & 1 deletion calendar/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { z } from "zod";

export const eventSchema = z.object({
entity: z.string().optional(), // Entity organizing the event
title: z.string().min(1, "Title is required"),
title: z.string().min(1, "Title is required").max(50, "Title must be 50 characters or less"),
description: z.string().min(1, "Description is required"),
startDate: z.date({ error: "Start date is required" }),
startTime: z.object(
Expand Down
2 changes: 1 addition & 1 deletion calendar/schemas.user-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { z } from "zod";
* Kept separate so the original eventSchema is never modified.
*/
export const userEventSchema = z.object({
title: z.string().min(1, "Title is required"),
title: z.string().min(1, "Title is required").max(50, "Title must be 50 characters or less"),
description: z.string().default(""),
startDate: z.date({ error: "Start date is required" }),
startTime: z.object(
Expand Down
5 changes: 5 additions & 0 deletions components/AddLocationDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import { useEffect, useState } from "react";
import { useHistoryBack } from "@/app/hooks/use-history-back";

import {
Drawer,
DrawerContent,
Expand Down Expand Up @@ -61,6 +63,9 @@ export default function AddLocationDrawer({
const [isSubmitting, setIsSubmitting] = useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");

// Close drawer on phone back button instead of navigating away
useHistoryBack(open, () => onOpenChange(false));

// Auto-fill lat/lon + trigger open
useEffect(() => {
const lat = localStorage.getItem("selected_lat");
Expand Down
12 changes: 11 additions & 1 deletion server/maps/handler.adminActions.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ func addNotice(c *gin.Context) {
return
}

if !input.EventEndTime.IsZero() && input.EventEndTime.Before(input.EventTime) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Event end time cannot be before start time"})
return
}

userID, exist := c.Get("userID") // means api requests must be authenticated
if !exist {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
Expand Down Expand Up @@ -429,13 +434,18 @@ func editNotice(c *gin.Context) {
return
}

var input model.Notice
var input AddNoticeRequest
if err := c.ShouldBindJSON(&input); err != nil {
logrus.WithError(err).Warn("JSON binding failed")
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
return
}

if !input.EventEndTime.IsZero() && input.EventEndTime.Before(input.EventTime) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Event end time cannot be before start time"})
return
}

var notice model.Notice
if err := connections.DB.Where("notice_id = ?", noticeID).First(&notice).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
Expand Down
Loading