"use client"; import { useRef, useState } from "react"; import { ArrowDownIcon, ArrowUpIcon, FileIcon, ImageIcon, MessageSquareTextIcon, PaperclipIcon, PlusIcon, Trash2Icon, UploadIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Input } from "@/components/ui/input"; import { uploadMediaAction } from "@/actions/media"; import { cn } from "@/lib/utils"; import type { MessagePart } from "@/lib/reminder-messages"; const MAX_PARTS = 10; /** * Per-part draft. Mirrors `MessagePart` but adds UI-only metadata * (filename / mime / preview URL) that's discarded before the parts * cross the wire to the server action. */ interface PartDraft extends MessagePart { /** Local id so React's key stays stable across reorders. */ uid: string; filename?: string; mimeType?: string; /** ObjectURL preview for image media — not persisted. */ previewUrl?: string; } function newUid(): string { return Math.random().toString(36).slice(2); } function emptyText(): PartDraft { return { uid: newUid(), kind: "text", textContent: "", mediaId: null }; } function emptyMedia(): PartDraft { return { uid: newUid(), kind: "media", textContent: "", mediaId: null }; } interface MessageStackProps { initial: MessagePart[]; /** Called whenever the stack changes — parent owns the canonical state. */ onChange: (parts: MessagePart[]) => void; /** Optional resolver for already-uploaded mediaId → display info. */ initialMediaInfo?: Record; } /** * Stack-of-blocks editor for a reminder's message parts. * * Each row is either a `text` block (Textarea) or a `media` block * (file picker + caption Input + per-block delete/move buttons). The * stack supports reorder via up/down buttons and ships at most * MAX_PARTS rows. The bot fires the parts in this order with ~1.5 s * spacing between sends. */ export function MessageStack({ initial, onChange, initialMediaInfo }: MessageStackProps) { const [parts, setParts] = useState(() => { if (initial.length === 0) return [emptyText()]; return initial.map((p) => { const info = p.mediaId ? initialMediaInfo?.[p.mediaId] : undefined; return { uid: newUid(), kind: p.kind, textContent: p.textContent ?? "", mediaId: p.mediaId ?? null, filename: info?.filename, mimeType: info?.mimeType, }; }); }); function emit(next: PartDraft[]) { setParts(next); onChange( next .map((p) => ({ kind: p.kind, textContent: p.textContent && p.textContent.trim() ? p.textContent : null, mediaId: p.mediaId, })) // Drop empty text blocks and orphan media blocks at compile-out // — onChange should reflect only "real" message parts. .filter((p) => p.kind === "text" ? Boolean(p.textContent) : Boolean(p.mediaId), ), ); } function update(uid: string, patch: Partial) { emit(parts.map((p) => (p.uid === uid ? { ...p, ...patch } : p))); } function remove(uid: string) { if (parts.length === 1) { // Don't go below one block; reset it instead. emit([emptyText()]); return; } emit(parts.filter((p) => p.uid !== uid)); } function move(uid: string, direction: -1 | 1) { const idx = parts.findIndex((p) => p.uid === uid); const target = idx + direction; if (idx < 0 || target < 0 || target >= parts.length) return; const next = parts.slice(); [next[idx], next[target]] = [next[target]!, next[idx]!]; emit(next); } function add(kind: "text" | "media") { if (parts.length >= MAX_PARTS) return; emit([...parts, kind === "text" ? emptyText() : emptyMedia()]); } return (
{parts.map((part, idx) => ( update(part.uid, patch)} onRemove={() => remove(part.uid)} onMoveUp={() => move(part.uid, -1)} onMoveDown={() => move(part.uid, 1)} /> ))}
{parts.length >= MAX_PARTS && ( Up to {MAX_PARTS} parts per reminder )}
); } interface BlockProps { part: PartDraft; index: number; total: number; onChange: (patch: Partial) => void; onRemove: () => void; onMoveUp: () => void; onMoveDown: () => void; } function Block({ part, index, total, onChange, onRemove, onMoveUp, onMoveDown }: BlockProps) { return (
{part.kind === "text" ? "Text" : "File"} · #{index + 1}
{part.kind === "text" ? (