build ok,

This commit is contained in:
louiscklaw
2025-04-14 09:26:24 +08:00
commit 6c931c1fe8
770 changed files with 63959 additions and 0 deletions

View File

@@ -0,0 +1,474 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Checkbox from '@mui/material/Checkbox';
import Chip from '@mui/material/Chip';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import Divider from '@mui/material/Divider';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import LinearProgress from '@mui/material/LinearProgress';
import Link from '@mui/material/Link';
import OutlinedInput from '@mui/material/OutlinedInput';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { Archive as ArchiveIcon } from '@phosphor-icons/react/dist/ssr/Archive';
import { File as FileIcon } from '@phosphor-icons/react/dist/ssr/File';
import { PaperPlaneTilt as PaperPlaneTiltIcon } from '@phosphor-icons/react/dist/ssr/PaperPlaneTilt';
import { PencilSimple as PencilSimpleIcon } from '@phosphor-icons/react/dist/ssr/PencilSimple';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
import { dayjs } from '@/lib/dayjs';
import type { Comment, Task } from './types';
export interface TaskModalProps {
onClose?: () => void;
onTaskDelete?: (taskId: string) => void;
onTaskUpdate?: (taskId: string, params: { title?: string; description?: string }) => void;
onCommentAdd?: (taskId: string, content: string) => void;
open: boolean;
task: Task;
}
export function TaskModal({
onClose,
onTaskDelete,
onTaskUpdate,
onCommentAdd,
open,
task,
}: TaskModalProps): React.JSX.Element {
const {
assignees = [],
attachments = [],
comments = [],
labels = [],
subtasks = [],
description = '',
id,
title,
} = task;
const [tab, setTab] = React.useState<string>('overview');
return (
<Dialog
maxWidth="sm"
onClose={onClose}
open={open}
sx={{
'& .MuiDialog-container': { justifyContent: 'flex-end' },
'& .MuiDialog-paper': { height: '100%', width: '100%' },
}}
>
<DialogContent sx={{ display: 'flex', flexDirection: 'column', minHeight: 0, p: 0 }}>
<Box sx={{ flex: '0 0 auto', p: 3 }}>
<IconButton onClick={onClose}>
<XIcon />
</IconButton>
</Box>
<Divider />
<Tabs
onChange={(_, value: string) => {
setTab(value);
}}
sx={{ px: 3 }}
value={tab}
>
<Tab label="Overview" tabIndex={0} value="overview" />
<Tab label="Subtasks" tabIndex={0} value="subtasks" />
<Tab label="Comments" tabIndex={0} value="comments" />
</Tabs>
<Divider />
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', minHeight: 0, overflowY: 'auto', p: 3 }}>
{tab === 'overview' ? (
<Stack spacing={4} sx={{ flex: '1 1 auto' }}>
<EditableDetails
description={description}
onUpdate={(params: { title: string; description: string }) => {
onTaskUpdate?.(id, params);
}}
title={title}
/>
<Stack divider={<Divider />} spacing={2} sx={{ flex: '1 1 auto' }}>
<Stack spacing={1}>
<Typography variant="subtitle2">Created by</Typography>
<Stack direction="row" spacing={2}>
<Avatar src={task.author.avatar} />
<div>
<Typography variant="subtitle2">{task.author.name}</Typography>
<Typography color="text.secondary" variant="body2">
@{task.author.username}
</Typography>
</div>
</Stack>
</Stack>
<Stack spacing={1}>
<Typography variant="subtitle2">Assignees</Typography>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
{assignees.map(
(assignee): React.JSX.Element => (
<Avatar key={assignee.id} src={assignee.avatar} />
)
)}
<IconButton>
<PlusIcon />
</IconButton>
</Stack>
</Stack>
<Stack spacing={1}>
<Typography variant="subtitle2">Due date</Typography>
<DatePicker format="MMM D, YYYY" name="dueDate" sx={{ maxWidth: '250px' }} />
</Stack>
<Stack spacing={1}>
<Typography variant="subtitle2">Labels</Typography>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
{labels.map((label) => (
<Chip
key={label}
label={label}
onDelete={() => {
// noop
}}
size="small"
variant="soft"
/>
))}
<IconButton>
<PlusIcon />
</IconButton>
</Stack>
</Stack>
<Stack spacing={1}>
<Typography variant="subtitle2">Attachments</Typography>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
{attachments.map(
(attachment): React.JSX.Element => (
<Paper
key={attachment.id}
sx={{ borderRadius: 1, p: '4px 8px', maxWidth: '220px' }}
variant="outlined"
>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<div>
<FileIcon fontSize="var(--icon-fontSize-lg)" />
</div>
<Box sx={{ minWidth: 0 }}>
<Typography noWrap variant="body2">
{attachment.name}
</Typography>
<Typography color="text.secondary" variant="body2">
{attachment.size}
</Typography>
</Box>
</Stack>
</Paper>
)
)}
<IconButton>
<PlusIcon />
</IconButton>
</Stack>
</Stack>
</Stack>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Button
color="error"
onClick={() => {
onTaskDelete?.(id);
}}
startIcon={<ArchiveIcon />}
>
Archive
</Button>
</Box>
</Stack>
) : null}
{tab === 'subtasks' ? (
<Stack spacing={2}>
{subtasks.length ? (
<Stack spacing={2}>
<Stack spacing={1}>
<Typography color="text.secondary" variant="subtitle2">
{countDoneSubtasks(subtasks)} of 5
</Typography>
<LinearProgress
sx={{ bgcolor: 'var(--mui-palette-background-level1)' }}
value={(100 / subtasks.length) * countDoneSubtasks(subtasks)}
variant="determinate"
/>
</Stack>
<Stack gap={1}>
{subtasks.map(
(subtask): React.JSX.Element => (
<FormControlLabel
control={<Checkbox checked={subtask.done} />}
key={subtask.id}
label={subtask.title}
/>
)
)}
</Stack>
</Stack>
) : null}
<div>
<Button color="secondary" startIcon={<PlusIcon />} variant="outlined">
Add subtask
</Button>
</div>
</Stack>
) : null}
{tab === 'comments' ? (
<Stack spacing={5}>
{comments.length ? (
<Stack spacing={3}>
{comments.map(
(comment, index): React.JSX.Element => (
<CommentItem comment={comment} connector={index < comments.length - 1} key={comment.id} />
)
)}
</Stack>
) : (
<Typography color="text.secondary" sx={{ fontStyle: 'italic' }} variant="body2">
No comments yet
</Typography>
)}
<CommentAdd
onAdd={(content: string): void => {
onCommentAdd?.(id, content);
}}
/>
</Stack>
) : null}
</Box>
</DialogContent>
</Dialog>
);
}
interface EditableDetailsProps {
description: string;
onUpdate?: (params: { title: string; description: string }) => void;
title: string;
}
function EditableDetails({
description: initialDescription,
onUpdate,
title: initialTitle,
}: EditableDetailsProps): React.JSX.Element {
const [title, setTitle] = React.useState<string>('');
const [description, setDescription] = React.useState<string>('');
const [edit, setEdit] = React.useState<boolean>(false);
React.useEffect((): void => {
setTitle(initialTitle);
}, [initialTitle]);
React.useEffect((): void => {
setDescription(initialDescription);
}, [initialDescription]);
const handleSave = React.useCallback((): void => {
if (!title) {
return;
}
onUpdate?.({ title, description });
setEdit(false);
}, [title, description, onUpdate]);
if (edit) {
return (
<Stack spacing={2}>
<OutlinedInput
name="title"
onChange={(event: React.ChangeEvent<HTMLInputElement>): void => {
setTitle(event.target.value);
}}
value={title}
/>
<OutlinedInput
maxRows={5}
minRows={3}
multiline
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>): void => {
if (!edit) {
setEdit(true);
}
setDescription(event.target.value);
}}
placeholder="No description"
value={description}
/>
<Stack direction="row" spacing={1} sx={{ justifyContent: 'flex-end' }}>
<Button
color="secondary"
onClick={(): void => {
setTitle(initialTitle);
setEdit(false);
}}
size="small"
>
Cancel
</Button>
<Button
onClick={(): void => {
handleSave();
}}
size="small"
variant="contained"
>
Save
</Button>
</Stack>
</Stack>
);
}
return (
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start' }}>
<Stack spacing={1} sx={{ flex: '1 1 auto' }}>
<Typography variant="h5">{title}</Typography>
<Typography color="text.secondary" variant="body2">
{description}
</Typography>
</Stack>
<IconButton
onClick={(): void => {
setEdit(true);
}}
>
<PencilSimpleIcon />
</IconButton>
</Stack>
);
}
interface CommentItemProps {
connector?: boolean;
comment: Comment;
}
function CommentItem({ comment, connector }: CommentItemProps): React.JSX.Element {
const { author, content, createdAt, comments } = comment;
const canReply = author.id !== 'USR-000'; // authenticated user
return (
<Stack direction="row" spacing={2}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Avatar src={author.avatar} />
{connector ? (
<Box sx={{ flex: '1 1 auto', pt: 3 }}>
<Box
sx={{
bgcolor: 'var(--mui-palette-divider)',
height: '100%',
minHeight: '24px',
mx: 'auto',
width: '1px',
}}
/>
</Box>
) : null}
</Box>
<Stack spacing={3} sx={{ flex: '1 1 auto' }}>
<div>
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', justifyContent: 'space-between' }}>
<Tooltip arrow title={`@${author.username}`}>
<Typography variant="subtitle2">{author.name}</Typography>
</Tooltip>
{createdAt ? (
<Typography sx={{ whiteSpace: 'nowrap' }} variant="caption">
{dayjs(createdAt).fromNow()}
</Typography>
) : null}
</Stack>
<Typography variant="body2">{content}</Typography>
{canReply ? (
<div>
<Link sx={{ cursor: 'pointer' }} variant="body2">
Reply
</Link>
</div>
) : null}
</div>
{comments?.length ? (
<Stack spacing={2}>
{comments.map(
(subComment, index): React.JSX.Element => (
<CommentItem comment={subComment} connector={index < comments.length - 1} key={subComment.id} />
)
)}
</Stack>
) : null}
</Stack>
</Stack>
);
}
interface CommentAddProps {
onAdd?: (content: string) => void;
}
function CommentAdd({ onAdd }: CommentAddProps): React.JSX.Element {
const [content, setContent] = React.useState<string>('');
const handleAdd = React.useCallback((): void => {
if (!content) {
return;
}
onAdd?.(content);
setContent('');
}, [content, onAdd]);
return (
<OutlinedInput
endAdornment={
<InputAdornment position="end">
<IconButton
onClick={(): void => {
handleAdd();
}}
>
<PaperPlaneTiltIcon />
</IconButton>
</InputAdornment>
}
onChange={(event: React.ChangeEvent<HTMLInputElement>): void => {
setContent(event.target.value);
}}
onKeyUp={(event: React.KeyboardEvent): void => {
if (event.key === 'Enter') {
handleAdd();
}
}}
placeholder="Add a comment..."
startAdornment={
<InputAdornment position="start">
<Avatar src="/assets/avatar.png" />
</InputAdornment>
}
sx={{ '--Input-paddingBlock': '12px' }}
value={content}
/>
);
}
function countDoneSubtasks(subtasks: Task['subtasks'] = []): number {
return subtasks.reduce((acc, curr) => acc + (curr.done ? 1 : 0), 0);
}