diff --git a/apps/blade/src/app/_components/issues/create-edit-dialog.tsx b/apps/blade/src/app/_components/issues/create-edit-dialog.tsx new file mode 100644 index 000000000..aeda07905 --- /dev/null +++ b/apps/blade/src/app/_components/issues/create-edit-dialog.tsx @@ -0,0 +1,780 @@ +"use client"; + +import * as React from "react"; +import { Link2, Plus, Trash2, X } from "lucide-react"; +import { createPortal } from "react-dom"; + +import { ISSUE } from "@forge/consts"; + +const baseField = + "w-full rounded-xl border border-white/10 bg-black/30 px-4 text-white placeholder:text-white/40 focus:border-white/30 focus:outline-none"; + +const tabButtonBase = + "rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-xs font-medium text-white/70 transition hover:border-white/30 hover:text-white"; + +const REQUIREMENT_FLAGS = [ + { + key: "requiresAV", + label: "AV Equipment", + caption: "Projector, microphones, speakers", + }, + { + key: "requiresFood", + label: "Food", + caption: "Snacks, catering, drinks", + }, +] as const; + +const STATUS_COLORS: Record = { + BACKLOG: "bg-slate-400", + PLANNING: "bg-amber-400", + IN_PROGRESS: "bg-emerald-400", + FINISHED: "bg-rose-400", +}; + +const SECTION_TABS: { key: ISSUE.DetailSectionKey; label: string }[] = [ + { key: "details", label: "Details" }, + { key: "requirements", label: "Room & Requirements" }, + { key: "links", label: "Links & Notes" }, +]; + +import { cn } from "@forge/ui"; +import { Button } from "@forge/ui/button"; +import { Input } from "@forge/ui/input"; +import { Label } from "@forge/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@forge/ui/select"; +import { Switch } from "@forge/ui/switch"; +import { Textarea } from "@forge/ui/textarea"; + +function getStatusLabel(status: string) { + return status.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); +} + +const TEAM_OPTIONS = [ + "Design", + "Workshop", + "Outreach", + "Programs", + "Sponsorship", + "E-Board", +]; + +// Helper to create a new link string +const createLinkItem = (): string => ""; + +const defaultEventForm = (): ISSUE.EventFormValues => { + const now = new Date(); + const end = new Date(now.getTime() + 60 * 60 * 1000); + return { + discordId: "", + googleId: "", + name: "", + tag: "", + description: "", + startDate: formatDateForInput(now), + startTime: formatTimeForInput(now), + endDate: formatDateForInput(end), + endTime: formatTimeForInput(end), + location: "", + dues_paying: false, + points: undefined, + hackathonId: undefined, + }; +}; + +const defaultForm = (): ISSUE.IssueFormValues => { + const now = new Date(); + // Default due date for tasks is today at 11:00 PM + const dueDate = new Date(now); + dueDate.setHours(23, 0, 0, 0); + return { + status: ISSUE.ISSUE_STATUS[0], + name: "", + description: "", + links: [], + date: dueDate.toISOString(), + priority: ISSUE.PRIORITY[0], + team: "", + parent: undefined, + isEvent: false, + event: undefined, + //ui + details: "", + notes: "", + isHackathonCritical: false, + requiresRoom: false, + requiresAV: false, + requiresFood: false + }; +}; + +export function CreateEditDialog(props: ISSUE.CreateEditDialogProps) { + const { + open, + intent = "create", + initialValues, + onClose, + onDelete, + onSubmit, + } = props; + const [portalElement, setPortalElement] = React.useState( + null, + ); + const [activeSection, setActiveSection] = + React.useState("details"); + const buildInitialFormValues = React.useCallback(() => { + const defaults = defaultForm(); + if (initialValues?.isEvent) { + return { + ...defaults, + ...initialValues, + isEvent: true, + event: initialValues.event ?? defaultEventForm(), + links: initialValues?.links ?? defaults.links, + }; + } + return { + ...defaults, + ...initialValues, + isEvent: false, + event: undefined, + links: initialValues?.links ?? defaults.links, + }; + }, [initialValues]); + const [formValues, setFormValues] = React.useState( + buildInitialFormValues, + ); + const baseId = React.useId(); + const isSubmitDisabled = !( + formValues.isEvent ? formValues.event?.name : formValues.name + )?.trim(); + + // Helper for event form + const updateEventForm = ( + key: K, + value: ISSUE.EventFormValues[K], + ) => { + setFormValues((previous) => ({ + ...previous, + event: { + ...(previous.event ?? defaultEventForm()), + [key]: value, + }, + })); + }; + + React.useEffect(() => { + setPortalElement(document.body); + }, []); + + React.useEffect(() => { + if (!open) { + return; + } + + setFormValues(buildInitialFormValues()); + setActiveSection("details"); + }, [buildInitialFormValues, open]); + + React.useEffect(() => { + if (!open) { + return; + } + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + const handleKeydown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose?.(); + } + }; + + window.addEventListener("keydown", handleKeydown); + + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener("keydown", handleKeydown); + }; + }, [open, onClose]); + + const handleOverlayPointerDown = ( + event: React.MouseEvent, + ) => { + if (event.target === event.currentTarget) { + onClose?.(); + } + }; + + const updateForm = ( + key: K, + value: ISSUE.IssueFormValues[K], + ) => { + setFormValues((previous) => ({ + ...previous, + [key]: value, + })); + }; + + const handleAddLink = () => { + setFormValues((previous) => ({ + ...previous, + links: [...previous.links, createLinkItem()], + })); + }; + + const handleRemoveLink = (index: number) => { + setFormValues((previous) => ({ + ...previous, + links: previous.links.filter((_, i) => i !== index), + })); + }; + + const handleLinkUpdate = (index: number, value: string) => { + setFormValues((previous) => ({ + ...previous, + links: previous.links.map((link, i) => (i === index ? value : link)), + })); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + // If not event, clear event field + if (!formValues.isEvent) { + onSubmit?.({ ...formValues, event: undefined }); + } else { + onSubmit?.(formValues); + } + }; + + const handleDelete = () => { + if (intent === "edit") { + onDelete?.(formValues); + } + }; + + if (!open || !portalElement) { + return null; + } + + return createPortal( +
+
event.stopPropagation()} + style={{ maxHeight: "90vh" }} + > + + +
+

