chore: initialize @limitless/ui git repo + add AppShell
This brings the long-untracked @limitless/ui source tree under version
control. Until now /srv/k8s/templates/limitless-ui has been a plain
file: dependency consumed by gscChronos / gscCRM / gscAdmin, with
copies scattered across web/gsc{Portal,WWW,Aether,Register}/ and
apps/gsc{Meet,Share}/. None were git-tracked.
Treating /srv/k8s/templates/limitless-ui as the canonical going
forward; secondary copies should be replaced with this version
in their consumers' Dockerfiles when they next get touched.
Changes in this initial commit beyond the snapshot:
- Add src/layout/AppShell.tsx — runtime-loaded chrome (header,
sidebar, footer) backed by gsc-shell-api. Public surface:
AppShell, ShellProvider, useShell, ShellConfig types
Framework-agnostic (no Next.js dep). Apps pass appKey + apiUrl +
getToken; AppShell composes the existing PageShell / Navbar /
Sidebar / Footer primitives with API data.
- Re-export AppShell from src/index.ts.
- Fix build script: `tsc -p tsconfig.json --noEmit false`. The bare
`tsc` command was a no-op because tsconfig.json sets noEmit:true
for typecheck speed. Existing dist/ only existed because of an
earlier emit; clean rebuilds were silently broken.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
813
src/pages/Mail.tsx
Normal file
813
src/pages/Mail.tsx
Normal file
@@ -0,0 +1,813 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
// Types
|
||||
export interface MailMessage {
|
||||
id: string;
|
||||
from: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
};
|
||||
to?: {
|
||||
name: string;
|
||||
email: string;
|
||||
}[];
|
||||
subject: string;
|
||||
preview: string;
|
||||
body?: string;
|
||||
date: Date;
|
||||
isRead?: boolean;
|
||||
isStarred?: boolean;
|
||||
hasAttachment?: boolean;
|
||||
attachments?: MailAttachment[];
|
||||
labels?: string[];
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
export interface MailAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
size: string;
|
||||
type: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface MailFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: React.ReactNode;
|
||||
count?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface MailLabel {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Mail Layout
|
||||
export interface MailLayoutProps {
|
||||
/** Sidebar content (folders, labels) */
|
||||
sidebar?: React.ReactNode;
|
||||
/** Main content area */
|
||||
children: React.ReactNode;
|
||||
/** Compose button handler */
|
||||
onCompose?: () => void;
|
||||
/** Show sidebar */
|
||||
showSidebar?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MailLayout: React.FC<MailLayoutProps> = ({
|
||||
sidebar,
|
||||
children,
|
||||
onCompose,
|
||||
showSidebar = true,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`ll-mail-layout ${className}`}>
|
||||
{showSidebar && (
|
||||
<div className="ll-mail-sidebar">
|
||||
{onCompose && (
|
||||
<button className="ll-mail-compose-btn" onClick={onCompose}>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
||||
</svg>
|
||||
Compose
|
||||
</button>
|
||||
)}
|
||||
{sidebar}
|
||||
</div>
|
||||
)}
|
||||
<div className="ll-mail-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Mail Sidebar
|
||||
export interface MailSidebarProps {
|
||||
/** Folders list */
|
||||
folders?: MailFolder[];
|
||||
/** Labels list */
|
||||
labels?: MailLabel[];
|
||||
/** Active folder ID */
|
||||
activeFolder?: string;
|
||||
/** Folder click handler */
|
||||
onFolderClick?: (folder: MailFolder) => void;
|
||||
/** Label click handler */
|
||||
onLabelClick?: (label: MailLabel) => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MailSidebar: React.FC<MailSidebarProps> = ({
|
||||
folders = [],
|
||||
labels = [],
|
||||
activeFolder,
|
||||
onFolderClick,
|
||||
onLabelClick,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`ll-mail-sidebar-content ${className}`}>
|
||||
{folders.length > 0 && (
|
||||
<div className="ll-mail-folders">
|
||||
<div className="ll-mail-section-title">Folders</div>
|
||||
<ul className="ll-mail-folder-list">
|
||||
{folders.map((folder) => (
|
||||
<li
|
||||
key={folder.id}
|
||||
className={`ll-mail-folder-item ${activeFolder === folder.id ? 'll-mail-folder-active' : ''}`}
|
||||
onClick={() => onFolderClick?.(folder)}
|
||||
>
|
||||
{folder.icon && <span className="ll-mail-folder-icon">{folder.icon}</span>}
|
||||
<span className="ll-mail-folder-name">{folder.name}</span>
|
||||
{folder.count !== undefined && folder.count > 0 && (
|
||||
<span className="ll-mail-folder-count">{folder.count}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{labels.length > 0 && (
|
||||
<div className="ll-mail-labels">
|
||||
<div className="ll-mail-section-title">Labels</div>
|
||||
<ul className="ll-mail-label-list">
|
||||
{labels.map((label) => (
|
||||
<li
|
||||
key={label.id}
|
||||
className="ll-mail-label-item"
|
||||
onClick={() => onLabelClick?.(label)}
|
||||
>
|
||||
<span
|
||||
className="ll-mail-label-dot"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
<span className="ll-mail-label-name">{label.name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Mail List
|
||||
export interface MailListProps {
|
||||
/** Messages to display */
|
||||
messages: MailMessage[];
|
||||
/** Selected message IDs */
|
||||
selectedIds?: string[];
|
||||
/** Selection change handler */
|
||||
onSelectionChange?: (ids: string[]) => void;
|
||||
/** Message click handler */
|
||||
onMessageClick?: (message: MailMessage) => void;
|
||||
/** Star toggle handler */
|
||||
onStarToggle?: (message: MailMessage) => void;
|
||||
/** Show checkboxes */
|
||||
showCheckboxes?: boolean;
|
||||
/** Loading state */
|
||||
loading?: boolean;
|
||||
/** Empty state message */
|
||||
emptyMessage?: string;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MailList: React.FC<MailListProps> = ({
|
||||
messages,
|
||||
selectedIds = [],
|
||||
onSelectionChange,
|
||||
onMessageClick,
|
||||
onStarToggle,
|
||||
showCheckboxes = true,
|
||||
loading = false,
|
||||
emptyMessage = 'No messages',
|
||||
className = '',
|
||||
}) => {
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (selectedIds.length === messages.length) {
|
||||
onSelectionChange?.([]);
|
||||
} else {
|
||||
onSelectionChange?.(messages.map((m) => m.id));
|
||||
}
|
||||
}, [selectedIds, messages, onSelectionChange]);
|
||||
|
||||
const handleSelectMessage = useCallback((id: string) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onSelectionChange?.(selectedIds.filter((sid) => sid !== id));
|
||||
} else {
|
||||
onSelectionChange?.([...selectedIds, id]);
|
||||
}
|
||||
}, [selectedIds, onSelectionChange]);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (days === 1) {
|
||||
return 'Yesterday';
|
||||
} else if (days < 7) {
|
||||
return date.toLocaleDateString([], { weekday: 'short' });
|
||||
} else {
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`ll-mail-list ll-mail-list-loading ${className}`}>
|
||||
<div className="ll-mail-loading-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className={`ll-mail-list ll-mail-list-empty ${className}`}>
|
||||
<div className="ll-mail-empty-icon">
|
||||
<svg viewBox="0 0 24 24" width="48" height="48" fill="currentColor">
|
||||
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="ll-mail-empty-text">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`ll-mail-list ${className}`}>
|
||||
{showCheckboxes && (
|
||||
<div className="ll-mail-list-header">
|
||||
<label className="ll-mail-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.length === messages.length && messages.length > 0}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
<span className="ll-mail-checkbox-mark" />
|
||||
</label>
|
||||
<span className="ll-mail-list-info">
|
||||
{selectedIds.length > 0 ? `${selectedIds.length} selected` : `${messages.length} messages`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ll-mail-messages">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`ll-mail-message ${!message.isRead ? 'll-mail-message-unread' : ''} ${
|
||||
selectedIds.includes(message.id) ? 'll-mail-message-selected' : ''
|
||||
}`}
|
||||
>
|
||||
{showCheckboxes && (
|
||||
<label className="ll-mail-checkbox" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(message.id)}
|
||||
onChange={() => handleSelectMessage(message.id)}
|
||||
/>
|
||||
<span className="ll-mail-checkbox-mark" />
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`ll-mail-star ${message.isStarred ? 'll-mail-star-active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onStarToggle?.(message);
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="ll-mail-message-content" onClick={() => onMessageClick?.(message)}>
|
||||
<div className="ll-mail-message-avatar">
|
||||
{message.from.avatar ? (
|
||||
<img src={message.from.avatar} alt={message.from.name} />
|
||||
) : (
|
||||
<span className="ll-mail-message-avatar-placeholder">
|
||||
{message.from.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ll-mail-message-info">
|
||||
<div className="ll-mail-message-header">
|
||||
<span className="ll-mail-message-sender">{message.from.name}</span>
|
||||
<span className="ll-mail-message-date">{formatDate(message.date)}</span>
|
||||
</div>
|
||||
<div className="ll-mail-message-subject">{message.subject}</div>
|
||||
<div className="ll-mail-message-preview">{message.preview}</div>
|
||||
</div>
|
||||
|
||||
{message.hasAttachment && (
|
||||
<div className="ll-mail-message-attachment">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Mail Toolbar
|
||||
export interface MailToolbarProps {
|
||||
/** Selected count */
|
||||
selectedCount?: number;
|
||||
/** Archive handler */
|
||||
onArchive?: () => void;
|
||||
/** Delete handler */
|
||||
onDelete?: () => void;
|
||||
/** Mark as read handler */
|
||||
onMarkRead?: () => void;
|
||||
/** Mark as unread handler */
|
||||
onMarkUnread?: () => void;
|
||||
/** Move handler */
|
||||
onMove?: () => void;
|
||||
/** Refresh handler */
|
||||
onRefresh?: () => void;
|
||||
/** Search value */
|
||||
searchValue?: string;
|
||||
/** Search change handler */
|
||||
onSearchChange?: (value: string) => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MailToolbar: React.FC<MailToolbarProps> = ({
|
||||
selectedCount = 0,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onMarkRead,
|
||||
onMarkUnread,
|
||||
onMove,
|
||||
onRefresh,
|
||||
searchValue = '',
|
||||
onSearchChange,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`ll-mail-toolbar ${className}`}>
|
||||
<div className="ll-mail-toolbar-actions">
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
{onArchive && (
|
||||
<button className="ll-mail-toolbar-btn" onClick={onArchive} title="Archive">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button className="ll-mail-toolbar-btn ll-mail-toolbar-btn-danger" onClick={onDelete} title="Delete">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onMarkRead && (
|
||||
<button className="ll-mail-toolbar-btn" onClick={onMarkRead} title="Mark as read">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onMarkUnread && (
|
||||
<button className="ll-mail-toolbar-btn" onClick={onMarkUnread} title="Mark as unread">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M22 8.98V18c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2h10.1c-.06.32-.1.66-.1 1 0 1.48.65 2.79 1.67 3.71L12 11 4 6v2l8 5 5.3-3.32c.54.2 1.1.32 1.7.32 1.13 0 2.16-.39 3-1.02zM16 5c0 1.66 1.34 3 3 3s3-1.34 3-3-1.34-3-3-3-3 1.34-3 3z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onMove && (
|
||||
<button className="ll-mail-toolbar-btn" onClick={onMove} title="Move to">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M20 6h-8l-2-2H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm0 12H4V8h16v10z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
onRefresh && (
|
||||
<button className="ll-mail-toolbar-btn" onClick={onRefresh} title="Refresh">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onSearchChange && (
|
||||
<div className="ll-mail-search">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search mail..."
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Mail Read View
|
||||
export interface MailReadProps {
|
||||
/** Message to display */
|
||||
message: MailMessage;
|
||||
/** Back button handler */
|
||||
onBack?: () => void;
|
||||
/** Reply handler */
|
||||
onReply?: () => void;
|
||||
/** Reply all handler */
|
||||
onReplyAll?: () => void;
|
||||
/** Forward handler */
|
||||
onForward?: () => void;
|
||||
/** Delete handler */
|
||||
onDelete?: () => void;
|
||||
/** Star toggle handler */
|
||||
onStarToggle?: () => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MailRead: React.FC<MailReadProps> = ({
|
||||
message,
|
||||
onBack,
|
||||
onReply,
|
||||
onReplyAll,
|
||||
onForward,
|
||||
onDelete,
|
||||
onStarToggle,
|
||||
className = '',
|
||||
}) => {
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString([], {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`ll-mail-read ${className}`}>
|
||||
<div className="ll-mail-read-header">
|
||||
{onBack && (
|
||||
<button className="ll-mail-read-back" onClick={onBack}>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="ll-mail-read-actions">
|
||||
{onReply && (
|
||||
<button className="ll-mail-read-btn" onClick={onReply} title="Reply">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onReplyAll && (
|
||||
<button className="ll-mail-read-btn" onClick={onReplyAll} title="Reply All">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M7 8V5l-7 7 7 7v-3l-4-4 4-4zm6 1V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onForward && (
|
||||
<button className="ll-mail-read-btn" onClick={onForward} title="Forward">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M14 9V5l7 7-7 7v-4.1c-5 0-8.5 1.6-11 5.1 1-5 4-10 11-11z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button className="ll-mail-read-btn ll-mail-read-btn-danger" onClick={onDelete} title="Delete">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onStarToggle && (
|
||||
<button
|
||||
className={`ll-mail-read-btn ${message.isStarred ? 'll-mail-read-btn-starred' : ''}`}
|
||||
onClick={onStarToggle}
|
||||
title={message.isStarred ? 'Remove star' : 'Add star'}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
|
||||
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ll-mail-read-subject">
|
||||
<h2>{message.subject}</h2>
|
||||
{message.labels && message.labels.length > 0 && (
|
||||
<div className="ll-mail-read-labels">
|
||||
{message.labels.map((label, index) => (
|
||||
<span key={index} className="ll-mail-read-label">{label}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ll-mail-read-meta">
|
||||
<div className="ll-mail-read-avatar">
|
||||
{message.from.avatar ? (
|
||||
<img src={message.from.avatar} alt={message.from.name} />
|
||||
) : (
|
||||
<span className="ll-mail-read-avatar-placeholder">
|
||||
{message.from.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ll-mail-read-info">
|
||||
<div className="ll-mail-read-sender">
|
||||
<strong>{message.from.name}</strong>
|
||||
<span><{message.from.email}></span>
|
||||
</div>
|
||||
<div className="ll-mail-read-recipients">
|
||||
to {message.to?.map((r) => r.name).join(', ') || 'me'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ll-mail-read-date">
|
||||
{formatDate(message.date)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ll-mail-read-body">
|
||||
{message.body || message.preview}
|
||||
</div>
|
||||
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="ll-mail-read-attachments">
|
||||
<div className="ll-mail-read-attachments-header">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
|
||||
</svg>
|
||||
<span>{message.attachments.length} attachment{message.attachments.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="ll-mail-read-attachments-list">
|
||||
{message.attachments.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={attachment.url}
|
||||
className="ll-mail-attachment"
|
||||
download
|
||||
>
|
||||
<div className="ll-mail-attachment-icon">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
|
||||
<path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ll-mail-attachment-info">
|
||||
<span className="ll-mail-attachment-name">{attachment.name}</span>
|
||||
<span className="ll-mail-attachment-size">{attachment.size}</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Mail Compose
|
||||
export interface MailComposeProps {
|
||||
/** Initial values */
|
||||
initialTo?: string;
|
||||
initialSubject?: string;
|
||||
initialBody?: string;
|
||||
/** Send handler */
|
||||
onSend?: (data: { to: string; cc?: string; bcc?: string; subject: string; body: string }) => void;
|
||||
/** Save draft handler */
|
||||
onSaveDraft?: (data: { to: string; cc?: string; bcc?: string; subject: string; body: string }) => void;
|
||||
/** Discard handler */
|
||||
onDiscard?: () => void;
|
||||
/** Close handler */
|
||||
onClose?: () => void;
|
||||
/** Show as modal */
|
||||
isModal?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MailCompose: React.FC<MailComposeProps> = ({
|
||||
initialTo = '',
|
||||
initialSubject = '',
|
||||
initialBody = '',
|
||||
onSend,
|
||||
onSaveDraft,
|
||||
onDiscard,
|
||||
onClose,
|
||||
isModal = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const [to, setTo] = useState(initialTo);
|
||||
const [cc, setCc] = useState('');
|
||||
const [bcc, setBcc] = useState('');
|
||||
const [subject, setSubject] = useState(initialSubject);
|
||||
const [body, setBody] = useState(initialBody);
|
||||
const [showCc, setShowCc] = useState(false);
|
||||
const [showBcc, setShowBcc] = useState(false);
|
||||
|
||||
const handleSend = () => {
|
||||
onSend?.({ to, cc, bcc, subject, body });
|
||||
};
|
||||
|
||||
const handleSaveDraft = () => {
|
||||
onSaveDraft?.({ to, cc, bcc, subject, body });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`ll-mail-compose ${isModal ? 'll-mail-compose-modal' : ''} ${className}`}>
|
||||
<div className="ll-mail-compose-header">
|
||||
<h3>New Message</h3>
|
||||
{onClose && (
|
||||
<button className="ll-mail-compose-close" onClick={onClose}>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ll-mail-compose-form">
|
||||
<div className="ll-mail-compose-field">
|
||||
<label>To</label>
|
||||
<div className="ll-mail-compose-input-row">
|
||||
<input
|
||||
type="email"
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
placeholder="Recipients"
|
||||
/>
|
||||
<div className="ll-mail-compose-cc-toggle">
|
||||
{!showCc && (
|
||||
<button onClick={() => setShowCc(true)}>Cc</button>
|
||||
)}
|
||||
{!showBcc && (
|
||||
<button onClick={() => setShowBcc(true)}>Bcc</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCc && (
|
||||
<div className="ll-mail-compose-field">
|
||||
<label>Cc</label>
|
||||
<input
|
||||
type="email"
|
||||
value={cc}
|
||||
onChange={(e) => setCc(e.target.value)}
|
||||
placeholder="Cc recipients"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBcc && (
|
||||
<div className="ll-mail-compose-field">
|
||||
<label>Bcc</label>
|
||||
<input
|
||||
type="email"
|
||||
value={bcc}
|
||||
onChange={(e) => setBcc(e.target.value)}
|
||||
placeholder="Bcc recipients"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ll-mail-compose-field">
|
||||
<label>Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Subject"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ll-mail-compose-body">
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Write your message..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ll-mail-compose-footer">
|
||||
<div className="ll-mail-compose-actions-left">
|
||||
<button className="ll-mail-compose-btn-primary" onClick={handleSend}>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
Send
|
||||
</button>
|
||||
{onSaveDraft && (
|
||||
<button className="ll-mail-compose-btn" onClick={handleSaveDraft}>
|
||||
Save Draft
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="ll-mail-compose-actions-right">
|
||||
{onDiscard && (
|
||||
<button className="ll-mail-compose-btn ll-mail-compose-btn-danger" onClick={onDiscard}>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Default Mail Folders
|
||||
export const defaultMailFolders: MailFolder[] = [
|
||||
{
|
||||
id: 'inbox',
|
||||
name: 'Inbox',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M19 3H4.99c-1.11 0-1.98.89-1.98 2L3 19c0 1.1.88 2 1.99 2H19c1.1 0 2-.9 2-2V5c0-1.11-.9-2-2-2zm0 12h-4c0 1.66-1.35 3-3 3s-3-1.34-3-3H4.99V5H19v10z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sent',
|
||||
name: 'Sent',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'drafts',
|
||||
name: 'Drafts',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M21.99 8c0-.72-.37-1.35-.94-1.7L12 1 2.95 6.3C2.38 6.65 2 7.28 2 8v10c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2l-.01-10zM12 13L3.74 7.84 12 3l8.26 4.84L12 13z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'spam',
|
||||
name: 'Spam',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'trash',
|
||||
name: 'Trash',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Default Mail Labels
|
||||
export const defaultMailLabels: MailLabel[] = [
|
||||
{ id: 'work', name: 'Work', color: '#3b82f6' },
|
||||
{ id: 'personal', name: 'Personal', color: '#10b981' },
|
||||
{ id: 'important', name: 'Important', color: '#ef4444' },
|
||||
{ id: 'social', name: 'Social', color: '#8b5cf6' },
|
||||
];
|
||||
Reference in New Issue
Block a user