Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import '../src/index.scss';
import type { Preview } from '@storybook/react-vite';
import { withThemeByClassName } from '@storybook/addon-themes';

import '../src/index.scss';

const preview: Preview = {
parameters: {
controls: {
Expand Down
16 changes: 13 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@chromatic-com/storybook": "4.1.3",
"@internxt/eslint-config-internxt": "2.0.1",
"@internxt/prettier-config": "internxt/prettier-config#v1.0.2",
"@storybook/addon-docs": "^10.1.4",
"@storybook/addon-links": "^10.1.4",
"@storybook/addon-onboarding": "^10.1.4",
"@storybook/addon-themes": "^10.1.4",
Expand Down Expand Up @@ -66,8 +67,7 @@
"vite-plugin-dts": "^4.5.4",
"vite-plugin-svgr": "^4.5.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"@storybook/addon-docs": "^10.1.4"
"vitest": "^3.2.4"
},
"scripts": {
"build:tsc": "tsc",
Expand All @@ -90,7 +90,17 @@
"@internxt/css-config": "1.1.0",
"@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/themes": "^3.2.1"
"@radix-ui/themes": "^3.2.1",
"@tiptap/extension-color": "^3.20.0",
"@tiptap/extension-font-family": "^3.20.0",
"@tiptap/extension-image": "^3.20.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-text-align": "^3.20.0",
"@tiptap/extension-text-style": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0"
},
"lint-staged": {
"*.{ts,tsx}": [
Expand Down
192 changes: 192 additions & 0 deletions src/components/mail/ComposeMessageDialog/ComposeMessageDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { PaperclipIcon, XIcon } from '@phosphor-icons/react';
import { useState, useCallback } from 'react';
import { Editor } from '@tiptap/react';

import Input from '../../input/Input';
import { ActionBar } from './components/actionBar/ActionBar';
import { RichTextEditor } from './components/RichTextEditor';
import { RecipientInput } from './components/RecipientInput';
import { Button } from '@/components/button';
import { DefaultAttachmentItem } from './components/DefaultAttachmentItem';
import { Attachment, Recipient } from './types';

export interface ComposeMessageDialogProps {
isOpen: boolean;
title: string;
mailValue: string;
subject?: string;
isLoading?: boolean;
primaryActionColor?: string;
attachments?: Attachment[];
toRecipients?: Recipient[];
ccRecipients?: Recipient[];
bccRecipients?: Recipient[];
text: {
to: string;
cc: string;
bcc: string;
subject: string;
send: string;
};
onClose: () => void;
onPrimaryAction: (html: string) => void;
onMailChange?: (html: string) => void;
onSecondaryAction?: () => void;
onRemoveAttachment?: (id: string) => void;
onAddToRecipient?: (email: string) => void;
onRemoveToRecipient?: (id: string) => void;
onAddCcRecipient?: (email: string) => void;
onRemoveCcRecipient?: (id: string) => void;
onAddBccRecipient?: (email: string) => void;
onRemoveBccRecipient?: (id: string) => void;
onSubjectChange?: (value: string) => void;
}

export const ComposeMessageDialog = ({
isOpen,
title,
isLoading,
primaryActionColor = 'primary',
mailValue,
attachments = [],
toRecipients = [],
ccRecipients = [],
bccRecipients = [],
subject = '',
onClose,
onMailChange,
onPrimaryAction,
onSecondaryAction,
onRemoveAttachment,
onAddToRecipient,
onRemoveToRecipient,
onAddCcRecipient,
onRemoveCcRecipient,
onAddBccRecipient,
onRemoveBccRecipient,
onSubjectChange,
text,
}: ComposeMessageDialogProps) => {
const [editor, setEditor] = useState<Editor | null>(null);
const [showCc, setShowCc] = useState(ccRecipients.length > 0);
const [showBcc, setShowBcc] = useState(bccRecipients.length > 0);

const handleEditorReady = useCallback((editorInstance: Editor) => {
setEditor(editorInstance);
}, []);

const handlePrimaryAction = useCallback(() => {
if (editor) {
const html = editor.getHTML();
onPrimaryAction(html);
}
}, [editor, onPrimaryAction]);

if (!isOpen) {
return null;
}

return (
<div className="fixed inset-0 z-50">
<div
className={`absolute inset-0 bg-gray-100/50 transition-opacity
duration-150 dark:bg-black/75
`}
role="button"
onClick={onClose}
data-testid="dialog-overlay"
></div>

<div
className={`absolute
left-1/2
top-1/2
w-full
max-w-[720px]
-translate-x-1/2
-translate-y-1/2
transform rounded-2xl
bg-surface p-5
transition-all
duration-150
dark:bg-gray-1
`}
>
<div className="flex flex-col space-y-2">
<div className=" flex flex-row justify-between">
<p className="text-lg font-medium text-gray-100">{title}</p>
<XIcon onClick={onClose} className="cursor-pointer" />
</div>
<RecipientInput
label={text.to}
recipients={toRecipients}
onAddRecipient={(email) => onAddToRecipient?.(email)}
onRemoveRecipient={(id) => onRemoveToRecipient?.(id)}
showCcBcc
onCcClick={() => setShowCc(true)}
onBccClick={() => setShowBcc(true)}
showCcButton={!showCc}
showBccButton={!showBcc}
ccButtonText={text.cc}
bccButtonText={text.bcc}
disabled={isLoading}
/>
{showCc && (
<RecipientInput
label={text.cc}
recipients={ccRecipients}
onAddRecipient={(email) => onAddCcRecipient?.(email)}
onRemoveRecipient={(id) => onRemoveCcRecipient?.(id)}
disabled={isLoading}
/>
)}
{showBcc && (
<RecipientInput
label={text.bcc}
recipients={bccRecipients}
onAddRecipient={(email) => onAddBccRecipient?.(email)}
onRemoveRecipient={(id) => onRemoveBccRecipient?.(id)}
disabled={isLoading}
/>
)}
<div className="flex flex-row gap-2 items-center">
<p className="font-medium max-w-[64px] w-full text-gray-100">{text.subject}</p>
<Input className="w-full" value={subject} onChange={onSubjectChange} disabled={isLoading} />
</div>
<div className="w-full flex border border-gray-5" />
<ActionBar editor={editor} disabled={isLoading} />
</div>
<div className="pt-4">
<RichTextEditor
value={mailValue}
onChange={onMailChange}
onEditorReady={handleEditorReady}
className="min-h-[300px]"
disabled={isLoading}
/>
</div>
{attachments.length > 0 && (
<div className="mt-4 max-h-[100px] space-y-2 overflow-y-auto">
{attachments.map((attachment) => {
const handleRemove = () => onRemoveAttachment?.(attachment.id);
return <DefaultAttachmentItem key={attachment.id} attachment={attachment} onRemove={handleRemove} />;
})}
</div>
)}
<div className="mt-5 flex justify-end space-x-2">
<Button variant="ghost" onClick={onSecondaryAction} disabled={isLoading}>
<PaperclipIcon size={24} />
</Button>
<Button
onClick={handlePrimaryAction}
loading={isLoading}
disabled={isLoading}
variant={primaryActionColor === 'primary' ? 'primary' : 'destructive'}
>
{text.send}
</Button>
</div>
</div>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { PaperclipIcon, XIcon } from '@phosphor-icons/react';
import { Attachment } from '../types';

export const DefaultAttachmentItem = ({ attachment, onRemove }: { attachment: Attachment; onRemove: () => void }) => (
<div className="flex items-center justify-between rounded-lg border border-gray-10 bg-gray-5 px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<PaperclipIcon size={16} className="shrink-0 text-gray-50" />
<span className="truncate text-sm text-gray-100">{attachment.name}</span>
<span className="shrink-0 text-xs text-gray-50">({attachment.size})</span>
</div>
<button
type="button"
onClick={onRemove}
className="ml-2 shrink-0 rounded p-1 hover:bg-gray-10"
aria-label={`Remove ${attachment.name}`}
>
<XIcon size={14} className="text-gray-50" />
</button>
</div>
);
100 changes: 100 additions & 0 deletions src/components/mail/ComposeMessageDialog/components/RecipientInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useState, KeyboardEvent } from 'react';
import { RecipientChip } from '../../chips/RecipientChip';
import { Recipient } from '../types';

interface RecipientInputProps {
label: string;
recipients: Recipient[];
onAddRecipient: (email: string) => void;
onRemoveRecipient: (id: string) => void;
showCcBcc?: boolean;
onCcClick?: () => void;
onBccClick?: () => void;
showCcButton?: boolean;
showBccButton?: boolean;
ccButtonText?: string;
bccButtonText?: string;
disabled?: boolean;
}

export const RecipientInput = ({
label,
recipients,
onAddRecipient,
onRemoveRecipient,
showCcBcc = false,
onCcClick,
onBccClick,
showCcButton = true,
showBccButton = true,
ccButtonText = 'CC',
bccButtonText = 'BCC',
disabled,
}: RecipientInputProps) => {
const [inputValue, setInputValue] = useState('');

const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const email = inputValue.trim().replace(/,$/, '');
if (email) {
onAddRecipient(email);
setInputValue('');
}
} else if (e.key === 'Backspace' && inputValue === '' && recipients.length > 0) {
onRemoveRecipient(recipients[recipients.length - 1].id);
}
};

const handleBlur = () => {
const email = inputValue.trim();
if (email) {
onAddRecipient(email);
setInputValue('');
}
};

return (
<div className="flex flex-row gap-2 items-start">
<p className="font-medium max-w-[64px] w-full text-gray-100 py-2">{label}</p>
<div className="flex-1 flex items-center gap-1 flex-wrap rounded-lg border border-gray-10 bg-surface px-3 py-1.5 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary">
{recipients.map((recipient) => (
<RecipientChip key={recipient.id} recipient={recipient} onRemove={() => onRemoveRecipient(recipient.id)} />
))}
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
disabled={disabled}
className={`flex-1 min-w-[120px] bg-transparent text-sm text-gray-100 placeholder:text-gray-40 focus:outline-none py-0.5 ${disabled ? 'cursor-not-allowed' : ''}`}
/>
{showCcBcc && (showCcButton || showBccButton) && (
<div className="flex items-center gap-1 ml-auto shrink-0">
{showCcButton && (
<button
type="button"
onClick={onCcClick}
disabled={disabled}
className={`px-1.5 py-0.5 text-sm font-medium text-primary hover:bg-gray-5 rounded bg-primary/20 hover:bg-primary/30 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{ccButtonText}
</button>
)}
{showBccButton && (
<button
type="button"
onClick={onBccClick}
disabled={disabled}
className={`px-1.5 py-0.5 text-sm font-medium text-primary hover:bg-gray-5 rounded bg-primary/20 hover:bg-primary/30 ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{bccButtonText}
</button>
)}
</div>
)}
</div>
</div>
);
};
Loading