+ {intent === "edit" + ? formValues.isEvent + ? "Edit Event" + : "Edit Task" + : formValues.isEvent + ? "Create Event" + : "Create Task"} +

+

+ {intent === "edit" + ? formValues.isEvent + ? "Update the event details below" + : "Update the task details below" + : formValues.isEvent + ? "Enter the event details below" + : "Enter the task details below"} +

+
+ +
+
+ + + { + if (formValues.isEvent) { + updateEventForm("name", event.target.value); + } else { + updateForm("name", event.target.value); + } + }} + /> +
+ +
+ + +
+ + {/* Date/Time fields */} + {formValues.isEvent ? ( +
+
+
+ + + updateEventForm("startDate", e.target.value) + } + /> +
+
+ + + updateEventForm("startTime", e.target.value) + } + /> +
+
+ + + updateEventForm("endDate", e.target.value) + } + /> +
+
+ + + updateEventForm("endTime", e.target.value) + } + /> +
+
+
+ + + updateEventForm("location", e.target.value) + } + /> +
+
+ ) : ( +
+ + { + // Always set to 11:00 PM + const d = new Date(e.target.value); + d.setHours(23, 0, 0, 0); + updateForm("date", d.toISOString()); + }} + /> +
+ )} + +
+

+ Sections +

+
+ {SECTION_TABS.map((section) => ( + + ))} +
+ +
+ {activeSection === "details" && ( +
+
+
+ + +
+
+ + +
+
+ +
+ +