This commit is contained in:
louiscklaw
2025-04-26 09:56:29 +08:00
parent a00d1ee7ce
commit 7d70b5826b
17 changed files with 1256 additions and 92 deletions

View File

@@ -0,0 +1,86 @@
import type { Thread } from '@/components/dashboard/teacher/mail/types';
import { dayjs } from '@/lib/dayjs';
export const SampleThreads = [
{
id: 'TRD-004',
from: { avatar: '/assets/avatar-9.png', email: 'marcus.finn@domain.com', name: 'Marcus Finn' },
to: [{ avatar: '/assets/avatar.png', email: 'sofia@devias.io', name: 'Sofia Rivers' }],
subject: 'Website redesign. Interested in collaboration',
message: `Hey there,
I hope this email finds you well. I'm glad you liked my projects, and I would be happy to provide you with a quote for a similar project.
Please let me know your requirements and any specific details you have in mind, so I can give you an accurate quote.
Looking forward to hearing from you soon.
Best regards,
Marcus Finn`,
attachments: [
{
id: 'ATT-001',
name: 'working-sketch.png',
size: '128.5 KB',
type: 'image',
url: '/assets/image-abstract-1.png',
},
{ id: 'ATT-002', name: 'summer-customers.pdf', size: '782.3 KB', type: 'file', url: '#' },
{
id: 'ATT-003',
name: 'desktop-coffee.png',
size: '568.2 KB',
type: 'image',
url: '/assets/image-minimal-1.png',
},
],
folder: 'inbox',
labels: ['work', 'business'],
isImportant: true,
isStarred: false,
isUnread: true,
createdAt: dayjs().subtract(3, 'hour').toDate(),
},
{
id: 'TRD-003',
to: [{ name: 'Sofia Rivers', avatar: '/assets/avatar.png', email: 'sofia@devias.io' }],
from: { name: 'Miron Vitold', avatar: '/assets/avatar-1.png', email: 'miron.vitold@domain.com' },
subject: 'Amazing work',
message: `Hey, nice projects! I really liked the one in react. What's your quote on kinda similar project?`,
folder: 'spam',
labels: [],
isImportant: false,
isStarred: true,
isUnread: false,
createdAt: dayjs().subtract(1, 'day').toDate(),
},
{
id: 'TRD-002',
from: { name: 'Penjani Inyene', avatar: '/assets/avatar-4.png', email: 'penjani.inyene@domain.com' },
to: [{ name: 'Sofia Rivers', avatar: '/assets/avatar.png', email: 'sofia@devias.io' }],
subject: 'Flight reminder',
message: `Dear Sofia,
Your flight is coming up soon. Please don't forget to check in for your scheduled flight.`,
folder: 'inbox',
labels: ['business'],
isImportant: false,
isStarred: false,
isUnread: false,
createdAt: dayjs().subtract(2, 'day').toDate(),
},
{
id: 'TRD-001',
from: { name: 'Carson Darrin', avatar: '/assets/avatar-3.png', email: 'carson.darrin@domain.com' },
to: [{ name: 'Sofia Rivers', avatar: '/assets/avatar.png', email: 'sofia@devias.io' }],
subject: 'Possible candidates for the position',
message: `My market leading client has another fantastic opportunity for an experienced Software Developer to join them on a heavily remote basis`,
folder: 'trash',
labels: ['personal'],
isImportant: false,
isStarred: false,
isUnread: true,
createdAt: dayjs().subtract(2, 'day').toDate(),
},
] satisfies Thread[];

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import type { Metadata } from 'next';
import { config } from '@/config';
import { ThreadView } from '@/components/dashboard/mail/thread-view';
import { ThreadView } from '@/components/dashboard/teacher/mail/thread-view';
export const metadata = { title: `Thread | Mail | Dashboard | ${config.site.name}` } satisfies Metadata;

View File

