feat: add wiki, metadata, and task management
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

This commit is contained in:
gitmuhammedalbayrak
2025-11-30 16:44:38 +03:00
parent b9148cfa4b
commit 9918a7556a
12 changed files with 2573 additions and 249 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,9 @@
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@uiw/react-md-editor": "^4.0.8",
"antd": "^6.0.0",
"dayjs": "^1.11.19",
"i18next": "^25.6.3",
"i18next-http-backend": "^3.0.2",
"react": "^19.2.0",

View File

@@ -1,9 +1,13 @@
import React, { useEffect, useState } from 'react';
import { Card, Form, Input, Button, message, Typography, Divider } from 'antd';
import { SaveOutlined, DeleteOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { Card, Form, Input, Button, message, Typography, Divider, Tabs, Select, DatePicker, List, Checkbox, Tag, Space } from 'antd';
import { SaveOutlined, DeleteOutlined, ArrowLeftOutlined, PlusOutlined } from '@ant-design/icons';
import { useProjectStore } from '../store/useProjectStore';
import { useTaskStore } from '../store/useTaskStore';
import MDEditor from '@uiw/react-md-editor';
import dayjs from 'dayjs';
const { Text } = Typography;
const { Option } = Select;
interface ProjectDetailPanelProps {
projectId: string | null;
@@ -13,8 +17,11 @@ interface ProjectDetailPanelProps {
const ProjectDetailPanel: React.FC<ProjectDetailPanelProps> = ({ projectId, onClose }) => {
const [form] = Form.useForm();
const { projects, updateProject, deleteProject, fetchProjects } = useProjectStore();
const { tasks, fetchTasks, createTask, updateTask, deleteTask } = useTaskStore();
const [loading, setLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [wikiContent, setWikiContent] = useState<string | undefined>('');
const [newTaskTitle, setNewTaskTitle] = useState('');
const project = projectId ? projects.find(p => p.id === projectId) : null;
@@ -23,12 +30,19 @@ const ProjectDetailPanel: React.FC<ProjectDetailPanelProps> = ({ projectId, onCl
form.setFieldsValue({
name: project.name,
description: project.description || '',
attributes: project.attributes ? JSON.stringify(project.attributes, null, 2) : '{}',
status: project.attributes?.status || 'active',
priority: project.attributes?.priority || 'medium',
startDate: project.attributes?.startDate ? dayjs(project.attributes.startDate) : null,
dueDate: project.attributes?.dueDate ? dayjs(project.attributes.dueDate) : null,
tags: project.attributes?.tags || [],
});
setWikiContent(project.wiki_content || '');
fetchTasks(project.id);
} else {
form.resetFields();
setWikiContent('');
}
}, [project, form]);
}, [project, form, fetchTasks]);
const handleSave = async () => {
if (!project) return;
@@ -36,23 +50,23 @@ const ProjectDetailPanel: React.FC<ProjectDetailPanelProps> = ({ projectId, onCl
try {
setLoading(true);
const values = await form.validateFields();
// Validate JSON if provided
if (values.attributes) {
try {
JSON.parse(values.attributes);
} catch (e) {
message.error('Invalid JSON in attributes field');
return;
}
}
const attributes = {
...project.attributes,
status: values.status,
priority: values.priority,
startDate: values.startDate ? values.startDate.toISOString() : null,
dueDate: values.dueDate ? values.dueDate.toISOString() : null,
tags: values.tags,
};
await updateProject(project.id, {
name: values.name,
description: values.description || null,
wiki_content: wikiContent,
attributes: attributes,
});
// Note: attributes update would need backend support
message.success('Project updated successfully');
await fetchProjects();
} catch (error: any) {
@@ -77,12 +91,24 @@ const ProjectDetailPanel: React.FC<ProjectDetailPanelProps> = ({ projectId, onCl
}
};
const handleAddTask = async () => {
if (!newTaskTitle.trim() || !project) return;
await createTask({
title: newTaskTitle,
project_id: project.id,
status: 'todo',
priority: 'medium'
});
setNewTaskTitle('');
message.success('Task added');
};
if (!project) {
return (
<div style={{
height: '100%',
display: 'flex',
alignItems: 'center',
<div style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
textAlign: 'center',
@@ -96,231 +122,128 @@ const ProjectDetailPanel: React.FC<ProjectDetailPanelProps> = ({ projectId, onCl
);
}
const children = projects.filter(p => p.parentId === project.id);
const parent = project.parentId ? projects.find(p => p.id === project.parentId) : null;
const items = [
{
key: '1',
label: 'Details',
children: (
<Form form={form} layout="vertical">
<Form.Item name="name" label="Project Name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={4} />
</Form.Item>
<Divider>Metadata</Divider>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<Form.Item name="status" label="Status">
<Select>
<Option value="active">Active</Option>
<Option value="on_hold">On Hold</Option>
<Option value="completed">Completed</Option>
</Select>
</Form.Item>
<Form.Item name="priority" label="Priority">
<Select>
<Option value="high">High</Option>
<Option value="medium">Medium</Option>
<Option value="low">Low</Option>
</Select>
</Form.Item>
<Form.Item name="startDate" label="Start Date">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="dueDate" label="Due Date">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</div>
<Form.Item name="tags" label="Tags">
<Select mode="tags" placeholder="Add tags" />
</Form.Item>
</Form>
),
},
{
key: '2',
label: 'Wiki',
children: (
<div data-color-mode="light">
<MDEditor
value={wikiContent}
onChange={setWikiContent}
height={500}
/>
</div>
),
},
{
key: '3',
label: `Tasks (${tasks.length})`,
children: (
<div>
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<Input
placeholder="Add a new task..."
value={newTaskTitle}
onChange={(e) => setNewTaskTitle(e.target.value)}
onPressEnter={handleAddTask}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddTask}>Add</Button>
</div>
<List
dataSource={tasks}
renderItem={(task) => (
<List.Item
actions={[
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => deleteTask(task.id)} />
]}
>
<List.Item.Meta
avatar={
<Checkbox
checked={task.status === 'done'}
onChange={(e) => updateTask(task.id, { status: e.target.checked ? 'done' : 'todo' })}
/>
}
title={
<Text delete={task.status === 'done'}>
{task.title}
</Text>
}
description={
<Space>
<Tag color={task.priority === 'high' ? 'red' : task.priority === 'medium' ? 'orange' : 'green'}>
{task.priority}
</Tag>
{task.due_date && <Text type="secondary" style={{ fontSize: '12px' }}>Due: {dayjs(task.due_date).format('YYYY-MM-DD')}</Text>}
</Space>
}
/>
</List.Item>
)}
/>
</div>
),
},
];
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#fff' }}>
{/* Header */}
<div style={{
padding: '12px 16px',
borderBottom: '1px solid #f0f0f0',
background: '#fafafa'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: '12px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1 }}>
{onClose && (
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={onClose}
size="small"
style={{
padding: '0 4px',
minWidth: 'auto'
}}
/>
)}
<Text strong style={{ fontSize: '15px', fontWeight: 600 }}>Project Details</Text>
</div>
<Button
type="primary"
icon={<SaveOutlined />}
loading={loading}
onClick={handleSave}
size="small"
style={{
minWidth: '70px',
height: '28px',
fontSize: '13px',
padding: '0 12px'
}}
>
Save
</Button>
</div>
{project && (
<div style={{
marginTop: '8px',
padding: '8px 12px',
background: '#fff',
borderRadius: '6px',
border: '1px solid #e8e8e8'
}}>
<Text type="secondary" style={{ fontSize: '12px', display: 'block', marginBottom: '4px' }}>Current Project</Text>
<Text strong style={{ fontSize: '14px' }}>{project.name}</Text>
</div>
)}
<div style={{ marginTop: '8px' }}>
<Button
danger
icon={<DeleteOutlined />}
loading={deleteLoading}
onClick={handleDelete}
size="small"
block
style={{
height: '28px',
fontSize: '13px'
}}
>
Delete Project
</Button>
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0', background: '#fafafa', display: 'flex', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{onClose && <Button type="text" icon={<ArrowLeftOutlined />} onClick={onClose} />}
<Text strong style={{ fontSize: '15px' }}>{project.name}</Text>
</div>
<Space>
<Button danger icon={<DeleteOutlined />} loading={deleteLoading} onClick={handleDelete}>Delete</Button>
<Button type="primary" icon={<SaveOutlined />} loading={loading} onClick={handleSave}>Save</Button>
</Space>
</div>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: '20px' }}>
<Card bordered={false} style={{ boxShadow: 'none', padding: 0 }}>
<Form form={form} layout="vertical" style={{ maxWidth: '100%' }}>
<Form.Item
name="name"
label={<Text strong style={{ fontSize: '13px', marginBottom: '6px', display: 'block' }}>Project Name</Text>}
rules={[{ required: true, message: 'Please enter project name' }]}
style={{ marginBottom: '18px' }}
>
<Input
placeholder="Enter project name"
style={{
borderRadius: '6px',
fontSize: '14px'
}}
/>
</Form.Item>
<Form.Item
name="description"
label={<Text strong style={{ fontSize: '13px', marginBottom: '6px', display: 'block' }}>Description</Text>}
style={{ marginBottom: '20px' }}
>
<Input.TextArea
rows={4}
placeholder="Enter project description (optional)"
showCount
maxLength={500}
style={{
borderRadius: '6px',
fontSize: '14px'
}}
/>
</Form.Item>
<Divider style={{ marginTop: '8px', marginBottom: '24px' }}>
<Text strong style={{ fontSize: '15px' }}>Hierarchy</Text>
</Divider>
<div style={{ marginBottom: '32px' }}>
<div style={{ marginBottom: '20px' }}>
<Text type="secondary" style={{ fontSize: '13px', display: 'block', marginBottom: '10px', fontWeight: 500 }}>Parent</Text>
{parent ? (
<div style={{
padding: '12px 16px',
background: '#e6f7ff',
border: '1px solid #91d5ff',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#bae7ff';
e.currentTarget.style.borderColor = '#69c0ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#e6f7ff';
e.currentTarget.style.borderColor = '#91d5ff';
}}
>
<Text strong style={{ color: '#1890ff', fontSize: '14px' }}>{parent.name}</Text>
</div>
) : (
<Text type="secondary" style={{ fontStyle: 'italic', fontSize: '13px' }}>Root project (no parent)</Text>
)}
</div>
<div>
<Text type="secondary" style={{ fontSize: '13px', display: 'block', marginBottom: '10px', fontWeight: 500 }}>Children ({children.length})</Text>
{children.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{children.map(child => (
<div
key={child.id}
style={{
padding: '12px 16px',
background: '#f5f5f5',
border: '1px solid #d9d9d9',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#e6f7ff';
e.currentTarget.style.borderColor = '#91d5ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#f5f5f5';
e.currentTarget.style.borderColor = '#d9d9d9';
}}
>
<Text style={{ fontSize: '14px' }}>{child.name}</Text>
</div>
))}
</div>
) : (
<Text type="secondary" style={{ fontStyle: 'italic', fontSize: '13px' }}>No child projects</Text>
)}
</div>
</div>
<Divider style={{ marginTop: '8px', marginBottom: '24px' }}>
<Text strong style={{ fontSize: '15px' }}>Metadata</Text>
</Divider>
<Form.Item
name="attributes"
label={<Text strong style={{ fontSize: '14px', marginBottom: '8px', display: 'block' }}>Attributes (JSON)</Text>}
tooltip='Custom attributes in JSON format. Example: {"key": "value"}'
style={{ marginBottom: '32px' }}
>
<Input.TextArea
rows={6}
placeholder='{"key": "value", "anotherKey": "anotherValue"}'
style={{
fontFamily: 'monospace',
fontSize: '13px',
borderRadius: '8px',
padding: '10px 16px'
}}
/>
</Form.Item>
<Divider style={{ marginTop: '8px', marginBottom: '24px' }} />
<div style={{
padding: '16px 20px',
background: '#fafafa',
borderRadius: '8px',
border: '1px solid #f0f0f0'
}}>
<div style={{ marginBottom: '16px' }}>
<Text type="secondary" style={{ fontSize: '12px', display: 'block', marginBottom: '8px', fontWeight: 500 }}>Project ID</Text>
<Text code style={{ fontSize: '12px', wordBreak: 'break-all', padding: '4px 8px', background: '#fff', borderRadius: '4px' }}>{project.id}</Text>
</div>
<div>
<Text type="secondary" style={{ fontSize: '12px', display: 'block', marginBottom: '8px', fontWeight: 500 }}>Path</Text>
<Text code style={{ fontSize: '12px', wordBreak: 'break-all', padding: '4px 8px', background: '#fff', borderRadius: '4px' }}>{project.path}</Text>
</div>
</div>
</Form>
</Card>
<Tabs defaultActiveKey="1" items={items} />
</div>
</div>
);
};
export default ProjectDetailPanel;

View File

@@ -6,6 +6,7 @@ interface Project {
path: string;
parentId: string | null;
description?: string | null;
wiki_content?: string;
attributes?: Record<string, any>;
children?: Project[];
}
@@ -20,7 +21,7 @@ interface ProjectState {
expandNode: (key: string) => void;
fetchProjects: () => Promise<void>;
createProject: (data: { name: string; parentId?: string | null; description?: string }) => Promise<void>;
updateProject: (id: string, data: { name?: string; description?: string; parentId?: string | null }) => Promise<void>;
updateProject: (id: string, data: { name?: string; description?: string; parentId?: string | null; wiki_content?: string; attributes?: any }) => Promise<void>;
deleteProject: (id: string) => Promise<void>;
}
@@ -101,6 +102,8 @@ export const useProjectStore = create<ProjectState>((set) => ({
...(data.name && { name: data.name }),
...(data.description !== undefined && { description: data.description }),
...(data.parentId !== undefined && { parent_id: data.parentId }),
...(data.wiki_content !== undefined && { wiki_content: data.wiki_content }),
...(data.attributes !== undefined && { attributes: data.attributes }),
}),
});
if (response.ok) {

View File

@@ -0,0 +1,102 @@
import { create } from 'zustand';
interface Task {
id: string;
title: string;
description?: string;
status: string;
priority: string;
due_date?: string;
project_id: string;
}
interface TaskState {
tasks: Task[];
loading: boolean;
fetchTasks: (projectId: string) => Promise<void>;
createTask: (task: Partial<Task>) => Promise<void>;
updateTask: (id: string, updates: Partial<Task>) => Promise<void>;
deleteTask: (id: string) => Promise<void>;
}
export const useTaskStore = create<TaskState>((set) => ({
tasks: [],
loading: false,
fetchTasks: async (projectId: string) => {
set({ loading: true });
try {
const response = await fetch(`http://localhost:3000/tasks?projectId=${projectId}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const tasks = await response.json();
set({ tasks });
}
} catch (error) {
console.error('Failed to fetch tasks:', error);
} finally {
set({ loading: false });
}
},
createTask: async (task: Partial<Task>) => {
try {
const response = await fetch('http://localhost:3000/tasks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(task)
});
if (response.ok) {
const newTask = await response.json();
set(state => ({ tasks: [newTask, ...state.tasks] }));
}
} catch (error) {
console.error('Failed to create task:', error);
}
},
updateTask: async (id: string, updates: Partial<Task>) => {
try {
const response = await fetch(`http://localhost:3000/tasks/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(updates)
});
if (response.ok) {
const updatedTask = await response.json();
set(state => ({
tasks: state.tasks.map(t => t.id === id ? updatedTask : t)
}));
}
} catch (error) {
console.error('Failed to update task:', error);
}
},
deleteTask: async (id: string) => {
try {
const response = await fetch(`http://localhost:3000/tasks/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
set(state => ({
tasks: state.tasks.filter(t => t.id !== id)
}));
}
} catch (error) {
console.error('Failed to delete task:', error);
}
}
}));

View File

@@ -3,11 +3,16 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"types": ["vite/client"],
"types": [
"vite/client"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
@@ -15,14 +20,15 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
"include": [
"src"
]
}