From a44da5c2f32a55596066a6af10d5fb780f899037 Mon Sep 17 00:00:00 2001 From: Mehdi Chraibi Date: Thu, 12 Mar 2026 20:57:15 -0400 Subject: [PATCH 01/24] added creation/edit dialog component --- .../_components/issues/create-edit-dialog.tsx | 821 ++++++++++++++++++ 1 file changed, 821 insertions(+) create mode 100644 apps/blade/src/app/_components/issues/create-edit-dialog.tsx 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..524573853 --- /dev/null +++ b/apps/blade/src/app/_components/issues/create-edit-dialog.tsx @@ -0,0 +1,821 @@ +"use client"; + +import * as React from "react"; +import { Link2, Plus, Trash2, X } from "lucide-react"; +import { createPortal } from "react-dom"; + +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"; + +type IssueStatus = (typeof STATUS_OPTIONS)[number]["value"]; + +type DetailSectionKey = "details" | "requirements" | "links"; + +type LinkItem = { + id: string; + label: string; + url: string; +}; + +export interface IssueFormValues { + title: string; + status: IssueStatus; + startDate: string; + startTime: string; + endDate: string; + endTime: string; + allDay: boolean; + team: string; + priority: string; + isHackathonCritical: boolean; + requiresRoom: boolean; + needsDesignAssets: boolean; + needsOutreach: boolean; + details: string; + requirements: string; + links: LinkItem[]; + notes: string; +} + +export interface CreateEditDialogProps { + open: boolean; + intent?: "create" | "edit"; + initialValues?: Partial; + onClose?: () => void; + onSubmit?: (values: IssueFormValues) => void; + onDelete?: (values: IssueFormValues) => void; +} + +const STATUS_OPTIONS = [ + { + value: "confirmed", + label: "Confirmed", + caption: "Everything is locked in", + dotClass: "bg-emerald-400", + }, + { + value: "tentative", + label: "Tentative", + caption: "Waiting on a few details", + dotClass: "bg-amber-400", + }, + { + value: "draft", + label: "Draft", + caption: "Still being scoped", + dotClass: "bg-slate-400", + }, + { + value: "cancelled", + label: "Cancelled", + caption: "No longer happening", + dotClass: "bg-rose-400", + }, +] as const; + +const SECTION_TABS: { key: DetailSectionKey; label: string }[] = [ + { key: "details", label: "Details" }, + { key: "requirements", label: "Room & Requirements" }, + { key: "links", label: "Links & Notes" }, +]; + +const TEAM_OPTIONS = [ + "Design", + "Workshop", + "Outreach", + "Programs", + "Sponsorship", + "E-Board", +]; + +const PRIORITY_OPTIONS = ["High", "Medium", "Low"]; + +const REQUIREMENT_FLAGS: { + key: keyof Pick; + label: string; + caption: string; +}[] = [ + { + key: "needsDesignAssets", + label: "Requires Design Assets", + caption: "Decks, flyers, or other creative deliverables", + }, + { + key: "needsOutreach", + label: "Requires Outreach/Marketing", + caption: "Share with campus orgs or sponsors", + }, +]; + +const focusGlow = + "transition-[border,background-color] duration-150 ease-out focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[rgba(120,82,255,0.45)] focus-visible:border-transparent"; + +const baseField = cn( + "text-foreground placeholder:text-foreground/50 rounded-2xl border border-white/10 bg-white/5 px-4 text-sm backdrop-blur-md hover:border-white/20", + focusGlow, +); + +const tabButtonBase = cn( + "text-foreground/70 hover:text-foreground flex-1 rounded-2xl border border-white/10 bg-white/0 px-4 py-3 text-sm font-medium transition-all duration-200", + focusGlow, +); + +const createLinkItem = (): LinkItem => ({ + id: + typeof crypto !== "undefined" && "randomUUID" in crypto + ? crypto.randomUUID() + : Math.random().toString(36).slice(2), + label: "", + url: "", +}); + +const defaultForm = (): IssueFormValues => { + const now = new Date(); + const end = new Date(now.getTime() + 60 * 60 * 1000); + + return { + title: "", + status: "confirmed", + startDate: formatDateForInput(now), + startTime: formatTimeForInput(now), + endDate: formatDateForInput(end), + endTime: formatTimeForInput(end), + allDay: false, + team: "", + priority: "", + isHackathonCritical: false, + requiresRoom: false, + needsDesignAssets: false, + needsOutreach: false, + details: "", + requirements: "", + links: [], + notes: "", + }; +}; + +export function CreateEditDialog(props: 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(); + return { + ...defaults, + ...initialValues, + links: initialValues?.links ?? defaults.links, + }; + }, [initialValues]); + const [formValues, setFormValues] = React.useState( + buildInitialFormValues, + ); + const baseId = React.useId(); + + 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 statusMeta = React.useMemo( + () => + STATUS_OPTIONS.find((status) => status.value === formValues.status) ?? + STATUS_OPTIONS[0], + [formValues.status], + ); + + const handleOverlayPointerDown = ( + event: React.MouseEvent, + ) => { + if (event.target === event.currentTarget) { + onClose?.(); + } + }; + + const updateForm = ( + key: K, + value: IssueFormValues[K], + ) => { + setFormValues((previous) => ({ + ...previous, + [key]: value, + })); + }; + + const handleAddLink = () => { + setFormValues((previous) => ({ + ...previous, + links: [...previous.links, createLinkItem()], + })); + }; + + const handleRemoveLink = (id: string) => { + setFormValues((previous) => ({ + ...previous, + links: previous.links.filter((link) => link.id !== id), + })); + }; + + const handleLinkUpdate = (id: string, key: keyof LinkItem, value: string) => { + setFormValues((previous) => ({ + ...previous, + links: previous.links.map((link) => + link.id === id + ? { + ...link, + [key]: value, + } + : link, + ), + })); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onSubmit?.(formValues); + }; + + const handleDelete = () => { + if (intent === "edit") { + onDelete?.(formValues); + } + }; + + if (!open || !portalElement) { + return null; + } + + return createPortal( +
+
event.stopPropagation()} + style={{ maxHeight: "90vh" }} + > + + +
+

+ {intent === "edit" ? "Edit Event" : "Create Event"} +

+

+ {intent === "edit" + ? "Update the event details below" + : "Enter the event details below"} +

+
+ +
+
+
+ + updateForm("title", event.target.value)} + /> +
+ +
+ + +
+ +
+
+
+ + + updateForm("startDate", event.target.value) + } + /> +
+
+ + + updateForm("startTime", event.target.value) + } + /> +
+
+ + + updateForm("endDate", event.target.value) + } + /> +
+
+ + + updateForm("endTime", event.target.value) + } + /> +
+
+ +
+
+

+ Mode +

+

+ All day +

+

+ Ignores start & end times +

+
+ updateForm("allDay", checked)} + className="mt-2" + /> +
+
+ +
+

+ Sections +

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