@@ -1,9 +1,9 @@
import * as React from 'react';
import { dayjs } from '@/lib/dayjs';
import { MailProvider } from '@/components/dashboard/mail/mail-context';
import { MailView } from '@/components/dashboard/mail/mail-view';
import type { Label, Thread } from '@/components/dashboard/mail/types';
import { MailProvider } from '@/components/dashboard/teacher/mail/mail-context';
import { MailView } from '@/components/dashboard/teacher/mail/mail-view';
import type { Label, Thread } from '@/components/dashboard/teacher/mail/types';
import { SampleThreads } from './SampleThreads';
function filterThreads(threads: Thread[], labelId: string): Thread[] {
return threads.filter((thread) => {
@@ -40,90 +40,6 @@ const labels = [
{ id: 'personal', type: 'custom', name: 'Personal', color: '#FB8A00', unreadCount: 0, totalCount: 1 },
] satisfies Label[];
const threads = [
{
id: 'TRD-004',
from: { avatar: '/assets/avatar-9.png', email: 'marcus.finn@domain.com', name: 'Marcus Finn' },
to: [{ avatar: '/assets/avatar.png', email: 'sofia@devias.io', name: 'Sofia Rivers' }],
subject: 'Website redesign. Interested in collaboration',
message: `Hey there,
I hope this email finds you well. I'm glad you liked my projects, and I would be happy to provide you with a quote for a similar project.
Please let me know your requirements and any specific details you have in mind, so I can give you an accurate quote.
Looking forward to hearing from you soon.
Best regards,
Marcus Finn`,
attachments: [
{
id: 'ATT-001',
name: 'working-sketch.png',
size: '128.5 KB',
type: 'image',
url: '/assets/image-abstract-1.png',
},
{ id: 'ATT-002', name: 'summer-customers.pdf', size: '782.3 KB', type: 'file', url: '#' },
{
id: 'ATT-003',
name: 'desktop-coffee.png',
size: '568.2 KB',
type: 'image',
url: '/assets/image-minimal-1.png',
},
],
folder: 'inbox',
labels: ['work', 'business'],
isImportant: true,
isStarred: false,
isUnread: true,
createdAt: dayjs().subtract(3, 'hour').toDate(),
},
{
id: 'TRD-003',
to: [{ name: 'Sofia Rivers', avatar: '/assets/avatar.png', email: 'sofia@devias.io' }],
from: { name: 'Miron Vitold', avatar: '/assets/avatar-1.png', email: 'miron.vitold@domain.com' },
subject: 'Amazing work',
message: `Hey, nice projects! I really liked the one in react. What's your quote on kinda similar project?`,
folder: 'spam',
labels: [],
isImportant: false,
isStarred: true,
isUnread: false,
createdAt: dayjs().subtract(1, 'day').toDate(),
},
{
id: 'TRD-002',
from: { name: 'Penjani Inyene', avatar: '/assets/avatar-4.png', email: 'penjani.inyene@domain.com' },
to: [{ name: 'Sofia Rivers', avatar: '/assets/avatar.png', email: 'sofia@devias.io' }],
subject: 'Flight reminder',
message: `Dear Sofia,
Your flight is coming up soon. Please don't forget to check in for your scheduled flight.`,
folder: 'inbox',
labels: ['business'],
isImportant: false,
isStarred: false,
isUnread: false,
createdAt: dayjs().subtract(2, 'day').toDate(),
},
{
id: 'TRD-001',
from: { name: 'Carson Darrin', avatar: '/assets/avatar-3.png', email: 'carson.darrin@domain.com' },
to: [{ name: 'Sofia Rivers', avatar: '/assets/avatar.png', email: 'sofia@devias.io' }],
subject: 'Possible candidates for the position',
message: `My market leading client has another fantastic opportunity for an experienced Software Developer to join them on a heavily remote basis`,
folder: 'trash',
labels: ['personal'],
isImportant: false,
isStarred: false,
isUnread: true,
createdAt: dayjs().subtract(2, 'day').toDate(),
},
] satisfies Thread[];
interface LayoutProps {
children: React.ReactNode;
params: { labelId: string };
@@ -132,7 +48,7 @@ interface LayoutProps {
export default function Layout({ children, params }: LayoutProps): React.JSX.Element {
const { labelId } = params;
const filteredThreads = filterThreads(threads, labelId);
const filteredThreads = filterThreads(SampleThreads, labelId);
return (
<MailProvider

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import type { Metadata } from 'next';
import { config } from '@/config';
import { ThreadsView } from '@/components/dashboard/mail/threads-view';
import { ThreadsView } from '@/components/dashboard/teacher/mail/threads-view';
export const metadata = { title: `Mail | Dashboard | ${config.site.name}` } satisfies Metadata;

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import type { Metadata } from 'next';
import { config } from '@/config';
import { ThreadsView } from '@/components/dashboard/mail/threads-view';
import { ThreadsView } from '@/components/dashboard/teacher/mail/threads-view';
export const metadata = { title: `Mail | Dashboard | ${config.site.name}` } satisfies Metadata;

View File

@@ -0,0 +1,53 @@
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { DownloadSimple as DownloadSimpleIcon } from '@phosphor-icons/react/dist/ssr/DownloadSimple';
import { File as FileIcon } from '@phosphor-icons/react/dist/ssr/File';
import type { Attachment } from './types';
export interface AttachmentsProps {
attachments?: Attachment[];
}
export function Attachments({ attachments = [] }: AttachmentsProps): React.JSX.Element {
const count = attachments.length;
return (
<Stack spacing={2}>
<Typography variant="h6">{count} attachments</Typography>
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start', flexWrap: 'wrap' }}>
{attachments.map((attachment) => (
<Stack
direction="row"
key={attachment.id}
spacing={1}
sx={{ alignItems: 'center', cursor: 'pointer', display: 'flex' }}
>
{attachment.type === 'image' ? <Avatar src={attachment.url} variant="rounded" /> : null}
{attachment.type === 'file' ? (
<Avatar variant="rounded">
<FileIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
) : null}
<div>
<Typography noWrap variant="subtitle2">
{attachment.name}
</Typography>
<Typography color="text.secondary" variant="body2">
{attachment.size}
</Typography>
</div>
</Stack>
))}
</Stack>
<div>
<Button color="secondary" size="small" startIcon={<DownloadSimpleIcon />}>
Download all
</Button>
</div>
</Stack>
);
}

View File

@@ -0,0 +1,119 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Input from '@mui/material/Input';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { ArrowsInSimple as ArrowsInSimpleIcon } from '@phosphor-icons/react/dist/ssr/ArrowsInSimple';
import { ArrowsOutSimple as ArrowsOutSimpleIcon } from '@phosphor-icons/react/dist/ssr/ArrowsOutSimple';
import { Image as ImageIcon } from '@phosphor-icons/react/dist/ssr/Image';
import { Paperclip as PaperclipIcon } from '@phosphor-icons/react/dist/ssr/Paperclip';
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
import type { EditorEvents } from '@tiptap/react';
import { TextEditor } from '@/components/core/text-editor/text-editor';
export interface ComposerProps {
onClose?: () => void;
open: boolean;
}
export function Composer({ onClose, open }: ComposerProps): React.JSX.Element | null {
const [isMaximized, setIsMaximized] = React.useState<boolean>(false);
const [message, setMessage] = React.useState<string>('');
const [subject, setSubject] = React.useState<string>('');
const [to, setTo] = React.useState<string>('');
const handleSubjectChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setSubject(event.target.value);
}, []);
const handleMessageChange = React.useCallback(({ editor }: EditorEvents['update']) => {
setMessage(editor.getText());
}, []);
const handleToChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setTo(event.target.value);
}, []);
if (!open) {
return null;
}
return (
<Paper
sx={{
border: '1px solid var(--mui-palette-divider)',
bottom: 0,
boxShadow: 'var(--mui-shadows-16)',
height: '600px',
m: 2,
maxWidth: '100%',
position: 'fixed',
right: 0,
width: '600px',
zIndex: 'var(--mui-zIndex-modal)',
...(isMaximized && { borderRadius: 0, height: '100%', left: 0, m: 0, top: 0, width: '100%' }),
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', display: 'flex', p: 2 }}>
<Box sx={{ flex: '1 1 auto' }}>
<Typography variant="h6">New message</Typography>
</Box>
{isMaximized ? (
<IconButton
onClick={() => {
setIsMaximized(false);
}}
>
<ArrowsInSimpleIcon />
</IconButton>
) : (
<IconButton
onClick={() => {
setIsMaximized(true);
}}
>
<ArrowsOutSimpleIcon />
</IconButton>
)}
<IconButton onClick={onClose}>
<XIcon />
</IconButton>
</Stack>
<div>
<Input onChange={handleToChange} placeholder="To" value={to} />
<Divider />
<Input onChange={handleSubjectChange} placeholder="Subject" value={subject} />
<Divider />
<Box sx={{ '& .tiptap-root': { border: 'none', borderRadius: 0 }, '& .tiptap-container': { height: '300px' } }}>
<TextEditor content={message} onUpdate={handleMessageChange} placeholder="Leave a message" />
</Box>
<Divider />
<Stack direction="row" spacing={3} sx={{ alignItems: 'center', justifyContent: 'space-between', p: 2 }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Tooltip title="Attach image">
<IconButton>
<ImageIcon />
</IconButton>
</Tooltip>
<Tooltip title="Attach file">
<IconButton>
<PaperclipIcon />
</IconButton>
</Tooltip>
</Stack>
<div>
<Button variant="contained">Send</Button>
</div>
</Stack>
</div>
</Paper>
);
}

View File

@@ -0,0 +1,100 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import type { Icon } from '@phosphor-icons/react/dist/lib/types';
import { Bookmark as BookmarkIcon } from '@phosphor-icons/react/dist/ssr/Bookmark';
import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple';
import { File as FileIcon } from '@phosphor-icons/react/dist/ssr/File';
import { PaperPlaneTilt as PaperPlaneTiltIcon } from '@phosphor-icons/react/dist/ssr/PaperPlaneTilt';
import { Star as StarIcon } from '@phosphor-icons/react/dist/ssr/Star';
import { Tag as TagIcon } from '@phosphor-icons/react/dist/ssr/Tag';
import { Trash as TrashIcon } from '@phosphor-icons/react/dist/ssr/Trash';
import { WarningCircle as WarningCircleIcon } from '@phosphor-icons/react/dist/ssr/WarningCircle';
import type { Label } from './types';
const systemLabelIcons: Record<string, Icon> = {
inbox: EnvelopeSimpleIcon,
sent: PaperPlaneTiltIcon,
trash: TrashIcon,
drafts: FileIcon,
spam: WarningCircleIcon,
starred: StarIcon,
important: BookmarkIcon,
};
function getIcon(label: Label): Icon {
if (label.type === 'system') {
return systemLabelIcons[label.id] ?? systemLabelIcons.inbox;
}
return TagIcon;
}
function getIconColor(label: Label, active?: boolean): string {
if (label.type === 'custom') {
return label.color ?? 'var(--mui-palette-text-secondary)';
}
return active ? 'var(--mui-palette-text-primary)' : 'var(--mui-palette-text-secondary)';
}
export interface LabelItemProps {
active?: boolean;
label: Label;
onSelect?: () => void;
}
export function LabelItem({ active, label, onSelect }: LabelItemProps): React.JSX.Element {
const Icon = getIcon(label);
const iconColor = getIconColor(label, active);
const showUnreadCount = (label.unreadCount ?? 0) > 0;
return (
<Box component="li" sx={{ userSelect: 'none' }}>
<Box
onClick={onSelect}
onKeyUp={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
onSelect?.();
}
}}
role="button"
sx={{
alignItems: 'center',
borderRadius: 1,
color: 'var(--mui-palette-text-secondary)',
cursor: 'pointer',
display: 'flex',
flex: '0 0 auto',
gap: 1,
p: '6px 16px',
textDecoration: 'none',
whiteSpace: 'nowrap',
...(active && { bgcolor: 'var(--mui-palette-action-selected)', color: 'var(--mui-palette-text-primary)' }),
'&:hover': {
...(!active && { bgcolor: 'var(--mui-palette-action-hover)', color: 'var(---mui-palette-text-primary)' }),
},
}}
tabIndex={0}
>
<Box sx={{ display: 'flex', flex: '0 0 auto' }}>
<Icon color={iconColor} fontSize="var(--icon-fontSize-md)" weight={active ? 'fill' : undefined} />
</Box>
<Box sx={{ flex: '1 1 auto' }}>
<Typography
component="span"
sx={{ color: 'inherit', fontSize: '0.875rem', fontWeight: 500, lineHeight: '28px' }}
>
{label.name}
</Typography>
</Box>
{showUnreadCount ? (
<Typography color="inherit" variant="subtitle2">
{label.unreadCount}
</Typography>
) : null}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import * as React from 'react';
import type { Label, Thread } from './types';
function noop(): void {
return undefined;
}
export interface MailContextValue {
labels: Label[];
threads: Thread[];
currentLabelId: string;
openDesktopSidebar: boolean;
setOpenDesktopSidebar: React.Dispatch<React.SetStateAction<boolean>>;
openMobileSidebar: boolean;
setOpenMobileSidebar: React.Dispatch<React.SetStateAction<boolean>>;
openCompose: boolean;
setOpenCompose: React.Dispatch<React.SetStateAction<boolean>>;
}
export const MailContext = React.createContext<MailContextValue>({
labels: [],
threads: [],
currentLabelId: 'inbox',
openDesktopSidebar: true,
setOpenDesktopSidebar: noop,
openMobileSidebar: true,
setOpenMobileSidebar: noop,
openCompose: false,
setOpenCompose: noop,
});
export interface MailProviderProps {
children: React.ReactNode;
labels: Label[];
threads: Thread[];
currentLabelId: string;
}
export function MailProvider({
children,
labels: initialLabels = [],
threads: initialThreads = [],
currentLabelId,
}: MailProviderProps): React.JSX.Element {
const [labels, setLabels] = React.useState<Label[]>([]);
const [threads, setThreads] = React.useState<Thread[]>([]);
const [openDesktopSidebar, setOpenDesktopSidebar] = React.useState<boolean>(true);
const [openMobileSidebar, setOpenMobileSidebar] = React.useState<boolean>(false);
const [openCompose, setOpenCompose] = React.useState<boolean>(false);
React.useEffect((): void => {
setLabels(initialLabels);
}, [initialLabels]);
React.useEffect((): void => {
setThreads(initialThreads);
}, [initialThreads]);
return (
<MailContext.Provider
value={{
labels,
threads,
currentLabelId,
openDesktopSidebar,
setOpenDesktopSidebar,
openMobileSidebar,
setOpenMobileSidebar,
openCompose,
setOpenCompose,
}}
>
{children}
</MailContext.Provider>
);
}

View File

@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import { Composer } from './composer';
import { MailContext } from './mail-context';
import { Sidebar } from './sidebar';
export interface MailViewProps {
children: React.ReactNode;
}
export function MailView({ children }: MailViewProps): React.JSX.Element {
const {
labels,
currentLabelId,
openDesktopSidebar,
openMobileSidebar,
setOpenMobileSidebar,
openCompose,
setOpenCompose,
} = React.useContext(MailContext);
return (
<React.Fragment>
<Box sx={{ display: 'flex', flex: '1 1 0', minHeight: 0 }}>
<Sidebar
currentLabelId={currentLabelId}
labels={labels}
onCloseMobile={() => {
setOpenMobileSidebar(false);
}}
onCompose={() => {
setOpenCompose(true);
}}
openDesktop={openDesktopSidebar}
openMobile={openMobileSidebar}
/>
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', overflow: 'hidden' }}>{children}</Box>
</Box>
<Composer
onClose={() => {
setOpenCompose(false);
}}
open={openCompose}
/>
</React.Fragment>
);
}

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Markdown from 'react-markdown';
export interface MessageProps {
content: string;
}
export function Message({ content }: MessageProps): React.JSX.Element {
return (
<Box
sx={{
'& h2': { fontWeight: 500, fontSize: '1.5rem', lineHeight: 1.2, mb: 3 },
'& h3': { fontWeight: 500, fontSize: '1.25rem', lineHeight: 1.2, mb: 3 },
'& p': { fontWeight: 400, fontSize: '1rem', lineHeight: 1.5, mb: 2, mt: 0 },
}}
>
<Markdown>{content}</Markdown>
</Box>
);
}

View File

@@ -0,0 +1,54 @@
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import OutlinedInput from '@mui/material/OutlinedInput';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import { Image as ImageIcon } from '@phosphor-icons/react/dist/ssr/Image';
import { Link as LinkIcon } from '@phosphor-icons/react/dist/ssr/Link';
import { Paperclip as PaperclipIcon } from '@phosphor-icons/react/dist/ssr/Paperclip';
import { Smiley as SmileyIcon } from '@phosphor-icons/react/dist/ssr/Smiley';
import type { User } from '@/types/user';
const user = {
id: 'USR-000',
name: 'Sofia Rivers',
avatar: '/assets/avatar.png',
email: 'sofia@devias.io',
} satisfies User;
export function Reply(): React.JSX.Element {
return (
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start', flex: '0 0 auto', p: 3 }}>
<Avatar src={user.avatar} />
<Stack spacing={2} sx={{ flex: '1 1 auto' }}>
<OutlinedInput maxRows={7} minRows={3} multiline placeholder="Leave a message" />
<Stack direction="row" spacing={3} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Tooltip title="Attach image">
<IconButton>
<ImageIcon />
</IconButton>
</Tooltip>
<Tooltip title="Attach file">
<IconButton>
<PaperclipIcon />
</IconButton>
</Tooltip>
<IconButton>
<LinkIcon />
</IconButton>
<IconButton>
<SmileyIcon />
</IconButton>
</Stack>
<div>
<Button variant="contained">Reply</Button>
</div>
</Stack>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,172 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import Drawer from '@mui/material/Drawer';
import IconButton from '@mui/material/IconButton';
import ListSubheader from '@mui/material/ListSubheader';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
import { paths } from '@/paths';
import { useMediaQuery } from '@/hooks/use-media-query';
import { LabelItem } from './label-item';
import type { Label, LabelType } from './types';
interface GroupedLabels {
system: Label[];
custom: Label[];
}
function groupLabels(labels: Label[]): GroupedLabels {
const groups: GroupedLabels = { system: [], custom: [] };
labels.forEach((label) => {
if (label.type === 'system') {
groups.system.push(label);
} else {
groups.custom.push(label);
}
});
return groups;
}
// A single sidebar component is used, because you might need to have internal state
// shared between both mobile and desktop
export interface SidebarProps {
currentLabelId?: string;
labels: Label[];
openDesktop?: boolean;
openMobile?: boolean;
onCloseMobile?: () => void;
onCompose?: () => void;
}
export function Sidebar({
currentLabelId,
labels,
onCloseMobile,
onCompose,
openDesktop = false,
openMobile = false,
}: SidebarProps): React.JSX.Element {
const mdUp = useMediaQuery('up', 'md');
const content = (
<SidebarContent
closeOnSelect={!mdUp}
currentLabelId={currentLabelId}
labels={labels}
onClose={onCloseMobile}
onCompose={onCompose}
/>
);
if (mdUp) {
return (
<Box
sx={{
borderRight: '1px solid var(--mui-palette-divider)',
flex: '0 0 auto',
ml: openDesktop ? 0 : '-320px',
position: 'relative',
transition: 'margin 225ms cubic-bezier(0.0, 0, 0.2, 1) 0ms',
width: '320px',
}}
>
{content}
</Box>
);
}
return (
<Drawer PaperProps={{ sx: { maxWidth: '100%', width: '320px' } }} onClose={onCloseMobile} open={openMobile}>
{content}
</Drawer>
);
}
export interface SidebarContentProps {
closeOnSelect?: boolean;
currentLabelId?: string;
labels: Label[];
open?: boolean;
onClose?: () => void;
onCompose?: () => void;
}
function SidebarContent({
closeOnSelect,
currentLabelId,
labels,
onClose,
onCompose,
}: SidebarContentProps): React.JSX.Element {
const router = useRouter();
const handleLabelSelect = React.useCallback(
(labelId: string) => {
if (closeOnSelect) {
onClose?.();
}
const href = paths.dashboard.mail.list(labelId);
router.push(href);
},
[router, closeOnSelect, onClose]
);
// Maybe use memo
const groupedLabels: GroupedLabels = groupLabels(labels);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Stack spacing={1} sx={{ flex: '0 0 auto', p: 2 }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="h5">Mailbox</Typography>
<IconButton onClick={onClose} sx={{ display: { md: 'none' } }}>
<XIcon />
</IconButton>
</Stack>
<Button onClick={onCompose} startIcon={<PlusIcon />} variant="contained">
Compose
</Button>
</Stack>
<Divider />
<Box sx={{ flex: '1 1 auto', overflowY: 'auto', p: 2 }}>
{Object.keys(groupedLabels).map((type) => (
<React.Fragment key={type}>
{type === 'custom' ? (
<ListSubheader disableSticky>
<Typography color="text.secondary" variant="overline">
Custom Labels
</Typography>
</ListSubheader>
) : null}
<Stack component="ul" spacing={1} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{groupedLabels[type as LabelType].map((label) => (
<LabelItem
active={currentLabelId === label.id}
key={label.id}
label={label}
onSelect={() => {
handleLabelSelect(label.id);
}}
/>
))}
</Stack>
</React.Fragment>
))}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Checkbox from '@mui/material/Checkbox';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { BookmarkSimple as BookmarkSimpleIcon } from '@phosphor-icons/react/dist/ssr/BookmarkSimple';
import { Paperclip as PaperclipIcon } from '@phosphor-icons/react/dist/ssr/Paperclip';
import { Star as StarIcon } from '@phosphor-icons/react/dist/ssr/Star';
import { dayjs } from '@/lib/dayjs';
import type { Thread } from './types';
export interface ThreadItemProps {
href: string;
onDeselect?: () => void;
onSelect?: () => void;
selected: boolean;
thread: Thread;
}
export function ThreadItem({ thread, onDeselect, onSelect, selected, href }: ThreadItemProps): React.JSX.Element {
const hasAnyAttachments = (thread.attachments ?? []).length > 0;
const hasManyAttachments = (thread.attachments ?? []).length > 1;
const handleSelectToggle = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
onSelect?.();
} else {
onDeselect?.();
}
},
[onSelect, onDeselect]
);
return (
<Box
sx={{
alignItems: 'center',
borderBottom: '1px solid var(--mui-palette-divider)',
display: 'flex',
gap: 1,
p: 2,
...(!thread.isUnread && {
position: 'relative',
'&::before': {
bgcolor: 'var(--mui-palette-primary-main)',
bottom: 0,
content: '" "',
left: 0,
position: 'absolute',
top: 0,
width: '4px',
},
}),
...(selected && { bgcolor: 'var(--mui-palette-primary-selected)' }),
'&:hover': { ...(!selected && { bgcolor: 'var(--mui-palette-action-hover)' }) },
}}
>
<Box sx={{ alignItems: 'center', display: { md: 'flex', xs: 'none' }, flex: '0 0 auto' }}>
<Checkbox checked={selected} onChange={handleSelectToggle} />
<Tooltip title="Starred">
<IconButton>
<StarIcon
color={thread.isStarred ? 'var(--mui-palette-warning-main)' : undefined}
weight={thread.isStarred ? 'fill' : undefined}
/>
</IconButton>
</Tooltip>
<Tooltip title="Important">
<IconButton>
<BookmarkSimpleIcon
color={thread.isImportant ? 'var(--mui-palette-warning-main)' : undefined}
weight={thread.isImportant ? 'fill' : undefined}
/>
</IconButton>
</Tooltip>
</Box>
<Box
component={RouterLink}
href={href}
sx={{
alignItems: 'center',
color: 'var(--mui-palette-text-primary)',
cursor: 'pointer',
display: 'flex',
flex: '1 1 auto',
flexWrap: { xs: 'wrap', md: 'nowrap' },
gap: 2,
minWidth: 0,
textDecoration: 'none',
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', display: 'flex' }}>
<Avatar src={thread.from.avatar} />
<Typography noWrap sx={{ width: '120px' }} variant={thread.isUnread ? 'body2' : 'subtitle2'}>
{thread.from.name}
</Typography>
</Stack>
<Stack spacing={1} sx={{ flex: '1 1 auto', overflow: 'hidden', width: { xs: '100%', md: 'auto' } }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', maxWidth: '800px', width: '100%' }}>
<Typography noWrap sx={{ minWidth: '100px', maxWidth: '400px' }} variant="subtitle2">
{thread.subject}
</Typography>
<Typography color="text.secondary" noWrap variant="body2">
{thread.message}
</Typography>
</Stack>
{hasAnyAttachments ? (
<Stack direction="row" spacing={1}>
<Chip icon={<PaperclipIcon />} label={thread.attachments![0].name} size="small" variant="soft" />
{hasManyAttachments ? <Chip label="+1" size="small" variant="soft" /> : null}
</Stack>
) : null}
</Stack>
<Typography
color="text.secondary"
sx={{ display: 'block', textAlign: { xs: 'left', md: 'right' }, whiteSpace: 'nowrap', width: '100px' }}
variant="caption"
>
{dayjs(thread.createdAt).format('DD MMM')}
</Typography>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,170 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import Link from '@mui/material/Link';
import OutlinedInput from '@mui/material/OutlinedInput';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { ArrowLeft as ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr/ArrowLeft';
import { CaretLeft as CaretLeftIcon } from '@phosphor-icons/react/dist/ssr/CaretLeft';
import { CaretRight as CaretRightIcon } from '@phosphor-icons/react/dist/ssr/CaretRight';
import { Chat as ChatIcon } from '@phosphor-icons/react/dist/ssr/Chat';
import { Chats as ChatsIcon } from '@phosphor-icons/react/dist/ssr/Chats';
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
import { Trash as TrashIcon } from '@phosphor-icons/react/dist/ssr/Trash';
import { paths } from '@/paths';
import { dayjs } from '@/lib/dayjs';
import { Attachments } from './attachments';
import { MailContext } from './mail-context';
import { Message } from './message';
import { Reply } from './reply';
import type { Thread } from './types';
/**
* This method is used to get the thread from the context based on the thread ID.
* The thread should be loaded from the API in the page, but for the sake of simplicity we are just using the context.
*/
function useThread(threadId: string): Thread | undefined {
const { threads } = React.useContext(MailContext);
return threads.find((thread) => thread.id === threadId);
}
export interface ThreadViewProps {
threadId: string;
}
export function ThreadView({ threadId }: ThreadViewProps): React.JSX.Element {
const { currentLabelId } = React.useContext(MailContext);
const thread = useThread(threadId);
if (!thread) {
return (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '1 1 auto', justifyContent: 'center' }}>
<Typography color="textSecondary" variant="h6">
Thread not found
</Typography>
</Box>
);
}
const backHref = paths.dashboard.mail.list(currentLabelId);
const hasMessage = Boolean(thread.message);
const hasAttachments = (thread.attachments ?? []).length > 0;
return (
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', minHeight: 0 }}>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'center',
borderBottom: '1px solid var(--mui-palette-divider)',
flex: '0 0 auto',
justifyContent: 'space-between',
p: 2,
}}
>
<div>
<IconButton component={RouterLink} href={backHref}>
<ArrowLeftIcon />
</IconButton>
</div>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<OutlinedInput
placeholder="Search message"
startAdornment={
<InputAdornment position="start">
<MagnifyingGlassIcon fontSize="var(--icon-fontSize-md)" />
</InputAdornment>
}
sx={{ width: '200px' }}
/>
<Tooltip title="Previous">
<IconButton>
<CaretLeftIcon />
</IconButton>
</Tooltip>
<Tooltip title="Next">
<IconButton>
<CaretRightIcon />
</IconButton>
</Tooltip>
</Stack>
</Stack>
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', overflowY: 'auto', p: 3 }}>
<Box sx={{ flex: '1 1 auto' }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Avatar src={thread.from.avatar} sx={{ '--Avatar-size': '48px' }} />
<div>
<Typography component="span" variant="subtitle2">
{thread.from.name}
</Typography>{' '}
<Link color="text.secondary" component="span" variant="body2">
{thread.from.email}
</Link>
<Typography color="text.secondary" variant="subtitle2">
To:{' '}
{thread.to.map((person) => (
<Link color="inherit" key={person.email}>
{person.email}
</Link>
))}
</Typography>
{thread.createdAt ? (
<Typography color="text.secondary" noWrap variant="caption">
{dayjs(thread.createdAt).format('MMM D, YYYY hh:mm:ss A')}
</Typography>
) : null}
</div>
</Stack>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', display: { xs: 'none', md: 'flex' } }}>
<Tooltip title="Reply">
<IconButton>
<ChatIcon />
</IconButton>
</Tooltip>
<Tooltip title="Reply all">
<IconButton>
<ChatsIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton>
<TrashIcon />
</IconButton>
</Tooltip>
</Stack>
<Box sx={{ display: { md: 'none' } }}>
<Tooltip title="More options">
<IconButton>
<DotsThreeIcon weight="bold" />
</IconButton>
</Tooltip>
</Box>
</Stack>
<Box sx={{ py: 3 }}>
<Typography variant="h4">{thread.subject}</Typography>
</Box>
<Stack spacing={3}>
{hasMessage ? <Message content={thread.message} /> : null}
{hasAttachments ? <Attachments attachments={thread.attachments} /> : null}
</Stack>
</Box>
<Reply />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,163 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import Checkbox from '@mui/material/Checkbox';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import OutlinedInput from '@mui/material/OutlinedInput';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { ArrowCounterClockwise as ArrowCounterClockwiseIcon } from '@phosphor-icons/react/dist/ssr/ArrowCounterClockwise';
import { CaretLeft as CaretLeftIcon } from '@phosphor-icons/react/dist/ssr/CaretLeft';
import { CaretRight as CaretRightIcon } from '@phosphor-icons/react/dist/ssr/CaretRight';
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
import { List as ListIcon } from '@phosphor-icons/react/dist/ssr/List';
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
import { paths } from '@/paths';
import { useMediaQuery } from '@/hooks/use-media-query';
import { useSelection } from '@/hooks/use-selection';
import { MailContext } from './mail-context';
import { ThreadItem } from './thread-item';
export function ThreadsView(): React.JSX.Element {
const { currentLabelId, threads, setOpenDesktopSidebar, setOpenMobileSidebar } = React.useContext(MailContext);
const mdDown = useMediaQuery('down', 'md');
const threadIds = React.useMemo(() => threads.map((thread) => thread.id), [threads]);
const { deselectAll, deselectOne, selectAll, selectOne, selected } = useSelection(threadIds);
return (
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', minHeight: 0 }}>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: 'center',
borderBottom: '1px solid var(--mui-palette-divider)',
flex: '0 0 auto',
justifyContent: 'space-between',
p: 2,
}}
>
<div>
<IconButton
onClick={() => {
if (mdDown) {
setOpenMobileSidebar((prev) => !prev);
} else {
setOpenDesktopSidebar((prev) => !prev);
}
}}
>
<ListIcon />
</IconButton>
</div>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flex: '0 0 auto' }}>
<OutlinedInput
placeholder="Search thread"
startAdornment={
<InputAdornment position="start">
<MagnifyingGlassIcon fontSize="var(--icon-fontSize-md)" />
</InputAdornment>
}
sx={{ width: '200px' }}
/>
<Tooltip title="Next page">
<IconButton>
<CaretLeftIcon />
</IconButton>
</Tooltip>
<Tooltip title="Previous page">
<IconButton>
<CaretRightIcon />
</IconButton>
</Tooltip>
<Tooltip title="Refresh">
<IconButton sx={{ display: { xs: 'none', md: 'inline-flex' } }}>
<ArrowCounterClockwiseIcon />
</IconButton>
</Tooltip>
</Stack>
</Stack>
{threads.length ? (
<React.Fragment>
<Box
sx={{
alignItems: 'center',
borderBottom: '1px solid var(--mui-palette-divider)',
display: { xs: 'none', md: 'flex' },
p: 2,
}}
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flex: '1 1 auto' }}>
<Checkbox
checked={selected.size === threads.length}
indeterminate={selected.size > 0 && selected.size < threads.length}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
selectAll();
} else {
deselectAll();
}
}}
/>
<Typography variant="subtitle2">Select all</Typography>
</Stack>
<Tooltip title="More options">
<IconButton>
<DotsThreeIcon weight="bold" />
</IconButton>
</Tooltip>
</Box>
<Box sx={{ overflowY: 'auto' }}>
{threads.map((thread) => (
<ThreadItem
href={paths.dashboard.mail.details(currentLabelId, thread.id)}
key={thread.id}
onDeselect={() => {
deselectOne(thread.id);
}}
onSelect={() => {
selectOne(thread.id);
}}
selected={selected.has(thread.id)}
thread={thread}
/>
))}
</Box>
</React.Fragment>
) : (
<Box
sx={{
alignItems: 'center',
display: 'flex',
flex: '1 1 auto',
flexDirection: 'column',
justifyContent: 'center',
overflowY: 'auto',
p: 3,
}}
>
<Stack spacing={2} sx={{ alignItems: 'center' }}>
<Box>
<Box
alt="No threads"
component="img"
src="/assets/not-found.svg"
sx={{ height: 'auto', maxWidth: '100%', width: '120px' }}
/>
</Box>
<Typography color="text.secondary" sx={{ textAlign: 'center' }} variant="h6">
There are no threads
</Typography>
</Stack>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,47 @@
export interface Attachment {
id: string;
type: 'file' | 'image';
name?: string;
size?: string;
url?: string;
}
export interface Sender {
name: string;
avatar?: string;
email: string;
}
export interface Receiver {
name: string;
avatar?: string;
email: string;
}
export interface Thread {
id: string;
from: Sender;
to: Receiver[];
subject: string;
message: string;
attachments?: Attachment[];
folder: Folder;
labels: string[];
isImportant: boolean;
isStarred: boolean;
isUnread: boolean;
createdAt: Date;
}
export type Folder = 'inbox' | 'sent' | 'drafts' | 'spam' | 'trash';
export type LabelType = 'system' | 'custom';
export interface Label {
id: string;
type: LabelType;
name: string;
color?: string;
unreadCount?: number;
totalCount?: number;
}