163 lines · 6.1 KB · TypeScript
RawDownload
"use client"; // The one dialog a curator writes to a reader through, whether the trigger is// a submission decision or a handled report.//// It was two nearly identical modals before the reports queue could reply at// all; making it one is what keeps the two from drifting on the things that// matter - the character cap, the "this is not public" promise, and the fact// that the recipient may not be reachable. import { useEffect, useRef, useState } from "react";import { MESSAGE_MAX, type ReviewReason } from "@/lib/messages"; export interface MessageDialogProps {  title: string;  /// One or two sentences under the title explaining where the message goes.  intro: string;  /// Canned reasons. Picking one seeds the message box; omit for a plain  /// free-text dialog.  reasons?: ReviewReason[];  reasonLabel?: string;  placeholder: string;  confirmLabel: string;  busy: boolean;  /// False when there is nobody to deliver to - an account that has since  /// been deleted. The action still runs; the message simply goes nowhere,  /// and saying so up front beats letting someone write into a void.  canDeliver?: boolean;  onCancel: () => void;  onConfirm: (reason: string | null, message: string) => void;} export function MessageDialog({  title,  intro,  reasons,  reasonLabel = "Reason",  placeholder,  confirmLabel,  busy,  canDeliver = true,  onCancel,  onConfirm,}: MessageDialogProps) {  const [reason, setReason] = useState("");  const [message, setMessage] = useState("");  const areaRef = useRef<HTMLTextAreaElement>(null);  // True once the curator has typed anything, after which picking a different  // reason must not overwrite their words.  const touched = useRef(false);   useEffect(() => {    function onKeyDown(event: KeyboardEvent) {      if (event.key === "Escape" && !busy) onCancel();    }    document.addEventListener("keydown", onKeyDown);    return () => document.removeEventListener("keydown", onKeyDown);  }, [busy, onCancel]);   function pickReason(value: string) {    setReason(value);    const draft = reasons?.find((r) => r.value === value)?.draft ?? "";    // Seed the box only while it is still untouched or still holds a draft    // this dialog put there. Anything the curator wrote themselves wins.    if (!touched.current && draft) {      setMessage(draft);      areaRef.current?.focus();    }  }   return (    <div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">      <div        className="absolute inset-0 bg-[rgba(20,18,12,0.45)]"        onClick={busy ? undefined : onCancel}        aria-hidden      />      <div        role="dialog"        aria-modal="true"        aria-label={title}        className="relative max-h-[92vh] w-full overflow-y-auto rounded-t-lg border border-[var(--hairline)] bg-[var(--paper)] dialog-scroll sm:max-w-md sm:rounded-lg"      >        <header className="border-b border-[var(--hairline)] px-5 py-3.5">          <h2 className="font-serif text-lg text-[var(--ink)]">{title}</h2>          <p className="mt-1 text-[11px] leading-relaxed text-[var(--ink-muted)]">            {intro}          </p>          {!canDeliver && (            <p className="mt-1.5 text-[11px] leading-relaxed text-[var(--accent-orange)]">              This account no longer exists, so anything written here will not              reach anyone. The action itself still goes through.            </p>          )}        </header>         <div className="space-y-3 px-5 py-4">          {reasons && (            <label className="block">              <span className="mb-1 block text-[11px] text-[var(--ink-muted)]">                {reasonLabel}              </span>              <select                value={reason}                onChange={(e) => pickReason(e.target.value)}                autoFocus                className="w-full rounded border border-[var(--hairline)] bg-[var(--paper-raised)] px-2.5 py-2 text-sm text-[var(--ink)] transition-colors hover:border-[var(--ink-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--accent-blue)]"              >                <option value="">No reason recorded</option>                {reasons.map((r) => (                  <option key={r.value} value={r.value}>                    {r.label}                  </option>                ))}              </select>            </label>          )}           <label className="block">            <span className="mb-1 block text-[11px] text-[var(--ink-muted)]">              Message            </span>            <textarea              ref={areaRef}              value={message}              maxLength={MESSAGE_MAX}              rows={5}              autoFocus={!reasons}              placeholder={placeholder}              onChange={(e) => {                touched.current = true;                setMessage(e.target.value);              }}              className="w-full resize-y rounded border border-[var(--hairline)] bg-[var(--paper-raised)] px-2.5 py-2 text-sm text-[var(--ink)] transition-colors hover:border-[var(--ink-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--accent-blue)]"            />            <span className="mt-1 block text-right text-[11px] text-[var(--ink-muted)]">              {message.length}/{MESSAGE_MAX}            </span>          </label>        </div>         <footer className="flex flex-wrap items-center gap-2 border-t border-[var(--hairline)] px-5 py-3">          <button            type="button"            disabled={busy}            onClick={() => onConfirm(reason || null, message)}            className="rounded-md border border-[var(--hairline)] bg-[var(--paper-raised)] px-3 py-1.5 text-xs text-[var(--ink)] transition-colors hover:border-[var(--accent-blue)] hover:text-[var(--accent-blue)] disabled:opacity-40"          >            {busy ? "Saving…" : confirmLabel}          </button>          <button            type="button"            disabled={busy}            onClick={onCancel}            className="rounded-md px-2 py-1 text-xs text-[var(--ink-muted)] transition-colors hover:text-[var(--ink)] disabled:opacity-40"          >            Cancel          </button>        </footer>      </div>    </div>  );}