Skip to content

Commit 13325bf

Browse files
committed
feat: support file attachments during entity creation (tasks, notes, skills)
1 parent 4b7e1d7 commit 13325bf

5 files changed

Lines changed: 140 additions & 6 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { useRef, useState } from 'react';
2+
import {
3+
Box, Typography, IconButton, List, ListItem, ListItemText, ListItemSecondaryAction,
4+
useTheme,
5+
} from '@mui/material';
6+
import UploadFileIcon from '@mui/icons-material/UploadFile';
7+
import DeleteIcon from '@mui/icons-material/Delete';
8+
9+
interface StagedAttachmentsProps {
10+
files: File[];
11+
onAdd: (files: File[]) => void;
12+
onRemove: (index: number) => void;
13+
}
14+
15+
function formatSize(bytes: number): string {
16+
if (bytes < 1024) return `${bytes} B`;
17+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
18+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
19+
}
20+
21+
export function StagedAttachments({ files, onAdd, onRemove }: StagedAttachmentsProps) {
22+
const { palette } = useTheme();
23+
const inputRef = useRef<HTMLInputElement>(null);
24+
const [dragOver, setDragOver] = useState(false);
25+
26+
const handleDrop = (e: React.DragEvent) => {
27+
e.preventDefault();
28+
setDragOver(false);
29+
if (e.dataTransfer.files.length > 0) {
30+
onAdd(Array.from(e.dataTransfer.files));
31+
}
32+
};
33+
34+
const handleFiles = (fileList: FileList | null) => {
35+
if (fileList && fileList.length > 0) {
36+
onAdd(Array.from(fileList));
37+
}
38+
};
39+
40+
return (
41+
<Box>
42+
{files.length > 0 && (
43+
<List dense disablePadding sx={{ mb: 1 }}>
44+
{files.map((file, i) => (
45+
<ListItem key={i} disableGutters sx={{ py: 0.25 }}>
46+
<ListItemText
47+
primary={file.name}
48+
secondary={formatSize(file.size)}
49+
primaryTypographyProps={{ variant: 'body2', noWrap: true }}
50+
secondaryTypographyProps={{ variant: 'caption' }}
51+
/>
52+
<ListItemSecondaryAction>
53+
<IconButton size="small" onClick={() => onRemove(i)}>
54+
<DeleteIcon fontSize="small" />
55+
</IconButton>
56+
</ListItemSecondaryAction>
57+
</ListItem>
58+
))}
59+
</List>
60+
)}
61+
62+
<Box
63+
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
64+
onDragLeave={() => setDragOver(false)}
65+
onDrop={handleDrop}
66+
onClick={() => inputRef.current?.click()}
67+
sx={{
68+
border: `2px dashed ${dragOver ? palette.primary.main : palette.divider}`,
69+
borderRadius: 1,
70+
p: 2,
71+
display: 'flex',
72+
alignItems: 'center',
73+
justifyContent: 'center',
74+
gap: 1,
75+
bgcolor: dragOver ? (palette.mode === 'dark' ? 'rgba(144,202,249,0.08)' : 'rgba(25,118,210,0.04)') : 'transparent',
76+
transition: 'all 0.2s',
77+
cursor: 'pointer',
78+
}}
79+
>
80+
<UploadFileIcon color="action" fontSize="small" />
81+
<Typography variant="body2" color="text.secondary">
82+
Drop files here or click to add
83+
</Typography>
84+
<input
85+
ref={inputRef}
86+
type="file"
87+
multiple
88+
hidden
89+
onChange={e => { handleFiles(e.target.files); e.target.value = ''; }}
90+
/>
91+
</Box>
92+
</Box>
93+
);
94+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { AttachmentSection, type AttachmentMeta } from './AttachmentSection.tsx';
2+
export { StagedAttachments } from './StagedAttachments.tsx';

ui/src/pages/knowledge/new.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
1+
import { useState } from 'react';
12
import { useParams, useNavigate } from 'react-router-dom';
23
import { Box, Button, Alert } from '@mui/material';
3-
import { createNote } from '@/entities/note/index.ts';
4+
import { createNote, uploadNoteAttachment } from '@/entities/note/index.ts';
45
import { NoteForm } from '@/features/note-crud/NoteForm.tsx';
6+
import { StagedAttachments } from '@/features/attachments/index.ts';
57
import { useCanWrite } from '@/shared/lib/AccessContext.tsx';
6-
import { PageTopBar } from '@/shared/ui/index.ts';
8+
import { PageTopBar, Section } from '@/shared/ui/index.ts';
79

810
export default function NoteNewPage() {
911
const { projectId } = useParams<{ projectId: string }>();
1012
const navigate = useNavigate();
1113
const canWrite = useCanWrite('knowledge');
14+
const [stagedFiles, setStagedFiles] = useState<File[]>([]);
1215

1316
const handleSubmit = async (data: { title: string; content: string; tags: string[] }) => {
1417
if (!projectId) return;
1518
const note = await createNote(projectId, data);
19+
for (const file of stagedFiles) {
20+
await uploadNoteAttachment(projectId, note.id, file).catch(() => {});
21+
}
1622
navigate(`/${projectId}/knowledge/${note.id}`);
1723
};
1824

@@ -35,6 +41,13 @@ export default function NoteNewPage() {
3541
onCancel={() => navigate(`/${projectId}/knowledge`)}
3642
submitLabel="Create"
3743
/>
44+
<Section title="Attachments" sx={{ mt: 3 }}>
45+
<StagedAttachments
46+
files={stagedFiles}
47+
onAdd={files => setStagedFiles(prev => [...prev, ...files])}
48+
onRemove={index => setStagedFiles(prev => prev.filter((_, i) => i !== index))}
49+
/>
50+
</Section>
3851
</Box>
3952
);
4053
}

ui/src/pages/skills/new.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1+
import { useState } from 'react';
12
import { useParams, useNavigate } from 'react-router-dom';
23
import { Box, Button, Alert } from '@mui/material';
3-
import { createSkill } from '@/entities/skill/index.ts';
4+
import { createSkill, uploadSkillAttachment } from '@/entities/skill/index.ts';
45
import { SkillForm } from '@/features/skill-crud/SkillForm.tsx';
6+
import { StagedAttachments } from '@/features/attachments/index.ts';
57
import { useCanWrite } from '@/shared/lib/AccessContext.tsx';
6-
import { PageTopBar } from '@/shared/ui/index.ts';
8+
import { PageTopBar, Section } from '@/shared/ui/index.ts';
79

810
export default function SkillNewPage() {
911
const { projectId } = useParams<{ projectId: string }>();
1012
const navigate = useNavigate();
1113
const canWrite = useCanWrite('skills');
14+
const [stagedFiles, setStagedFiles] = useState<File[]>([]);
1215

1316
const handleSubmit = async (data: {
1417
title: string;
@@ -23,6 +26,9 @@ export default function SkillNewPage() {
2326
}) => {
2427
if (!projectId) return;
2528
const skill = await createSkill(projectId, data);
29+
for (const file of stagedFiles) {
30+
await uploadSkillAttachment(projectId, skill.id, file).catch(() => {});
31+
}
2632
navigate(`/${projectId}/skills/${skill.id}`);
2733
};
2834

@@ -45,6 +51,13 @@ export default function SkillNewPage() {
4551
onCancel={() => navigate(`/${projectId}/skills`)}
4652
submitLabel="Create"
4753
/>
54+
<Section title="Attachments" sx={{ mt: 3 }}>
55+
<StagedAttachments
56+
files={stagedFiles}
57+
onAdd={files => setStagedFiles(prev => [...prev, ...files])}
58+
onRemove={index => setStagedFiles(prev => prev.filter((_, i) => i !== index))}
59+
/>
60+
</Section>
4861
</Box>
4962
);
5063
}

ui/src/pages/tasks/new.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
1+
import { useState } from 'react';
12
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
23
import { Box, Button, Alert } from '@mui/material';
3-
import { createTask, type TaskStatus, type TaskPriority } from '@/entities/task/index.ts';
4+
import { createTask, uploadTaskAttachment, type TaskStatus, type TaskPriority } from '@/entities/task/index.ts';
45
import { linkTaskToEpic } from '@/entities/epic/index.ts';
56
import { TaskForm } from '@/features/task-crud/TaskForm.tsx';
7+
import { StagedAttachments } from '@/features/attachments/index.ts';
68
import { useCanWrite } from '@/shared/lib/AccessContext.tsx';
7-
import { PageTopBar } from '@/shared/ui/index.ts';
9+
import { PageTopBar, Section } from '@/shared/ui/index.ts';
810

911
export default function TaskNewPage() {
1012
const { projectId } = useParams<{ projectId: string }>();
1113
const navigate = useNavigate();
1214
const canWrite = useCanWrite('tasks');
1315
const [searchParams] = useSearchParams();
16+
const [stagedFiles, setStagedFiles] = useState<File[]>([]);
1417

1518
const defaults = {
1619
title: searchParams.get('title') || undefined,
@@ -27,6 +30,9 @@ export default function TaskNewPage() {
2730
if (epicId) {
2831
await linkTaskToEpic(projectId, epicId, task.id).catch(() => {});
2932
}
33+
for (const file of stagedFiles) {
34+
await uploadTaskAttachment(projectId, task.id, file).catch(() => {});
35+
}
3036
navigate(`/${projectId}/tasks/${task.id}`);
3137
};
3238

@@ -50,6 +56,13 @@ export default function TaskNewPage() {
5056
onCancel={() => navigate(`/${projectId}/tasks`)}
5157
submitLabel="Create"
5258
/>
59+
<Section title="Attachments" sx={{ mt: 3 }}>
60+
<StagedAttachments
61+
files={stagedFiles}
62+
onAdd={files => setStagedFiles(prev => [...prev, ...files])}
63+
onRemove={index => setStagedFiles(prev => prev.filter((_, i) => i !== index))}
64+
/>
65+
</Section>
5366
</Box>
5467
);
5568
}

0 commit comments

Comments
 (0)