update,
This commit is contained in:
@@ -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[];
|
@@ -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;
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
172
002_source/cms/src/components/dashboard/teacher/mail/sidebar.tsx
Normal file
172
002_source/cms/src/components/dashboard/teacher/mail/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
47
002_source/cms/src/components/dashboard/teacher/mail/types.d.ts
vendored
Normal file
47
002_source/cms/src/components/dashboard/teacher/mail/types.d.ts
vendored
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user