reply ui upgrade

This commit is contained in:
Nizzy
2025-03-02 13:31:24 -05:00
parent 801e10d1a9
commit 6948c788c3
5 changed files with 279 additions and 114 deletions

View File

@@ -289,6 +289,7 @@ export function CreateEmail() {
initialValue={defaultValue}
onChange={(newContent) => setMessageContent(newContent)}
key={resetEditorKey}
placeholder="Write your message here..."
/>
</div>
</div>

View File

@@ -1,11 +1,23 @@
/* Only show placeholder when editor is completely empty */
.ProseMirror p.is-editor-empty:first-child::before {
color: #adb5bd;
content: attr(data-placeholder);
color: #c2c8d0;
content: "Start writing...";
float: left;
height: 0;
pointer-events: none;
opacity: 0.5;
opacity: 0.4;
font-weight: normal;
}
/* Style for all empty paragraphs */
.ProseMirror p.is-empty::before {
color: #c2c8d0;
content: "Continue writing...";
float: left;
height: 0;
pointer-events: none;
opacity: 0.4;
font-weight: normal;
}
/* Remove the style for all empty paragraphs */

View File

@@ -19,7 +19,8 @@ import { uploadFn } from "@/components/create/image-upload";
import { handleImageDrop, handleImagePaste } from "novel";
import EditorMenu from "@/components/create/editor-menu";
import { Separator } from "@/components/ui/separator";
import { useReducer } from "react";
import { useReducer, useRef } from "react";
import "./editor.css";
const hljs = require("highlight.js");
@@ -38,6 +39,7 @@ export const defaultEditorContent = {
interface EditorProps {
initialValue?: JSONContent;
onChange: (content: string) => void;
placeholder?: string;
}
interface EditorState {
@@ -68,24 +70,35 @@ function editorReducer(state: EditorState, action: EditorAction): EditorState {
}
}
export default function Editor({ initialValue, onChange }: EditorProps) {
export default function Editor({ initialValue, onChange, placeholder = "Write something..." }: EditorProps) {
const [state, dispatch] = useReducer(editorReducer, {
openNode: false,
openColor: false,
openLink: false,
openAI: false,
});
// Add a ref to store the editor content to prevent losing it on refresh
const contentRef = useRef<string>("");
const { openNode, openColor, openLink, openAI } = state;
return (
<div className="relative w-full max-w-[220px] sm:max-w-[400px]">
<div
className="relative w-full max-w-[450px] sm:max-w-[600px]"
onKeyDown={(e) => {
// Prevent form submission on Enter key
if (e.key === 'Enter' && !e.shiftKey) {
e.stopPropagation();
}
}}
>
<EditorRoot>
<EditorContent
immediatelyRender={false}
initialContent={initialValue}
extensions={extensions}
className="min-h-96 max-w-[220px] sm:max-w-[400px]"
className="min-h-96 max-w-[450px] sm:max-w-[600px]"
editorProps={{
handleDOMEvents: {
keydown: (_view, event) => handleCommandNavigation(event),
@@ -96,14 +109,28 @@ export default function Editor({ initialValue, onChange }: EditorProps) {
attributes: {
class:
"prose dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full",
"data-placeholder": placeholder,
},
}}
onUpdate={({ editor }) => {
// Store the content in the ref to prevent losing it
contentRef.current = editor.getHTML();
onChange(editor.getHTML());
}}
slotAfter={<ImageResizer />}
>
<EditorCommand className="border-muted bg-background z-50 h-auto max-h-[330px] overflow-y-auto rounded-md border px-1 py-2 shadow-md transition-all">
{/* Make sure the command palette doesn't cause a refresh */}
<EditorCommand
className="border-muted bg-background z-50 h-auto max-h-[330px] overflow-y-auto rounded-md border px-1 py-2 shadow-md transition-all"
onKeyDown={(e) => {
// Prevent form submission on any key that might trigger it
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
e.preventDefault();
e.stopPropagation();
}
}}
>
{/* Rest of the command palette */}
<EditorCommandEmpty className="text-muted-foreground px-2">
No results
</EditorCommandEmpty>
@@ -111,7 +138,11 @@ export default function Editor({ initialValue, onChange }: EditorProps) {
{suggestionItems.map((item) => (
<EditorCommandItem
value={item.title}
onCommand={(val) => item.command?.(val)}
onCommand={(val) => {
// Prevent default behavior that might cause refresh
item.command?.(val);
return false;
}}
className="hover:bg-accent aria-selected:bg-accent flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-[10px]"
key={item.title}
>
@@ -127,6 +158,7 @@ export default function Editor({ initialValue, onChange }: EditorProps) {
</EditorCommandList>
</EditorCommand>
{/* Rest of the editor menu */}
<EditorMenu
open={openAI}
onOpenChange={(open) => dispatch({ type: "TOGGLE_AI", payload: open })}

View File

@@ -237,11 +237,11 @@ export function Mail({ folder }: MailProps) {
<>
<ResizableHandle className="opacity-0" />
<ResizablePanel
className="shadow-sm md:flex md:rounded-2xl md:border md:shadow-sm"
className="shadow-sm md:flex md:rounded-2xl md:border md:shadow-sm bg-offsetLight dark:bg-offsetDark"
defaultSize={75}
minSize={25}
>
<div className="hidden h-[calc(100vh-(12px+14px))] flex-1 md:block">
<div className="hidden h-[calc(100vh-(12px+14px))] flex-1 md:block relative top-2">
<ThreadDisplay mail={mail.selected} onClose={handleClose} />
</div>
</ResizablePanel>

View File

@@ -1,15 +1,19 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { FileIcon, Paperclip, Reply, Send, X } from "lucide-react";
import { ArrowUp, FileIcon, Paperclip, Reply, Send, X, Plus } from "lucide-react";
import { cleanEmailAddress, truncateFileName } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
import Editor from "@/components/create/editor";
import { Button } from "@/components/ui/button";
import { sendEmail } from "@/actions/send";
import { useRef, useState } from "react";
import { ParsedMessage } from "@/types";
import { Badge } from "../ui/badge";
import { JSONContent } from "novel";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { toast } from "sonner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
export default function ReplyCompose({ emailData }: { emailData: ParsedMessage[] }) {
const editorRef = useRef<HTMLTextAreaElement>(null);
@@ -17,6 +21,8 @@ export default function ReplyCompose({ emailData }: { emailData: ParsedMessage[]
const [isUploading, setIsUploading] = useState(false);
const [messageContent, setMessageContent] = useState("");
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
const [isComposerOpen, setIsComposerOpen] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const handleAttachment = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
@@ -37,6 +43,34 @@ export default function ReplyCompose({ emailData }: { emailData: ParsedMessage[]
const handleFocus = () => setIsTextAreaFocused(true);
const handleBlur = () => setIsTextAreaFocused(false);
const handleDragOver = (e: React.DragEvent) => {
if (!e.target || !(e.target as HTMLElement).closest('.ProseMirror')) {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
if (!e.target || !(e.target as HTMLElement).closest('.ProseMirror')) {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}
};
const handleDrop = (e: React.DragEvent) => {
if (!e.target || !(e.target as HTMLElement).closest('.ProseMirror')) {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files) {
setAttachments([...attachments, ...Array.from(e.dataTransfer.files)]);
}
}
};
const constructReplyBody = (
formattedMessage: string,
originalDate: string,
@@ -76,10 +110,7 @@ export default function ReplyCompose({ emailData }: { emailData: ParsedMessage[]
const messageId = emailData[0]?.messageId;
const threadId = emailData[0]?.threadId;
const formattedMessage = messageContent
.split("\n")
.map((line) => `<div>${line || "<br/>"}</div>`)
.join("");
const formattedMessage = messageContent;
const replyBody = constructReplyBody(
formattedMessage,
@@ -109,6 +140,7 @@ export default function ReplyCompose({ emailData }: { emailData: ParsedMessage[]
});
setMessageContent("");
setIsComposerOpen(false);
toast.success("Email sent successfully!");
} catch (error) {
console.error("Error sending email:", error);
@@ -116,15 +148,69 @@ export default function ReplyCompose({ emailData }: { emailData: ParsedMessage[]
}
};
const toggleComposer = () => {
setIsComposerOpen(!isComposerOpen);
if (!isComposerOpen) {
setTimeout(() => {
editorRef.current?.focus();
}, 0);
}
};
// Check if the message is empty
const isMessageEmpty = !messageContent || messageContent === JSON.stringify({
type: "doc",
content: [
{
type: "paragraph",
content: [],
},
],
});
// Check if form is valid for submission
const isFormValid = !isMessageEmpty || attachments.length > 0;
if (!isComposerOpen) {
return (
<div className="bg-offsetLight dark:bg-offsetDark w-full p-2">
<Button
onClick={toggleComposer}
className="flex h-12 w-full items-center justify-center gap-2 rounded-md"
variant="outline"
>
<Reply className="h-4 w-4" />
<span>Reply to {emailData[emailData.length - 1]?.sender?.name || "this email"}</span>
</Button>
</div>
);
}
return (
<div className="w-full bg-offsetLight p-2 dark:bg-offsetDark">
<div className="bg-offsetLight dark:bg-offsetDark w-full p-2">
<form
className={cn(
"flex h-72 flex-col space-y-2.5 rounded-[10px] border border-border px-2 py-4",
"border-border flex h-[300px] flex-col space-y-2.5 rounded-[10px] border px-2 py-4 relative",
isTextAreaFocused ? "ring-2 ring-[#3D3D3D]" : "",
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onSubmit={(e) => {
// Prevent default form submission
e.preventDefault();
}}
>
<div className="flex items-center justify-between text-sm text-muted-foreground">
{isDragging && (
<div className="absolute inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center border-2 border-dashed border-primary/30 rounded-2xl m-4">
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Paperclip className="h-12 w-12 text-muted-foreground" />
<p className="text-lg font-medium">Drop files to attach</p>
</div>
</div>
)}
<div className="text-muted-foreground flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Reply className="h-4 w-4" />
<p className="truncate">
@@ -132,105 +218,127 @@ export default function ReplyCompose({ emailData }: { emailData: ParsedMessage[]
{emailData[emailData.length - 1]?.sender?.email})
</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.preventDefault();
toggleComposer();
}}
>
<X className="h-4 w-4" />
</Button>
</div>
<Textarea
ref={editorRef}
className="min-h-[40px] w-full flex-grow resize-none rounded-2xl border-0 bg-transparent leading-relaxed placeholder:text-muted-foreground/70 focus:outline-none md:text-base"
placeholder="Write your reply..."
spellCheck={true}
value={messageContent}
onChange={(e) => {
setMessageContent(e.target.value);
}}
onFocus={handleFocus}
onBlur={handleBlur}
/>
{(attachments.length > 0 || isUploading) && (
<div className="relative z-50 min-h-[32px]">
<div className="hide-scrollbar absolute inset-x-0 flex gap-2 overflow-x-auto">
{isUploading && (
<Badge
variant="secondary"
className="inline-flex shrink-0 animate-pulse items-center bg-background/50 px-2 py-1.5 text-xs"
>
Uploading...
</Badge>
)}
{attachments.map((file, index) => (
<Tooltip key={index}>
<TooltipTrigger asChild>
<Badge
key={index}
variant="secondary"
className="inline-flex shrink-0 items-center gap-1 bg-background/50 px-2 py-1.5 text-xs"
>
<span className="max-w-[120px] truncate">{truncateFileName(file.name)}</span>
<Button
variant="ghost"
size="icon"
className="ml-1 h-4 w-4 hover:bg-background/80"
onClick={(e) => {
e.preventDefault();
removeAttachment(index);
}}
>
<X className="h-3 w-3" />
</Button>
</Badge>
</TooltipTrigger>
<TooltipContent className="w-64 p-0">
<div className="relative h-32 w-full">
{file.type.startsWith("image/") ? (
<Image
src={URL.createObjectURL(file) || "/placeholder.svg"}
alt={file.name}
fill
className="rounded-t-md object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center p-4">
<FileIcon className="h-16 w-16 text-primary" />
</div>
)}
</div>
<div className="bg-secondary p-2">
<p className="text-sm font-medium">{truncateFileName(file.name, 30)}</p>
<p className="text-xs text-muted-foreground">
Size: {(file.size / (1024 * 1024)).toFixed(2)} MB
</p>
<p className="text-xs text-muted-foreground">
Last modified: {new Date(file.lastModified).toLocaleDateString()}
</p>
</div>
</TooltipContent>
</Tooltip>
))}
</div>
<div className="w-full flex-grow overflow-hidden p-1">
<div
className=" h-full w-full"
onDragOver={(e) => e.stopPropagation()}
onDragLeave={(e) => e.stopPropagation()}
onDrop={(e) => e.stopPropagation()}
>
<Editor
onChange={(content) => {
setMessageContent(content);
}}
initialValue={{
type: "doc",
content: [
{
type: "paragraph",
content: [],
},
],
}}
placeholder="Type your reply here..."
/>
</div>
)}
</div>
<div className="mt-auto flex items-center justify-between">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
type="button"
className="h-8 w-8 hover:bg-background/80"
onClick={(e) => {
e.preventDefault();
document.getElementById("attachment-input")?.click();
}}
>
<Paperclip className="h-4 w-4" />
<span className="sr-only">Add attachment</span>
</Button>
</TooltipTrigger>
<TooltipContent>Attach file</TooltipContent>
</Tooltip>
<Button
variant="outline"
className="group relative w-9 overflow-hidden transition-all duration-200 hover:w-32"
onClick={(e) => {
e.preventDefault();
document.getElementById("attachment-input")?.click();
}}
>
<Plus className="absolute left-[9px] h-6 w-6" />
<span className="whitespace-nowrap pl-7 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
Attachments
</span>
</Button>
{attachments.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="flex items-center gap-2">
<Paperclip className="h-4 w-4" />
<span>
{attachments.length} attachment{attachments.length !== 1 ? "s" : ""}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 touch-auto" align="start">
<div className="space-y-2">
<div className="px-1">
<h4 className="font-medium leading-none">Attachments</h4>
<p className="text-muted-foreground text-sm">
{attachments.length} file{attachments.length !== 1 ? "s" : ""} attached
</p>
</div>
<Separator />
<div className="h-[300px] touch-auto overflow-y-auto overscroll-contain px-1 py-1">
<div className="grid grid-cols-2 gap-2">
{attachments.map((file, index) => (
<div
key={index}
className="group relative overflow-hidden rounded-md border"
>
<div className="relative h-24 w-full overflow-hidden bg-muted">
{file.type.startsWith("image/") ? (
<Image
src={URL.createObjectURL(file) || "/placeholder.svg"}
alt={file.name}
fill
className="object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<FileIcon className="text-primary h-10 w-10" />
</div>
)}
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1 h-6 w-6 rounded-full bg-background/80 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.preventDefault();
removeAttachment(index);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="bg-muted/10 p-2">
<p className="text-xs font-medium">
{truncateFileName(file.name, 20)}
</p>
<p className="text-muted-foreground text-xs">
{(file.size / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
</div>
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
)}
<input
type="file"
id="attachment-input"
@@ -244,8 +352,20 @@ export default function ReplyCompose({ emailData }: { emailData: ParsedMessage[]
<Button variant="ghost" size="sm" className="h-8">
Save draft
</Button>
<Button size="sm" className="h-8" onClick={handleSendEmail}>
Send
<Button
size="sm"
className="group relative h-8 w-9 overflow-hidden transition-all duration-200 hover:w-24"
onClick={(e) => {
e.preventDefault();
handleSendEmail(e);
}}
disabled={!isFormValid}
type="button"
>
<ArrowUp className="absolute left-2.5 h-4 w-4" />
<span className="whitespace-nowrap pl-7 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
Send
</span>
</Button>
</div>
</div>