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,225 @@
'use client';
import * as React from 'react';
import {
closestCenter,
defaultDropAnimation,
DndContext,
DragOverlay,
getFirstCollision,
KeyboardSensor,
MouseSensor,
pointerWithin,
rectIntersection,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import type {
CollisionDetection,
DragEndEvent,
DragOverEvent,
DragStartEvent,
DropAnimation,
UniqueIdentifier,
} from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import { logger } from '@/lib/default-logger';
import { ColumnItem } from './column-item';
import { ColumnList } from './column-list';
import { TaskCard } from './task-card';
import { TasksContext } from './tasks-context';
import type { Column, DnDData, Task } from './types';
const dropAnimation: DropAnimation = { ...defaultDropAnimation };
export function BoardView(): React.JSX.Element | null {
const {
columns,
tasks,
setCurrentColumnId,
setCurrentTaskId,
createColumn,
clearColumn,
deleteColumn,
createTask,
dragTask,
} = React.useContext(TasksContext);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 10 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 10 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [active, setActive] = React.useState<{ id: string; type: 'column' | 'task' } | null>(null);
const collisionDetection = useCollisionDetection(columns, active);
const activeTask = React.useMemo((): Task | undefined => {
return active?.type === 'task' ? tasks.get(active.id) : undefined;
}, [tasks, active]);
const handleDragStart = React.useCallback((event: DragStartEvent): void => {
if (!canDrag(event)) {
return;
}
setActive({ id: event.active.id as string, type: event.active.data.current!.type as 'column' | 'task' });
}, []);
const handleDragOver = React.useCallback((_: DragOverEvent): void => {
// console.log('handleDragOver', event);
}, []);
const handleDragEnd = React.useCallback(
(event: DragEndEvent): void => {
if (!canDrop(event)) {
return;
}
dragTask(
{ id: event.active.id as string, type: event.active.data.current!.type as 'task' },
{ id: event.over!.id as string, type: event.over!.data.current!.type as 'column' | 'task' }
);
},
[dragTask]
);
return (
<DndContext
collisionDetection={collisionDetection}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
sensors={sensors}
>
<ColumnList>
{Array.from(columns.values()).map(({ taskIds, ...column }): React.JSX.Element => {
const tasksFiltered = taskIds
.map((taskId) => tasks.get(taskId))
.filter((task) => typeof task !== 'undefined') as Task[];
return (
<ColumnItem
column={column}
key={column.id}
onColumnClear={clearColumn}
onColumnDelete={deleteColumn}
onColumnEdit={setCurrentColumnId}
onTaskCreate={createTask}
onTaskOpen={setCurrentTaskId}
tasks={tasksFiltered}
/>
);
})}
<Box sx={{ flex: '0 0 auto' }}>
<Button color="secondary" onClick={createColumn} startIcon={<PlusIcon />}>
Add column
</Button>
</Box>
</ColumnList>
<DragOverlay dropAnimation={dropAnimation}>
{activeTask ? (
<div style={{ cursor: 'grab' }}>
<TaskCard task={activeTask} />
</div>
) : null}
</DragOverlay>
</DndContext>
);
}
function useCollisionDetection(
columns: Map<string, Column>,
active: { id: string; type: 'column' | 'task' } | null
): CollisionDetection {
const lastOverId = React.useRef<string | null>(null);
return React.useCallback(
(args) => {
/**
* Custom collision detection strategy optimized for multiple containers
*
* - First, find any droppable containers intersecting with the pointer.
* - If there are none, find intersecting containers with the active draggable.
* - If there are no intersecting containers, return the last matched intersection
*/
if (active?.type === 'column' && columns.has(active.id)) {
return closestCenter({
...args,
droppableContainers: args.droppableContainers.filter((container) => columns.has(container.id as string)),
});
}
// Start by finding any intersecting droppable
const pointerIntersections = pointerWithin(args);
const intersections =
pointerIntersections.length > 0
? // If there are droppables intersecting with the pointer, return those
pointerIntersections
: rectIntersection(args);
let overId = getFirstCollision(intersections, 'id') as string | null;
if (overId !== null) {
if (columns.has(overId)) {
const columnTasks = columns.get(overId)?.taskIds ?? [];
if (columnTasks.length > 0) {
// Return the closest droppable within that container
overId = closestCenter({
...args,
droppableContainers: args.droppableContainers.filter(
(container) => container.id !== overId && columnTasks.includes(container.id as string)
),
})[0]?.id as string | null;
}
}
lastOverId.current = overId;
return [{ id: overId as UniqueIdentifier }];
}
// If no droppable is matched, return the last match
return lastOverId.current ? [{ id: lastOverId.current as UniqueIdentifier }] : [];
},
[active, columns]
);
}
function canDrag({ active }: DragStartEvent): boolean {
if (active.data.current?.type !== 'task') {
logger.warn('[DnD] onDragStart missing or invalid active type. Must be "task"');
return false;
}
return true;
}
function canDrop({ active, over }: DragOverEvent): boolean {
if (!over) {
// Since all draggable tasks are inside droppable columns,
// in theory there should always be an "over".
return false;
}
if (!active.data.current || !['task'].includes((active.data.current as DnDData).type)) {
// You might want to be able to drag columns.
// We do did not implement this functionality.
logger.warn('onDragEnd missing or invalid active type. Must be "task"');
return false;
}
if (!over.data.current || !['column', 'task'].includes((over.data.current as DnDData).type)) {
logger.warn('onDragEnd missing or invalid over type, Must be "column" or "task"');
return false;
}
return true;
}

View File

@@ -0,0 +1,50 @@
'use client';
import * as React from 'react';
import { useDroppable } from '@dnd-kit/core';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import { TaskCard } from './task-card';
import { TaskDraggable } from './task-draggable';
import type { DnDData, Task } from './types';
export interface ColumnDroppableProps {
id: string;
onTaskCreate?: () => void;
onTaskOpen?: (taskId: string) => void;
tasks: Task[];
}
export function ColumnDroppable({ id, onTaskCreate, onTaskOpen, tasks }: ColumnDroppableProps): React.JSX.Element {
const { over, setNodeRef } = useDroppable({ id, data: { type: 'column' } satisfies DnDData });
const isOver = over ? over.id === id || tasks.find((task) => task.id === over.id) : false;
return (
<Stack
ref={setNodeRef}
spacing={3}
sx={{
bgcolor: 'var(--mui-palette-background-level1)',
borderRadius: '20px',
flex: '0 0 auto',
minHeight: '250px',
p: 3,
...(isOver && { bgcolor: 'var(--mui-palette-background-level2)' }),
}}
>
<Button color="secondary" onClick={onTaskCreate} startIcon={<PlusIcon />}>
Add task
</Button>
{tasks.map(
(task): React.JSX.Element => (
<TaskDraggable id={task.id} key={task.id}>
<TaskCard onOpen={onTaskOpen} task={task} />
</TaskDraggable>
)
)}
</Stack>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import * as React from 'react';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { DotsThree as DotsThreeIcon } from '@phosphor-icons/react/dist/ssr/DotsThree';
import { usePopover } from '@/hooks/use-popover';
import { ColumnDroppable } from './column-droppable';
import type { Column, Task } from './types';
export interface ColumnItemProps {
column: Omit<Column, 'taskIds'>;
onColumnEdit?: (columnId: string) => void;
onColumnClear?: (columnId: string) => void;
onColumnDelete?: (columnId: string) => void;
onTaskOpen?: (taskId: string) => void;
onTaskCreate?: (columnId: string) => void;
tasks: Task[];
}
export function ColumnItem({
column,
onColumnEdit,
onColumnClear,
onColumnDelete,
onTaskOpen,
onTaskCreate,
tasks = [],
}: ColumnItemProps): React.JSX.Element {
const { id, name } = column;
const morePopover = usePopover<HTMLButtonElement>();
return (
<React.Fragment>
<Stack spacing={3} sx={{ flex: '0 0 auto', width: '360px' }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" spacing={1} sx={{ alignItems: 'center' }}>
<Typography variant="h6">{name}</Typography>
<Chip label={tasks.length} size="small" variant="soft" />
</Stack>
<IconButton onClick={morePopover.handleOpen} ref={morePopover.anchorRef}>
<DotsThreeIcon weight="bold" />
</IconButton>
</Stack>
<ColumnDroppable
id={id}
onTaskCreate={() => {
onTaskCreate?.(id);
}}
onTaskOpen={onTaskOpen}
tasks={tasks}
/>
</Stack>
<Menu
anchorEl={morePopover.anchorRef.current}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
onClose={morePopover.handleClose}
open={morePopover.open}
slotProps={{ paper: { sx: { width: '200px' } } }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuItem
onClick={(): void => {
morePopover.handleClose();
onColumnEdit?.(id);
}}
>
Edit
</MenuItem>
<MenuItem
onClick={(): void => {
morePopover.handleClose();
onColumnClear?.(id);
}}
>
Clear
</MenuItem>
<MenuItem
onClick={(): void => {
morePopover.handleClose();
onColumnDelete?.(id);
}}
>
Delete
</MenuItem>
</Menu>
</React.Fragment>
);
}

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
import Box from '@mui/material/Box';
export interface ColumnListProps {
children: React.ReactNode;
}
export function ColumnList({ children }: ColumnListProps): React.JSX.Element {
return (
<Box
sx={{
display: 'flex',
flex: '1 1 auto',
gap: 4,
overflowX: 'auto',
px: 'var(--Content-paddingX)',
mx: 'calc(-1 * var(--Content-paddingX))',
}}
>
{children}
</Box>
);
}

View File

@@ -0,0 +1,66 @@
import * as React from 'react';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import OutlinedInput from '@mui/material/OutlinedInput';
import Stack from '@mui/material/Stack';
import type { Column } from './types';
export interface ColumnModalProps {
column: Column;
onClose?: () => void;
onColumnUpdate?: (columnId: string, params: { name?: string }) => void;
open: boolean;
}
export function ColumnModal({ column, onClose, onColumnUpdate, open }: ColumnModalProps): React.JSX.Element {
const { id, name: initialName } = column;
const [name, setName] = React.useState<string>('');
React.useEffect((): void => {
setName(initialName);
}, [initialName]);
const handleSave = React.useCallback((): void => {
if (!name) {
return;
}
if (name === initialName) {
return;
}
onColumnUpdate?.(id, { name });
onClose?.();
}, [name, initialName, id, onClose, onColumnUpdate]);
return (
<Dialog fullWidth maxWidth="sm" onClose={onClose} open={open}>
<DialogContent>
<Stack spacing={3}>
<FormControl>
<InputLabel>Name</InputLabel>
<OutlinedInput
name="name"
onChange={(event: React.ChangeEvent<HTMLInputElement>): void => {
setName(event.target.value);
}}
value={name}
/>
</FormControl>
<Stack direction="row" spacing={2} sx={{ justifyContent: 'flex-end' }}>
<Button color="secondary" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} variant="contained">
Save
</Button>
</Stack>
</Stack>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import AvatarGroup from '@mui/material/AvatarGroup';
import Card from '@mui/material/Card';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Chat as ChatIcon } from '@phosphor-icons/react/dist/ssr/Chat';
import { Link as LinkIcon } from '@phosphor-icons/react/dist/ssr/Link';
import { List as ListIcon } from '@phosphor-icons/react/dist/ssr/List';
import { dayjs } from '@/lib/dayjs';
import type { Task } from './types';
export interface TaskCardProps {
onOpen?: (taskId: string) => void;
task: Task;
}
export function TaskCard({ onOpen, task }: TaskCardProps): React.JSX.Element {
const { assignees = [], attachments = [], comments = [], description, dueDate, id, subtasks = [], title } = task;
return (
<Card>
<Stack spacing={2} sx={{ p: 3 }}>
{dueDate ? (
<div>
<Typography color="text.secondary" variant="body2">
Due {dayjs(dueDate).diff(dayjs(), 'day')} days
</Typography>
</div>
) : null}
<Stack spacing={0.5}>
<Typography
onClick={(): void => {
onOpen?.(id);
}}
sx={{ cursor: 'pointer', ':hover': { color: 'var(--mui-palette-primary-main)' } }}
variant="subtitle1"
>
{title}
</Typography>
<Typography variant="body2">{description}</Typography>
</Stack>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between' }}>
<div>
{assignees.length ? (
<AvatarGroup sx={{ flex: '1 1 auto' }}>
{assignees.map(
(assignee): React.JSX.Element => (
<Avatar key={assignee.id} src={assignee.avatar} />
)
)}
</AvatarGroup>
) : null}
</div>
<Stack direction="row" spacing={1}>
{attachments.length ? <LinkIcon fontSize="var(--icon-fontSize-md)" /> : null}
{comments.length ? <ChatIcon fontSize="var(--icon-fontSize-md)" /> : null}
{subtasks.length ? <ListIcon fontSize="var(--icon-fontSize-md)" /> : null}
</Stack>
</Stack>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,25 @@
import * as React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { DnDData } from './types';
export interface TaskDraggableProps {
children: React.ReactNode;
id: string;
}
export function TaskDraggable({ children, id }: TaskDraggableProps): React.JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
data: { type: 'task' } satisfies DnDData,
});
const style = { transform: CSS.Transform.toString(transform), transition, ...(isDragging && { opacity: 0 }) };
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
{children}
</div>
);
}

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);
}

View File

@@ -0,0 +1,473 @@
'use client';
import * as React from 'react';
import type { Column, Comment, Task } from './types';
function noop(): void {
return undefined;
}
export interface TasksContextValue {
columns: Map<string, Column>;
tasks: Map<string, Task>;
currentColumnId?: string;
currentTaskId?: string;
setCurrentColumnId: (columnId?: string) => void;
setCurrentTaskId: (taskId?: string) => void;
createColumn: () => void;
updateColumn: (taskId: string, params: { name?: string }) => void;
clearColumn: (columnId: string) => void;
deleteColumn: (columnId: string) => void;
dragTask: (active: { id: string; type: 'task' }, over: { id: string; type: 'column' | 'task' }) => void;
createTask: (columnId: string) => void;
deleteTask: (taskId: string) => void;
updateTask: (taskId: string, params: { title?: string; description?: string }) => void;
addComment: (taskId: string, content: string) => void;
}
export const TasksContext = React.createContext<TasksContextValue>({
columns: new Map(),
tasks: new Map(),
setCurrentColumnId: noop,
setCurrentTaskId: noop,
createColumn: noop,
updateColumn: noop,
clearColumn: noop,
deleteColumn: noop,
dragTask: noop,
createTask: noop,
deleteTask: noop,
updateTask: noop,
addComment: noop,
});
export interface TasksProviderProps {
children: React.ReactNode;
columns: Column[];
tasks: Task[];
}
export function TasksProvider({
children,
columns: initialColumns = [],
tasks: initialTasks = [],
}: TasksProviderProps): React.JSX.Element {
const [columns, setColumns] = React.useState(new Map<string, Column>());
const [tasks, setTasks] = React.useState(new Map<string, Task>());
const [currentColumnId, setCurrentColumnId] = React.useState<string>();
const [currentTaskId, setCurrentTaskId] = React.useState<string>();
React.useEffect((): void => {
setColumns(new Map(initialColumns.map((column) => [column.id, column])));
}, [initialColumns]);
React.useEffect((): void => {
setTasks(new Map(initialTasks.map((task) => [task.id, task])));
}, [initialTasks]);
const handleCreateColumn = React.useCallback((): void => {
const column = { id: `COL-${Date.now()}`, name: 'Untitled', taskIds: [] } satisfies Column;
const updatedColumns = new Map<string, Column>(columns);
// Add column
updatedColumns.set(column.id, column);
// Dispatch update
setColumns(updatedColumns);
}, [columns]);
const handleUpdateColumn = React.useCallback(
(columnId: string, { name }: { name?: string }): void => {
const column = columns.get(columnId);
// Column might no longer exist
if (!column) {
return;
}
const updatedColumns = new Map<string, Column>(columns);
const updatedColumn = { ...column };
if (typeof name !== 'undefined') {
updatedColumn.name = name;
}
// Update column
updatedColumns.set(updatedColumn.id, updatedColumn);
// Dispatch update
setColumns(updatedColumns);
},
[columns]
);
const handleClearColumn = React.useCallback(
(columnId: string): void => {
const column = columns.get(columnId);
// Column might no longer exist
if (!column) {
return;
}
const updatedTasks = new Map<string, Task>(tasks);
// Delete tasks
column.taskIds.forEach((taskId): void => {
updatedTasks.delete(taskId);
});
const updatedColumns = new Map<string, Column>(columns);
const updatedColumn = { ...column, taskIds: [] };
// Update column
updatedColumns.set(updatedColumn.id, updatedColumn);
// Dispatch update
setColumns(updatedColumns);
setTasks(updatedTasks);
},
[columns, tasks]
);
const handleDeleteColumn = React.useCallback(
(columnId: string): void => {
const column = columns.get(columnId);
// Column might no longer exist
if (!column) {
return;
}
const updatedTasks = new Map<string, Task>(tasks);
// Delete tasks
column.taskIds.forEach((taskId): void => {
updatedTasks.delete(taskId);
});
const updatedColumns = new Map<string, Column>(columns);
// Delete column
updatedColumns.delete(column.id);
// Dispatch update
setColumns(updatedColumns);
setTasks(updatedTasks);
},
[columns, tasks]
);
const handleDragTask = React.useCallback(
(active: { id: string; type: 'task' }, over: { id: string; type: 'column' | 'task' }): void => {
const activeTask = tasks.get(active.id);
// Active task and might no longer exist
if (!activeTask) {
return;
}
const activeColumn = columns.get(activeTask.columnId);
// Active column might no longer exist
if (!activeColumn) {
return;
}
// Dropped over a column
if (over.type === 'column') {
// Dropped on the same column, reorder at the end
if (activeTask.columnId === over.id) {
const updatedActiveColumn = {
...activeColumn,
taskIds: [...activeColumn.taskIds.filter((taskId) => taskId !== activeTask.id), activeTask.id],
} satisfies Column;
const updatedColumns = new Map<string, Column>(columns);
updatedColumns.set(updatedActiveColumn.id, updatedActiveColumn);
// Dispatch update
setColumns(updatedColumns);
}
// Dropped in a different column, move at the end
else {
const overColumn = columns.get(over.id);
// Over column might no longer exist
if (!overColumn) {
return;
}
// Change task column
const updatedActiveTask = { ...activeTask, columnId: overColumn.id } satisfies Task;
const updatedTasks = new Map<string, Task>(tasks);
updatedTasks.set(updatedActiveTask.id, updatedActiveTask);
// Remove task from active column
const updatedActiveColumn = {
...activeColumn,
taskIds: activeColumn.taskIds.filter((taskId) => taskId !== activeTask.id),
} satisfies Column;
// Add task to over column
const updatedOverColumn = { ...overColumn, taskIds: [...overColumn.taskIds, activeTask.id] } satisfies Column;
const updatedColumns = new Map<string, Column>(columns);
updatedColumns.set(updatedActiveColumn.id, updatedActiveColumn);
updatedColumns.set(updatedOverColumn.id, updatedOverColumn);
// Dispatch update
setTasks(updatedTasks);
setColumns(updatedColumns);
}
}
// Dropped over a task
else {
// Dropped over self
if (activeTask.id === over.id) {
return;
}
const overTask = tasks.get(over.id);
// Over task might no longer exist
if (!overTask) {
return;
}
// Dropped on the same column, reorder
if (activeTask.columnId === overTask.columnId) {
const oldTaskIndex = activeColumn.taskIds.findIndex((taskId) => taskId === activeTask.id);
const newTaskIndex = activeColumn.taskIds.findIndex((taskId) => taskId === overTask.id);
const updatedActiveColumn = {
...activeColumn,
taskIds: arrayMove(activeColumn.taskIds, oldTaskIndex, newTaskIndex),
} satisfies Column;
const updatedColumns = new Map<string, Column>(columns);
updatedColumns.set(updatedActiveColumn.id, updatedActiveColumn);
// Dispatch update
setColumns(updatedColumns);
}
// Dopped on a different column, move at position
else {
const overColumn = columns.get(overTask.columnId);
// Column might no longer exist
if (!overColumn) {
return;
}
// Change task column
const updatedActiveTask = { ...activeTask, columnId: overColumn.id } satisfies Task;
const updatedTasks = new Map<string, Task>(tasks);
updatedTasks.set(updatedActiveTask.id, updatedActiveTask);
// Find new task position
const overTaskIndex = overColumn.taskIds.findIndex((taskId) => taskId === overTask.id);
// Remove task from active column
const updatedActiveColumn = {
...activeColumn,
taskIds: activeColumn.taskIds.filter((taskId) => taskId !== activeTask.id),
} satisfies Column;
// Add task to over column at position
const updatedOverColumn = {
...overColumn,
taskIds: arrayInsert(overColumn.taskIds, overTaskIndex, activeTask.id),
} satisfies Column;
const updatedColumns = new Map<string, Column>(columns);
updatedColumns.set(updatedActiveColumn.id, updatedActiveColumn);
updatedColumns.set(updatedOverColumn.id, updatedOverColumn);
// Dispatch update
setTasks(updatedTasks);
setColumns(updatedColumns);
}
}
},
[columns, tasks]
);
const handleCreateTask = React.useCallback(
(columnId: string): void => {
const column = columns.get(columnId);
// Column might no longer exist
if (!column) {
return;
}
// Create the new task
const task = {
id: `TSK-${Date.now()}`,
author: { id: 'USR-000', name: 'Sofia Rivers', username: 'sofia.rivers', avatar: '/assets/avatar.png' },
title: 'Untitled',
columnId,
createdAt: new Date(),
} satisfies Task;
const updatedTasks = new Map<string, Task>(tasks);
// Add it to the tasks
updatedTasks.set(task.id, task);
// Add the task to the column
const updatedColumn = { ...column, taskIds: [task.id, ...column.taskIds] } satisfies Column;
const updatedColumns = new Map<string, Column>(columns);
updatedColumns.set(updatedColumn.id, updatedColumn);
// Dispatch update
setTasks(updatedTasks);
setColumns(updatedColumns);
},
[columns, tasks]
);
const handleDeleteTask = React.useCallback(
(taskId: string): void => {
const task = tasks.get(taskId);
// Task might no longer exist
if (!task) {
return;
}
const updatedTasks = new Map<string, Task>(tasks);
// Delete the task
updatedTasks.delete(task.id);
const updatedColumns = new Map<string, Column>(columns);
// Delete the task ID from column
const column = updatedColumns.get(task.columnId);
// Column might no longer exist
if (column) {
const updatedColumn = { ...column, taskId: column.taskIds.filter((id) => id !== task.id) };
updatedColumns.set(updatedColumn.id, updatedColumn);
}
// Dispatch update
setColumns(updatedColumns);
setTasks(updatedTasks);
},
[columns, tasks]
);
const handleUpdateTask = React.useCallback(
(taskId: string, { title, description }: { title?: string; description?: string }): void => {
const task = tasks.get(taskId);
// Task might no longer exist
if (!task) {
return;
}
const updatedTasks = new Map<string, Task>(tasks);
const updatedTask = { ...task };
// Title changed
if (typeof title !== 'undefined') {
updatedTask.title = title;
}
// Description changed
if (typeof description !== 'undefined') {
updatedTask.description = description;
}
updatedTasks.set(updatedTask.id, updatedTask);
// Dispatch update
setTasks(updatedTasks);
},
[tasks]
);
const handleAddComment = React.useCallback(
(taskId: string, content: string): void => {
const task = tasks.get(taskId);
// Task might no longer exist
if (!task) {
return;
}
// Copy existing tasks
const updatedTasks = new Map<string, Task>(tasks);
// Create the comment and add it to the task
const comment = {
id: `MSG-${Date.now()}`,
author: { id: 'USR-000', name: 'Sofia Rivers', username: 'sofia.rivers', avatar: '/assets/avatar.png' },
content,
createdAt: new Date(),
} satisfies Comment;
updatedTasks.set(task.id, { ...task, comments: [...(task.comments ?? []), comment] });
// Dispatch update
setTasks(updatedTasks);
},
[tasks]
);
return (
<TasksContext.Provider
value={{
columns,
tasks,
currentColumnId,
currentTaskId,
setCurrentColumnId,
setCurrentTaskId,
createColumn: handleCreateColumn,
updateColumn: handleUpdateColumn,
clearColumn: handleClearColumn,
deleteColumn: handleDeleteColumn,
dragTask: handleDragTask,
createTask: handleCreateTask,
deleteTask: handleDeleteTask,
updateTask: handleUpdateTask,
addComment: handleAddComment,
}}
>
{children}
</TasksContext.Provider>
);
}
export const TasksConsumer = TasksContext.Consumer;
function arrayMove<T = unknown>(arr: T[], from: number, to: number): T[] {
const copy = [...arr];
const [item] = copy.splice(from, 1);
copy.splice(to, 0, item);
return copy;
}
function arrayInsert<T = unknown>(arr: T[], index: number, item: T): T[] {
return [...arr.slice(0, index), item, ...arr.slice(index)];
}

View File

@@ -0,0 +1,93 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { Plus as PlusIcon } from '@phosphor-icons/react/dist/ssr/Plus';
import { BoardView } from './board-view';
import { ColumnModal } from './column-modal';
import { TaskModal } from './task-modal';
import { TasksContext } from './tasks-context';
// You may want to also have the List instead of Board.
// We do not handle the list at this moment.
export interface TasksViewProps {
mode?: 'board' | 'list';
}
export function TasksView(_: TasksViewProps): React.JSX.Element {
const {
columns,
tasks,
currentColumnId,
currentTaskId,
setCurrentColumnId,
setCurrentTaskId,
updateColumn,
updateTask,
deleteTask,
addComment,
} = React.useContext(TasksContext);
const currentColumn = currentColumnId ? columns.get(currentColumnId) : undefined;
const currentTask = currentTaskId ? tasks.get(currentTaskId) : undefined;
return (
<React.Fragment>
<Box
sx={{
display: 'flex',
flex: '1 1 auto',
flexDirection: 'column',
px: 'var(--Content-paddingX)',
py: 'var(--Content-paddingY)',
}}
>
<Stack spacing={4}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={3} sx={{ alignItems: 'flex-start' }}>
<Box sx={{ flex: '1 1 auto' }}>
<Typography variant="h4">Tasks</Typography>
</Box>
<div>
<Button startIcon={<PlusIcon />} variant="contained">
Member
</Button>
</div>
</Stack>
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column' }}>
<BoardView />
</Box>
</Stack>
</Box>
{currentColumn ? (
<ColumnModal
column={currentColumn}
onClose={(): void => {
setCurrentColumnId(undefined);
}}
onColumnUpdate={updateColumn}
open
/>
) : null}
{currentTask ? (
<TaskModal
onClose={(): void => {
setCurrentTaskId(undefined);
}}
onCommentAdd={addComment}
onTaskDelete={(taskId: string): void => {
setCurrentColumnId(undefined);
deleteTask(taskId);
}}
onTaskUpdate={updateTask}
open
task={currentTask}
/>
) : null}
</React.Fragment>
);
}

View File

@@ -0,0 +1,54 @@
export interface DnDData {
type: 'column' | 'task';
}
export interface Column {
id: string;
name: string;
taskIds: string[];
}
export interface Task {
id: string;
author: { id: string; name: string; username: string; avatar?: string };
title: string;
description?: string;
columnId: string;
createdAt: Date;
labels?: string[];
dueDate?: Date;
subscribed?: boolean;
assignees?: Assignee[];
attachments?: Attachment[];
subtasks?: Subtask[];
comments?: Comment[];
}
export interface Assignee {
id: string;
name: string;
username: string;
avatar?: string;
}
export interface Attachment {
id: string;
name: string;
extension: 'png' | 'pdf';
url: string;
size: string;
}
export interface Subtask {
id: string;
title: string;
done?: boolean;
}
export interface Comment {
id: string;
author: { id: string; name: string; username: string; avatar?: string };
content: string;
createdAt: Date;
comments?: Comment[];
}