floq 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +89 -9
- package/README.md +89 -9
- package/dist/changelog.d.ts +13 -0
- package/dist/changelog.js +95 -0
- package/dist/cli.js +44 -1
- package/dist/commands/comment.d.ts +2 -0
- package/dist/commands/comment.js +67 -0
- package/dist/commands/config.d.ts +4 -0
- package/dist/commands/config.js +123 -1
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +13 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +40 -3
- package/dist/db/index.js +20 -0
- package/dist/db/schema.d.ts +83 -0
- package/dist/db/schema.js +6 -0
- package/dist/i18n/en.d.ts +237 -0
- package/dist/i18n/en.js +127 -3
- package/dist/i18n/ja.js +127 -3
- package/dist/index.js +14 -4
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +63 -5
- package/dist/ui/App.js +280 -25
- package/dist/ui/ModeSelector.d.ts +7 -0
- package/dist/ui/ModeSelector.js +37 -0
- package/dist/ui/SetupWizard.d.ts +6 -0
- package/dist/ui/SetupWizard.js +321 -0
- package/dist/ui/components/HelpModal.d.ts +2 -1
- package/dist/ui/components/HelpModal.js +118 -4
- package/dist/ui/components/KanbanBoard.d.ts +6 -0
- package/dist/ui/components/KanbanBoard.js +508 -0
- package/dist/ui/components/KanbanColumn.d.ts +12 -0
- package/dist/ui/components/KanbanColumn.js +11 -0
- package/dist/ui/components/ProgressBar.d.ts +7 -0
- package/dist/ui/components/ProgressBar.js +13 -0
- package/dist/ui/components/SearchBar.d.ts +8 -0
- package/dist/ui/components/SearchBar.js +11 -0
- package/dist/ui/components/SearchResults.d.ts +9 -0
- package/dist/ui/components/SearchResults.js +18 -0
- package/dist/ui/components/TaskItem.d.ts +6 -1
- package/dist/ui/components/TaskItem.js +3 -2
- package/package.json +1 -1
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useApp } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import { eq, and, inArray } from 'drizzle-orm';
|
|
6
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
7
|
+
import { KanbanColumn } from './KanbanColumn.js';
|
|
8
|
+
import { HelpModal } from './HelpModal.js';
|
|
9
|
+
import { FunctionKeyBar } from './FunctionKeyBar.js';
|
|
10
|
+
import { SearchBar } from './SearchBar.js';
|
|
11
|
+
import { SearchResults } from './SearchResults.js';
|
|
12
|
+
import { getDb, schema } from '../../db/index.js';
|
|
13
|
+
import { t, fmt } from '../../i18n/index.js';
|
|
14
|
+
import { useTheme } from '../theme/index.js';
|
|
15
|
+
import { isTursoEnabled, getTursoConfig } from '../../config.js';
|
|
16
|
+
import { VERSION } from '../../version.js';
|
|
17
|
+
const COLUMNS = ['todo', 'doing', 'done'];
|
|
18
|
+
export function KanbanBoard({ onSwitchToGtd }) {
|
|
19
|
+
const theme = useTheme();
|
|
20
|
+
const { exit } = useApp();
|
|
21
|
+
const [mode, setMode] = useState('normal');
|
|
22
|
+
const [inputValue, setInputValue] = useState('');
|
|
23
|
+
const [currentColumnIndex, setCurrentColumnIndex] = useState(0);
|
|
24
|
+
const [selectedTaskIndices, setSelectedTaskIndices] = useState({
|
|
25
|
+
todo: 0,
|
|
26
|
+
doing: 0,
|
|
27
|
+
done: 0,
|
|
28
|
+
});
|
|
29
|
+
const [tasks, setTasks] = useState({
|
|
30
|
+
todo: [],
|
|
31
|
+
doing: [],
|
|
32
|
+
done: [],
|
|
33
|
+
});
|
|
34
|
+
const [message, setMessage] = useState(null);
|
|
35
|
+
const [selectedTask, setSelectedTask] = useState(null);
|
|
36
|
+
const [taskComments, setTaskComments] = useState([]);
|
|
37
|
+
const [selectedCommentIndex, setSelectedCommentIndex] = useState(0);
|
|
38
|
+
const [projects, setProjects] = useState([]);
|
|
39
|
+
const [projectSelectIndex, setProjectSelectIndex] = useState(0);
|
|
40
|
+
// Search state
|
|
41
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
42
|
+
const [searchResults, setSearchResults] = useState([]);
|
|
43
|
+
const [searchResultIndex, setSearchResultIndex] = useState(0);
|
|
44
|
+
const i18n = t();
|
|
45
|
+
// Status mapping:
|
|
46
|
+
// TODO = inbox + someday
|
|
47
|
+
// Doing = next + waiting
|
|
48
|
+
// Done = done
|
|
49
|
+
const loadTasks = useCallback(async () => {
|
|
50
|
+
const db = getDb();
|
|
51
|
+
// TODO: inbox + someday (non-project tasks)
|
|
52
|
+
const todoTasks = await db
|
|
53
|
+
.select()
|
|
54
|
+
.from(schema.tasks)
|
|
55
|
+
.where(and(inArray(schema.tasks.status, ['inbox', 'someday']), eq(schema.tasks.isProject, false)));
|
|
56
|
+
// Doing: next + waiting (non-project tasks)
|
|
57
|
+
const doingTasks = await db
|
|
58
|
+
.select()
|
|
59
|
+
.from(schema.tasks)
|
|
60
|
+
.where(and(inArray(schema.tasks.status, ['next', 'waiting']), eq(schema.tasks.isProject, false)));
|
|
61
|
+
// Done: done (non-project tasks)
|
|
62
|
+
const doneTasks = await db
|
|
63
|
+
.select()
|
|
64
|
+
.from(schema.tasks)
|
|
65
|
+
.where(and(eq(schema.tasks.status, 'done'), eq(schema.tasks.isProject, false)));
|
|
66
|
+
// Load projects for linking
|
|
67
|
+
const projectTasks = await db
|
|
68
|
+
.select()
|
|
69
|
+
.from(schema.tasks)
|
|
70
|
+
.where(and(eq(schema.tasks.isProject, true), eq(schema.tasks.status, 'next')));
|
|
71
|
+
setTasks({
|
|
72
|
+
todo: todoTasks,
|
|
73
|
+
doing: doingTasks,
|
|
74
|
+
done: doneTasks,
|
|
75
|
+
});
|
|
76
|
+
setProjects(projectTasks);
|
|
77
|
+
}, []);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
loadTasks();
|
|
80
|
+
}, [loadTasks]);
|
|
81
|
+
const loadTaskComments = useCallback(async (taskId) => {
|
|
82
|
+
const db = getDb();
|
|
83
|
+
const comments = await db
|
|
84
|
+
.select()
|
|
85
|
+
.from(schema.comments)
|
|
86
|
+
.where(eq(schema.comments.taskId, taskId));
|
|
87
|
+
setTaskComments(comments);
|
|
88
|
+
}, []);
|
|
89
|
+
const addCommentToTask = useCallback(async (task, content) => {
|
|
90
|
+
const db = getDb();
|
|
91
|
+
await db.insert(schema.comments).values({
|
|
92
|
+
id: uuidv4(),
|
|
93
|
+
taskId: task.id,
|
|
94
|
+
content: content.trim(),
|
|
95
|
+
createdAt: new Date(),
|
|
96
|
+
});
|
|
97
|
+
setMessage(i18n.tui.commentAdded || 'Comment added');
|
|
98
|
+
await loadTaskComments(task.id);
|
|
99
|
+
}, [i18n.tui.commentAdded, loadTaskComments]);
|
|
100
|
+
const deleteComment = useCallback(async (comment) => {
|
|
101
|
+
const db = getDb();
|
|
102
|
+
await db.delete(schema.comments).where(eq(schema.comments.id, comment.id));
|
|
103
|
+
setMessage(i18n.tui.commentDeleted || 'Comment deleted');
|
|
104
|
+
if (selectedTask) {
|
|
105
|
+
await loadTaskComments(selectedTask.id);
|
|
106
|
+
}
|
|
107
|
+
}, [i18n.tui.commentDeleted, loadTaskComments, selectedTask]);
|
|
108
|
+
const linkTaskToProject = useCallback(async (task, project) => {
|
|
109
|
+
const db = getDb();
|
|
110
|
+
await db.update(schema.tasks)
|
|
111
|
+
.set({ parentId: project.id, updatedAt: new Date() })
|
|
112
|
+
.where(eq(schema.tasks.id, task.id));
|
|
113
|
+
setMessage(fmt(i18n.tui.linkedToProject || 'Linked "{title}" to {project}', { title: task.title, project: project.title }));
|
|
114
|
+
await loadTasks();
|
|
115
|
+
}, [i18n.tui.linkedToProject, loadTasks]);
|
|
116
|
+
const currentColumn = COLUMNS[currentColumnIndex];
|
|
117
|
+
const currentTasks = tasks[currentColumn];
|
|
118
|
+
const selectedTaskIndex = selectedTaskIndices[currentColumn];
|
|
119
|
+
// Get all tasks for search
|
|
120
|
+
const getAllTasks = useCallback(() => {
|
|
121
|
+
const allTasks = [];
|
|
122
|
+
for (const col of COLUMNS) {
|
|
123
|
+
allTasks.push(...tasks[col]);
|
|
124
|
+
}
|
|
125
|
+
return allTasks;
|
|
126
|
+
}, [tasks]);
|
|
127
|
+
// Search tasks by query
|
|
128
|
+
const searchTasks = useCallback((query) => {
|
|
129
|
+
if (!query.trim())
|
|
130
|
+
return [];
|
|
131
|
+
const lowerQuery = query.toLowerCase();
|
|
132
|
+
const allTasks = getAllTasks();
|
|
133
|
+
return allTasks.filter(task => task.title.toLowerCase().includes(lowerQuery) ||
|
|
134
|
+
(task.description && task.description.toLowerCase().includes(lowerQuery)));
|
|
135
|
+
}, [getAllTasks]);
|
|
136
|
+
// Handle search query change
|
|
137
|
+
const handleSearchChange = useCallback((value) => {
|
|
138
|
+
setSearchQuery(value);
|
|
139
|
+
const results = searchTasks(value);
|
|
140
|
+
setSearchResults(results);
|
|
141
|
+
setSearchResultIndex(0);
|
|
142
|
+
}, [searchTasks]);
|
|
143
|
+
const addTask = useCallback(async (title) => {
|
|
144
|
+
if (!title.trim())
|
|
145
|
+
return;
|
|
146
|
+
const db = getDb();
|
|
147
|
+
const now = new Date();
|
|
148
|
+
await db.insert(schema.tasks)
|
|
149
|
+
.values({
|
|
150
|
+
id: uuidv4(),
|
|
151
|
+
title: title.trim(),
|
|
152
|
+
status: 'inbox', // New tasks go to inbox (which maps to TODO)
|
|
153
|
+
createdAt: now,
|
|
154
|
+
updatedAt: now,
|
|
155
|
+
});
|
|
156
|
+
setMessage(fmt(i18n.tui.added, { title: title.trim() }));
|
|
157
|
+
await loadTasks();
|
|
158
|
+
}, [i18n.tui.added, loadTasks]);
|
|
159
|
+
const handleInputSubmit = async (value) => {
|
|
160
|
+
if (mode === 'add-comment' && selectedTask) {
|
|
161
|
+
if (value.trim()) {
|
|
162
|
+
await addCommentToTask(selectedTask, value);
|
|
163
|
+
}
|
|
164
|
+
setMode('task-detail');
|
|
165
|
+
setInputValue('');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// Handle search mode submit
|
|
169
|
+
if (mode === 'search') {
|
|
170
|
+
if (searchResults.length > 0) {
|
|
171
|
+
const task = searchResults[searchResultIndex];
|
|
172
|
+
setSelectedTask(task);
|
|
173
|
+
loadTaskComments(task.id);
|
|
174
|
+
setMode('task-detail');
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
setMode('normal');
|
|
178
|
+
}
|
|
179
|
+
setSearchQuery('');
|
|
180
|
+
setSearchResults([]);
|
|
181
|
+
setSearchResultIndex(0);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (value.trim()) {
|
|
185
|
+
await addTask(value);
|
|
186
|
+
}
|
|
187
|
+
setInputValue('');
|
|
188
|
+
setMode('normal');
|
|
189
|
+
};
|
|
190
|
+
const moveTaskRight = useCallback(async (task) => {
|
|
191
|
+
const db = getDb();
|
|
192
|
+
let newStatus;
|
|
193
|
+
// Determine new status based on current status
|
|
194
|
+
if (task.status === 'inbox' || task.status === 'someday') {
|
|
195
|
+
// TODO -> Doing (next)
|
|
196
|
+
newStatus = 'next';
|
|
197
|
+
}
|
|
198
|
+
else if (task.status === 'next' || task.status === 'waiting') {
|
|
199
|
+
// Doing -> Done
|
|
200
|
+
newStatus = 'done';
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
// Already done, do nothing
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
await db.update(schema.tasks)
|
|
207
|
+
.set({ status: newStatus, waitingFor: null, updatedAt: new Date() })
|
|
208
|
+
.where(eq(schema.tasks.id, task.id));
|
|
209
|
+
setMessage(fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }));
|
|
210
|
+
await loadTasks();
|
|
211
|
+
}, [i18n.tui.movedTo, i18n.status, loadTasks]);
|
|
212
|
+
const moveTaskLeft = useCallback(async (task) => {
|
|
213
|
+
const db = getDb();
|
|
214
|
+
let newStatus;
|
|
215
|
+
// Determine new status based on current status
|
|
216
|
+
if (task.status === 'next' || task.status === 'waiting') {
|
|
217
|
+
// Doing -> TODO (inbox)
|
|
218
|
+
newStatus = 'inbox';
|
|
219
|
+
}
|
|
220
|
+
else if (task.status === 'done') {
|
|
221
|
+
// Done -> Doing (next)
|
|
222
|
+
newStatus = 'next';
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
// Already in TODO, do nothing
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
await db.update(schema.tasks)
|
|
229
|
+
.set({ status: newStatus, waitingFor: null, updatedAt: new Date() })
|
|
230
|
+
.where(eq(schema.tasks.id, task.id));
|
|
231
|
+
setMessage(fmt(i18n.tui.movedTo, { title: task.title, status: i18n.status[newStatus] }));
|
|
232
|
+
await loadTasks();
|
|
233
|
+
}, [i18n.tui.movedTo, i18n.status, loadTasks]);
|
|
234
|
+
const markTaskDone = useCallback(async (task) => {
|
|
235
|
+
const db = getDb();
|
|
236
|
+
await db.update(schema.tasks)
|
|
237
|
+
.set({ status: 'done', updatedAt: new Date() })
|
|
238
|
+
.where(eq(schema.tasks.id, task.id));
|
|
239
|
+
setMessage(fmt(i18n.tui.completed, { title: task.title }));
|
|
240
|
+
await loadTasks();
|
|
241
|
+
}, [i18n.tui.completed, loadTasks]);
|
|
242
|
+
const getColumnLabel = (column) => {
|
|
243
|
+
return i18n.kanban[column];
|
|
244
|
+
};
|
|
245
|
+
useInput((input, key) => {
|
|
246
|
+
// Handle help mode - any key closes
|
|
247
|
+
if (mode === 'help') {
|
|
248
|
+
setMode('normal');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
// Handle search mode
|
|
252
|
+
if (mode === 'search') {
|
|
253
|
+
if (key.escape) {
|
|
254
|
+
setSearchQuery('');
|
|
255
|
+
setSearchResults([]);
|
|
256
|
+
setSearchResultIndex(0);
|
|
257
|
+
setMode('normal');
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
// Navigate search results with Ctrl+j/k or Ctrl+n/p
|
|
261
|
+
if (key.ctrl && (input === 'j' || input === 'n')) {
|
|
262
|
+
setSearchResultIndex((prev) => prev < searchResults.length - 1 ? prev + 1 : 0);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (key.ctrl && (input === 'k' || input === 'p')) {
|
|
266
|
+
setSearchResultIndex((prev) => prev > 0 ? prev - 1 : Math.max(0, searchResults.length - 1));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// Let TextInput handle other keys
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
// Handle add mode
|
|
273
|
+
if (mode === 'add' || mode === 'add-comment') {
|
|
274
|
+
if (key.escape) {
|
|
275
|
+
setInputValue('');
|
|
276
|
+
if (mode === 'add-comment') {
|
|
277
|
+
setMode('task-detail');
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
setMode('normal');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
// Handle task-detail mode
|
|
286
|
+
if (mode === 'task-detail') {
|
|
287
|
+
if (key.escape || key.backspace || input === 'b') {
|
|
288
|
+
setMode('normal');
|
|
289
|
+
setSelectedTask(null);
|
|
290
|
+
setTaskComments([]);
|
|
291
|
+
setSelectedCommentIndex(0);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Navigate comments
|
|
295
|
+
if (key.upArrow || input === 'k') {
|
|
296
|
+
setSelectedCommentIndex((prev) => (prev > 0 ? prev - 1 : Math.max(0, taskComments.length - 1)));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (key.downArrow || input === 'j') {
|
|
300
|
+
setSelectedCommentIndex((prev) => (prev < taskComments.length - 1 ? prev + 1 : 0));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
// Delete comment
|
|
304
|
+
if (input === 'd' && taskComments.length > 0) {
|
|
305
|
+
const comment = taskComments[selectedCommentIndex];
|
|
306
|
+
deleteComment(comment).then(() => {
|
|
307
|
+
if (selectedCommentIndex >= taskComments.length - 1) {
|
|
308
|
+
setSelectedCommentIndex(Math.max(0, selectedCommentIndex - 1));
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
// Add comment
|
|
314
|
+
if (input === 'i') {
|
|
315
|
+
setMode('add-comment');
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// Link to project (P key)
|
|
319
|
+
if (input === 'P' && projects.length > 0) {
|
|
320
|
+
setProjectSelectIndex(0);
|
|
321
|
+
setMode('select-project');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// Handle select-project mode
|
|
327
|
+
if (mode === 'select-project') {
|
|
328
|
+
if (key.escape) {
|
|
329
|
+
setProjectSelectIndex(0);
|
|
330
|
+
setMode('task-detail');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Navigate projects
|
|
334
|
+
if (key.upArrow || input === 'k') {
|
|
335
|
+
setProjectSelectIndex((prev) => (prev > 0 ? prev - 1 : projects.length - 1));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (key.downArrow || input === 'j') {
|
|
339
|
+
setProjectSelectIndex((prev) => (prev < projects.length - 1 ? prev + 1 : 0));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// Select project with Enter
|
|
343
|
+
if (key.return && selectedTask && projects.length > 0) {
|
|
344
|
+
const project = projects[projectSelectIndex];
|
|
345
|
+
linkTaskToProject(selectedTask, project);
|
|
346
|
+
setProjectSelectIndex(0);
|
|
347
|
+
setMode('task-detail');
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// Clear message on any input
|
|
353
|
+
if (message) {
|
|
354
|
+
setMessage(null);
|
|
355
|
+
}
|
|
356
|
+
// Show help
|
|
357
|
+
if (input === '?') {
|
|
358
|
+
setMode('help');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// Search mode
|
|
362
|
+
if (input === '/') {
|
|
363
|
+
setMode('search');
|
|
364
|
+
setSearchQuery('');
|
|
365
|
+
setSearchResults([]);
|
|
366
|
+
setSearchResultIndex(0);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
// Quit
|
|
370
|
+
if (input === 'q' || (key.ctrl && input === 'c')) {
|
|
371
|
+
exit();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
// Add task
|
|
375
|
+
if (input === 'a') {
|
|
376
|
+
setMode('add');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
// Direct column switch with number keys
|
|
380
|
+
if (input === '1') {
|
|
381
|
+
setCurrentColumnIndex(0);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (input === '2') {
|
|
385
|
+
setCurrentColumnIndex(1);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (input === '3') {
|
|
389
|
+
setCurrentColumnIndex(2);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
// Navigate between columns
|
|
393
|
+
if (key.leftArrow || input === 'h') {
|
|
394
|
+
setCurrentColumnIndex((prev) => (prev > 0 ? prev - 1 : COLUMNS.length - 1));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (key.rightArrow || input === 'l') {
|
|
398
|
+
setCurrentColumnIndex((prev) => (prev < COLUMNS.length - 1 ? prev + 1 : 0));
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
// Navigate within column
|
|
402
|
+
if (key.upArrow || input === 'k') {
|
|
403
|
+
setSelectedTaskIndices((prev) => ({
|
|
404
|
+
...prev,
|
|
405
|
+
[currentColumn]: prev[currentColumn] > 0 ? prev[currentColumn] - 1 : Math.max(0, currentTasks.length - 1),
|
|
406
|
+
}));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (key.downArrow || input === 'j') {
|
|
410
|
+
setSelectedTaskIndices((prev) => ({
|
|
411
|
+
...prev,
|
|
412
|
+
[currentColumn]: prev[currentColumn] < currentTasks.length - 1 ? prev[currentColumn] + 1 : 0,
|
|
413
|
+
}));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
// Open task detail (Enter)
|
|
417
|
+
if (key.return && currentTasks.length > 0) {
|
|
418
|
+
const task = currentTasks[selectedTaskIndex];
|
|
419
|
+
setSelectedTask(task);
|
|
420
|
+
loadTaskComments(task.id);
|
|
421
|
+
setMode('task-detail');
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
// Move task right (m)
|
|
425
|
+
if (input === 'm' && currentTasks.length > 0 && currentColumn !== 'done') {
|
|
426
|
+
const task = currentTasks[selectedTaskIndex];
|
|
427
|
+
moveTaskRight(task).then(() => {
|
|
428
|
+
// Adjust selection if needed
|
|
429
|
+
if (selectedTaskIndex >= currentTasks.length - 1) {
|
|
430
|
+
setSelectedTaskIndices((prev) => ({
|
|
431
|
+
...prev,
|
|
432
|
+
[currentColumn]: Math.max(0, prev[currentColumn] - 1),
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// Move task left (Backspace)
|
|
439
|
+
if ((key.backspace || key.delete) && currentTasks.length > 0 && currentColumn !== 'todo') {
|
|
440
|
+
const task = currentTasks[selectedTaskIndex];
|
|
441
|
+
moveTaskLeft(task).then(() => {
|
|
442
|
+
// Adjust selection if needed
|
|
443
|
+
if (selectedTaskIndex >= currentTasks.length - 1) {
|
|
444
|
+
setSelectedTaskIndices((prev) => ({
|
|
445
|
+
...prev,
|
|
446
|
+
[currentColumn]: Math.max(0, prev[currentColumn] - 1),
|
|
447
|
+
}));
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
// Mark as done (d)
|
|
453
|
+
if (input === 'd' && currentTasks.length > 0) {
|
|
454
|
+
const task = currentTasks[selectedTaskIndex];
|
|
455
|
+
markTaskDone(task).then(() => {
|
|
456
|
+
if (selectedTaskIndex >= currentTasks.length - 1) {
|
|
457
|
+
setSelectedTaskIndices((prev) => ({
|
|
458
|
+
...prev,
|
|
459
|
+
[currentColumn]: Math.max(0, prev[currentColumn] - 1),
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// Refresh
|
|
466
|
+
if (input === 'r') {
|
|
467
|
+
loadTasks();
|
|
468
|
+
setMessage(i18n.tui.refreshed);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
// Help modal overlay
|
|
473
|
+
if (mode === 'help') {
|
|
474
|
+
return (_jsx(Box, { flexDirection: "column", padding: 1, children: _jsx(HelpModal, { onClose: () => setMode('normal'), isKanban: true }) }));
|
|
475
|
+
}
|
|
476
|
+
const formatTitle = (title) => theme.style.headerUppercase ? title.toUpperCase() : title;
|
|
477
|
+
// Turso connection info
|
|
478
|
+
const tursoEnabled = isTursoEnabled();
|
|
479
|
+
const tursoHost = tursoEnabled ? (() => {
|
|
480
|
+
const config = getTursoConfig();
|
|
481
|
+
if (config) {
|
|
482
|
+
try {
|
|
483
|
+
return new URL(config.url).host;
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return config.url;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return '';
|
|
490
|
+
})() : '';
|
|
491
|
+
return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: theme.colors.primary, children: formatTitle(i18n.tui.title) }), _jsx(Text, { color: theme.colors.accent, children: " [KANBAN]" }), _jsx(Text, { color: theme.colors.textMuted, children: theme.name === 'modern' ? ` v${VERSION}` : ` VER ${VERSION}` }), tursoEnabled && (_jsxs(Text, { color: theme.colors.accent, children: [theme.name === 'modern' ? ' ☁️ ' : ' [SYNC] ', tursoHost] })), !tursoEnabled && (_jsx(Text, { color: theme.colors.textMuted, children: theme.name === 'modern' ? ' 💾 local' : ' [LOCAL]' }))] }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.helpHint })] }), (mode === 'task-detail' || mode === 'add-comment' || mode === 'select-project') && selectedTask ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: theme.colors.accent, bold: true, children: [theme.name === 'modern' ? '📋 ' : '>> ', i18n.tui.taskDetailTitle || 'Task Details'] }), _jsxs(Text, { color: theme.colors.textMuted, children: [" (Esc/b: ", i18n.tui.back || 'back', ", ", i18n.tui.commentHint || 'i: add comment', ")"] })] }), _jsxs(Box, { flexDirection: "column", borderStyle: theme.borders.list, borderColor: theme.colors.border, paddingX: 1, paddingY: 1, marginBottom: 1, children: [_jsx(Text, { color: theme.colors.text, bold: true, children: selectedTask.title }), selectedTask.description && (_jsx(Text, { color: theme.colors.textMuted, children: selectedTask.description })), _jsxs(Text, { color: theme.colors.textMuted, children: [i18n.status[selectedTask.status], selectedTask.waitingFor && ` - ${selectedTask.waitingFor}`, selectedTask.dueDate && ` (${selectedTask.dueDate.toLocaleDateString()})`] })] }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: theme.colors.secondary, bold: true, children: [i18n.tui.comments || 'Comments', " (", taskComments.length, ")"] }) }), _jsx(Box, { flexDirection: "column", borderStyle: theme.borders.list, borderColor: theme.colors.border, paddingX: 1, paddingY: 1, minHeight: 5, children: taskComments.length === 0 ? (_jsx(Text, { color: theme.colors.textMuted, italic: true, children: i18n.tui.noComments || 'No comments yet' })) : (taskComments.map((comment, index) => {
|
|
492
|
+
const isSelected = index === selectedCommentIndex && mode === 'task-detail';
|
|
493
|
+
return (_jsxs(Box, { flexDirection: "row", marginBottom: 1, children: [_jsx(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.textMuted, children: isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: theme.colors.textMuted, children: ["[", comment.createdAt.toLocaleString(), "]"] }), _jsx(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: comment.content })] })] }, comment.id));
|
|
494
|
+
})) }), mode === 'add-comment' && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: i18n.tui.addComment || 'New comment: ' }), _jsx(TextInput, { value: inputValue, onChange: setInputValue, onSubmit: handleInputSubmit, placeholder: "" }), _jsxs(Text, { color: theme.colors.textMuted, children: [" ", i18n.tui.inputHelp] })] })), mode === 'select-project' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.colors.secondary, bold: true, children: [i18n.tui.selectProject || 'Select project for', ": ", selectedTask.title] }), _jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: theme.borders.list, borderColor: theme.colors.borderActive, paddingX: 1, children: projects.map((project, index) => (_jsxs(Text, { color: index === projectSelectIndex ? theme.colors.textSelected : theme.colors.text, bold: index === projectSelectIndex, children: [index === projectSelectIndex ? theme.style.selectedPrefix : theme.style.unselectedPrefix, project.title] }, project.id))) }), _jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.selectProjectHelp || 'j/k: select, Enter: confirm, Esc: cancel' })] }))] })) : mode === 'search' ? (_jsx(_Fragment, { children: searchQuery && (_jsx(SearchResults, { results: searchResults, selectedIndex: searchResultIndex, query: searchQuery })) })) : (_jsxs(_Fragment, { children: [_jsx(Box, { marginBottom: 1, children: COLUMNS.map((column, index) => (_jsx(Box, { flexGrow: 1, flexBasis: 0, marginRight: index < 2 ? 1 : 0, children: _jsxs(Text, { color: currentColumnIndex === index ? theme.colors.textHighlight : theme.colors.textMuted, children: [index + 1, ":"] }) }, column))) }), _jsx(Box, { flexDirection: "row", children: COLUMNS.map((column, index) => (_jsx(KanbanColumn, { title: getColumnLabel(column), tasks: tasks[column], isActive: index === currentColumnIndex, selectedTaskIndex: selectedTaskIndices[column], columnIndex: index }, column))) })] })), mode === 'add' && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: i18n.tui.newTask }), _jsx(TextInput, { value: inputValue, onChange: setInputValue, onSubmit: handleInputSubmit, placeholder: i18n.tui.placeholder }), _jsxs(Text, { color: theme.colors.textMuted, children: [" ", i18n.tui.inputHelp] })] })), mode === 'search' && (_jsx(SearchBar, { value: searchQuery, onChange: handleSearchChange, onSubmit: handleInputSubmit })), message && mode === 'normal' && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.colors.textHighlight, children: message }) })), _jsx(Box, { marginTop: 1, children: mode === 'select-project' ? (_jsx(Text, { color: theme.colors.textMuted, children: i18n.tui.selectProjectHelp || 'j/k: select, Enter: confirm, Esc: cancel' })) : (mode === 'task-detail' || mode === 'add-comment') ? (theme.style.showFunctionKeys ? (_jsx(FunctionKeyBar, { keys: [
|
|
495
|
+
{ key: 'i', label: i18n.tui.keyBar.comment },
|
|
496
|
+
{ key: 'd', label: i18n.tui.keyBar.delete },
|
|
497
|
+
{ key: 'P', label: i18n.tui.keyBar.project },
|
|
498
|
+
{ key: 'b', label: i18n.tui.keyBar.back },
|
|
499
|
+
] })) : (_jsx(Text, { color: theme.colors.textMuted, children: "i=comment d=delete P=link j/k=select Esc/b=back" }))) : theme.style.showFunctionKeys ? (_jsx(FunctionKeyBar, { keys: [
|
|
500
|
+
{ key: 'a', label: i18n.tui.keyBar.add },
|
|
501
|
+
{ key: 'd', label: i18n.tui.keyBar.done },
|
|
502
|
+
{ key: 'm', label: '→' },
|
|
503
|
+
{ key: 'BS', label: '←' },
|
|
504
|
+
{ key: 'Enter', label: 'detail' },
|
|
505
|
+
{ key: '?', label: i18n.tui.keyBar.help },
|
|
506
|
+
{ key: 'q', label: i18n.tui.keyBar.quit },
|
|
507
|
+
] })) : (_jsx(Text, { color: theme.colors.textMuted, children: "a=add d=done m=\u2192 BS=\u2190 Enter=detail h/l=col j/k=task ?=help q=quit" })) })] }));
|
|
508
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Task } from '../../db/schema.js';
|
|
3
|
+
export type KanbanColumnType = 'todo' | 'doing' | 'done';
|
|
4
|
+
interface KanbanColumnProps {
|
|
5
|
+
title: string;
|
|
6
|
+
tasks: Task[];
|
|
7
|
+
isActive: boolean;
|
|
8
|
+
selectedTaskIndex: number;
|
|
9
|
+
columnIndex: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function KanbanColumn({ title, tasks, isActive, selectedTaskIndex, columnIndex, }: KanbanColumnProps): React.ReactElement;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { useTheme } from '../theme/index.js';
|
|
4
|
+
export function KanbanColumn({ title, tasks, isActive, selectedTaskIndex, columnIndex, }) {
|
|
5
|
+
const theme = useTheme();
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, flexBasis: 0, borderStyle: theme.borders.list, borderColor: isActive ? theme.colors.borderActive : theme.colors.border, marginRight: columnIndex < 2 ? 1 : 0, children: [_jsx(Box, { paddingX: 1, justifyContent: "center", borderStyle: undefined, children: _jsxs(Text, { bold: true, color: isActive ? theme.colors.primary : theme.colors.textMuted, inverse: isActive && theme.style.tabActiveInverse, children: [title, " (", tasks.length, ")"] }) }), _jsx(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, minHeight: 8, children: tasks.length === 0 ? (_jsx(Text, { color: theme.colors.textMuted, italic: true, children: "-" })) : (tasks.map((task, index) => {
|
|
7
|
+
const isSelected = isActive && index === selectedTaskIndex;
|
|
8
|
+
const shortId = task.id.slice(0, 6);
|
|
9
|
+
return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title] }) }, task.id));
|
|
10
|
+
})) })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import { useTheme } from '../theme/index.js';
|
|
4
|
+
export function ProgressBar({ completed, total, width = 10 }) {
|
|
5
|
+
const theme = useTheme();
|
|
6
|
+
const [filledChar, emptyChar] = theme.style.loadingChars;
|
|
7
|
+
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
8
|
+
const filledCount = total > 0 ? Math.round((completed / total) * width) : 0;
|
|
9
|
+
const emptyCount = width - filledCount;
|
|
10
|
+
const filledBar = filledChar.repeat(filledCount);
|
|
11
|
+
const emptyBar = emptyChar.repeat(emptyCount);
|
|
12
|
+
return (_jsxs(Text, { color: theme.colors.textMuted, children: [' ', filledBar, _jsx(Text, { color: theme.colors.muted, children: emptyBar }), _jsxs(Text, { color: theme.colors.textMuted, children: [" ", percentage, "% (", completed, "/", total, ")"] })] }));
|
|
13
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface SearchBarProps {
|
|
3
|
+
value: string;
|
|
4
|
+
onChange: (value: string) => void;
|
|
5
|
+
onSubmit: (value: string) => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function SearchBar({ value, onChange, onSubmit }: SearchBarProps): React.ReactElement;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import TextInput from 'ink-text-input';
|
|
4
|
+
import { t } from '../../i18n/index.js';
|
|
5
|
+
import { useTheme } from '../theme/index.js';
|
|
6
|
+
export function SearchBar({ value, onChange, onSubmit }) {
|
|
7
|
+
const i18n = t();
|
|
8
|
+
const theme = useTheme();
|
|
9
|
+
const search = i18n.tui.search;
|
|
10
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.colors.secondary, bold: true, children: search.prefix }), _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit, placeholder: search.placeholder }), _jsxs(Text, { color: theme.colors.textMuted, children: [" ", search.help] })] }));
|
|
11
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Task } from '../../db/schema.js';
|
|
3
|
+
interface SearchResultsProps {
|
|
4
|
+
results: Task[];
|
|
5
|
+
selectedIndex: number;
|
|
6
|
+
query: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function SearchResults({ results, selectedIndex, query }: SearchResultsProps): React.ReactElement;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { t } from '../../i18n/index.js';
|
|
4
|
+
import { useTheme } from '../theme/index.js';
|
|
5
|
+
export function SearchResults({ results, selectedIndex, query }) {
|
|
6
|
+
const i18n = t();
|
|
7
|
+
const theme = useTheme();
|
|
8
|
+
const search = i18n.tui.search;
|
|
9
|
+
if (!query) {
|
|
10
|
+
return _jsx(_Fragment, {});
|
|
11
|
+
}
|
|
12
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: theme.borders.list, borderColor: theme.colors.borderActive, paddingX: 1, paddingY: 1, minHeight: 5, children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: theme.colors.secondary, bold: true, children: ["[", search.resultsTitle, "] (", results.length, ")"] }) }), results.length === 0 ? (_jsx(Text, { color: theme.colors.textMuted, italic: true, children: search.noResults })) : (results.slice(0, 10).map((task, index) => {
|
|
13
|
+
const isSelected = index === selectedIndex;
|
|
14
|
+
const shortId = task.id.slice(0, 8);
|
|
15
|
+
const statusLabel = i18n.status[task.status];
|
|
16
|
+
return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title, _jsxs(Text, { color: theme.colors.textMuted, children: [" (", statusLabel, ")"] }), task.waitingFor && (_jsxs(Text, { color: theme.colors.statusWaiting, children: [" - ", task.waitingFor] }))] }) }, task.id));
|
|
17
|
+
})), results.length > 10 && (_jsxs(Text, { color: theme.colors.textMuted, italic: true, children: ["... and ", results.length - 10, " more"] }))] }));
|
|
18
|
+
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import type { Task } from '../../db/schema.js';
|
|
3
|
+
export interface ProjectProgress {
|
|
4
|
+
completed: number;
|
|
5
|
+
total: number;
|
|
6
|
+
}
|
|
3
7
|
interface TaskItemProps {
|
|
4
8
|
task: Task;
|
|
5
9
|
isSelected: boolean;
|
|
6
10
|
projectName?: string;
|
|
11
|
+
progress?: ProjectProgress;
|
|
7
12
|
}
|
|
8
|
-
export declare function TaskItem({ task, isSelected, projectName }: TaskItemProps): React.ReactElement;
|
|
13
|
+
export declare function TaskItem({ task, isSelected, projectName, progress }: TaskItemProps): React.ReactElement;
|
|
9
14
|
export {};
|
|
@@ -2,9 +2,10 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { t } from '../../i18n/index.js';
|
|
4
4
|
import { useTheme } from '../theme/index.js';
|
|
5
|
-
|
|
5
|
+
import { ProgressBar } from './ProgressBar.js';
|
|
6
|
+
export function TaskItem({ task, isSelected, projectName, progress }) {
|
|
6
7
|
const shortId = task.id.slice(0, 8);
|
|
7
8
|
const i18n = t();
|
|
8
9
|
const theme = useTheme();
|
|
9
|
-
return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title, projectName && (_jsxs(Text, { color: theme.colors.statusSomeday, children: [" @", projectName] })), task.waitingFor && (_jsxs(Text, { color: theme.colors.statusWaiting, children: [" (", i18n.status.waiting.toLowerCase(), ": ", task.waitingFor, ")"] })), task.dueDate && (_jsxs(Text, { color: theme.colors.accent, children: [" (due: ", task.dueDate.toLocaleDateString(), ")"] }))] }) }));
|
|
10
|
+
return (_jsx(Box, { children: _jsxs(Text, { color: isSelected ? theme.colors.textSelected : theme.colors.text, bold: isSelected, children: [isSelected ? theme.style.selectedPrefix : theme.style.unselectedPrefix, "[", shortId, "] ", task.title, projectName && (_jsxs(Text, { color: theme.colors.statusSomeday, children: [" @", projectName] })), progress && progress.total > 0 && (_jsx(ProgressBar, { completed: progress.completed, total: progress.total })), task.waitingFor && (_jsxs(Text, { color: theme.colors.statusWaiting, children: [" (", i18n.status.waiting.toLowerCase(), ": ", task.waitingFor, ")"] })), task.dueDate && (_jsxs(Text, { color: theme.colors.accent, children: [" (due: ", task.dueDate.toLocaleDateString(), ")"] }))] }) }));
|
|
10
11
|
}
|