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 type { Metadata } from 'next';
|
||||||
|
|
||||||
import { config } from '@/config';
|
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;
|
export const metadata = { title: `Thread | Mail | Dashboard | ${config.site.name}` } satisfies Metadata;
|
||||||
|
|
||||||
|
@@ -1,9 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { dayjs } from '@/lib/dayjs';
|
import { MailProvider } from '@/components/dashboard/teacher/mail/mail-context';
|
||||||
import { MailProvider } from '@/components/dashboard/mail/mail-context';
|
import { MailView } from '@/components/dashboard/teacher/mail/mail-view';
|
||||||
import { MailView } from '@/components/dashboard/mail/mail-view';
|
import type { Label, Thread } from '@/components/dashboard/teacher/mail/types';
|
||||||
import type { Label, Thread } from '@/components/dashboard/mail/types';
|
import { SampleThreads } from './SampleThreads';
|
||||||
|
|
||||||
function filterThreads(threads: Thread[], labelId: string): Thread[] {
|
function filterThreads(threads: Thread[], labelId: string): Thread[] {
|
||||||
return threads.filter((thread) => {
|
return threads.filter((thread) => {
|
||||||
@@ -40,90 +40,6 @@ const labels = [
|
|||||||
{ id: 'personal', type: 'custom', name: 'Personal', color: '#FB8A00', unreadCount: 0, totalCount: 1 },
|
{ id: 'personal', type: 'custom', name: 'Personal', color: '#FB8A00', unreadCount: 0, totalCount: 1 },
|
||||||
] satisfies Label[];
|
] 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 {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: { labelId: string };
|
params: { labelId: string };
|
||||||
@@ -132,7 +48,7 @@ interface LayoutProps {
|
|||||||
export default function Layout({ children, params }: LayoutProps): React.JSX.Element {
|
export default function Layout({ children, params }: LayoutProps): React.JSX.Element {
|
||||||
const { labelId } = params;
|
const { labelId } = params;
|
||||||
|
|
||||||
const filteredThreads = filterThreads(threads, labelId);
|
const filteredThreads = filterThreads(SampleThreads, labelId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MailProvider
|
<MailProvider
|
||||||
|
@@ -2,7 +2,7 @@ import * as React from 'react';
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
import { config } from '@/config';
|
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;
|
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 type { Metadata } from 'next';
|
||||||
|
|
||||||
import { config } from '@/config';
|
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;
|
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