tui-kanban-board 0.1.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.md +147 -0
- package/bin/tkb.js +3 -0
- package/package.json +26 -0
- package/src/App.tsx +388 -0
- package/src/components/Board.tsx +38 -0
- package/src/components/Card.tsx +30 -0
- package/src/components/Column.tsx +71 -0
- package/src/components/ColumnEditModal.tsx +72 -0
- package/src/components/ConfirmModal.tsx +41 -0
- package/src/components/EditModal.tsx +69 -0
- package/src/components/MarkdownView.tsx +224 -0
- package/src/components/PreviewModal.tsx +46 -0
- package/src/components/StatusBar.tsx +32 -0
- package/src/index.tsx +35 -0
- package/src/store.ts +236 -0
- package/src/types.ts +32 -0
- package/tsconfig.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# TUI Trello Kanban Board
|
|
2
|
+
|
|
3
|
+
ターミナル上で動作するカンバンボードです。キーボードだけで操作できます。
|
|
4
|
+
|
|
5
|
+
## インストール
|
|
6
|
+
|
|
7
|
+
[GitHub Releases](../../releases/latest) からお使いのプラットフォームのファイルをダウンロードしてください。
|
|
8
|
+
|
|
9
|
+
> **注意**: プライベートリポジトリのため、ダウンロードには GitHub の認証が必要です。
|
|
10
|
+
> `gh` CLI([GitHub CLI](https://cli.github.com/))を使うと便利です。
|
|
11
|
+
|
|
12
|
+
### Linux (x64)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
gh release download --repo OWNER/REPO --pattern "tkb-linux-x64.tar.gz"
|
|
16
|
+
tar xzf tkb-linux-x64.tar.gz
|
|
17
|
+
chmod +x tkb
|
|
18
|
+
./tkb
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### macOS (Apple Silicon / M1以降)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
gh release download --repo OWNER/REPO --pattern "tkb-darwin-arm64.tar.gz"
|
|
25
|
+
tar xzf tkb-darwin-arm64.tar.gz
|
|
26
|
+
chmod +x tkb
|
|
27
|
+
# セキュリティ確認をスキップ(初回のみ)
|
|
28
|
+
xattr -d com.apple.quarantine tkb
|
|
29
|
+
./tkb
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### macOS (Intel)
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
gh release download --repo OWNER/REPO --pattern "tkb-darwin-x64.tar.gz"
|
|
36
|
+
tar xzf tkb-darwin-x64.tar.gz
|
|
37
|
+
chmod +x tkb
|
|
38
|
+
xattr -d com.apple.quarantine tkb
|
|
39
|
+
./tkb
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Windows (x64)
|
|
43
|
+
|
|
44
|
+
```powershell
|
|
45
|
+
gh release download --repo OWNER/REPO --pattern "tkb-windows-x64.zip"
|
|
46
|
+
Expand-Archive tkb-windows-x64.zip .
|
|
47
|
+
.\tkb.exe
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 使い方
|
|
53
|
+
|
|
54
|
+
`.tkb` ファイルがあるディレクトリで実行します。ファイルが存在しない場合は自動で作成されます。
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# カレントディレクトリの .tkb を開く
|
|
58
|
+
./tkb
|
|
59
|
+
|
|
60
|
+
# ファイルを指定して開く
|
|
61
|
+
./tkb path/to/board.tkb
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## キーボードショートカット
|
|
65
|
+
|
|
66
|
+
### ボード操作
|
|
67
|
+
|
|
68
|
+
| キー | 動作 |
|
|
69
|
+
|------|------|
|
|
70
|
+
| `←` / `→` | 列を移動 |
|
|
71
|
+
| `↑` / `↓` / `k` / `j` | カードを移動 |
|
|
72
|
+
| `Enter` | カードをプレビュー |
|
|
73
|
+
| `n` | カードを新規追加 |
|
|
74
|
+
| `e` | カードを編集 |
|
|
75
|
+
| `d` | カードを削除 |
|
|
76
|
+
| `Shift+←/→` | カードを隣の列に移動 |
|
|
77
|
+
| `q` | 終了 |
|
|
78
|
+
|
|
79
|
+
### 列操作
|
|
80
|
+
|
|
81
|
+
| キー | 動作 |
|
|
82
|
+
|------|------|
|
|
83
|
+
| `N`(Shift+n) | 列を追加(現在の列の右隣) |
|
|
84
|
+
| `E`(Shift+e) | 列名を編集 |
|
|
85
|
+
| `D`(Shift+d) | 列を削除 |
|
|
86
|
+
| `Ctrl+←/→` | 列を左右に並び替え |
|
|
87
|
+
|
|
88
|
+
### プレビュー / 編集モード
|
|
89
|
+
|
|
90
|
+
| キー | 動作 |
|
|
91
|
+
|------|------|
|
|
92
|
+
| `Esc` | 閉じる / キャンセル |
|
|
93
|
+
| `Ctrl+S` | 保存 |
|
|
94
|
+
| `e` | プレビューから編集へ |
|
|
95
|
+
|
|
96
|
+
## カードの書き方(Markdown対応)
|
|
97
|
+
|
|
98
|
+
カードの内容は Markdown で書けます。
|
|
99
|
+
|
|
100
|
+
```markdown
|
|
101
|
+
# 見出し
|
|
102
|
+
|
|
103
|
+
**太字** / *斜体* / ~~取り消し線~~ / `コード`
|
|
104
|
+
|
|
105
|
+
- リストアイテム
|
|
106
|
+
- ネストされたアイテム
|
|
107
|
+
|
|
108
|
+
1. 順序付きリスト
|
|
109
|
+
2. 二番目
|
|
110
|
+
|
|
111
|
+
| ヘッダ1 | ヘッダ2 |
|
|
112
|
+
|---------|---------|
|
|
113
|
+
| セル1 | セル2 |
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## ファイル形式 (.tkb)
|
|
117
|
+
|
|
118
|
+
JSON 形式のファイルです。操作のたびに自動保存されます。
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{
|
|
122
|
+
"scope": "Workspace",
|
|
123
|
+
"columns": [
|
|
124
|
+
{
|
|
125
|
+
"id": "column-xxx",
|
|
126
|
+
"title": "To Do",
|
|
127
|
+
"tasksIds": ["task-yyy"]
|
|
128
|
+
}
|
|
129
|
+
],
|
|
130
|
+
"tasks": {
|
|
131
|
+
"task-yyy": {
|
|
132
|
+
"id": "task-yyy",
|
|
133
|
+
"description": "タスクの内容",
|
|
134
|
+
"columnId": "column-xxx"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 開発
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
bun install
|
|
146
|
+
bun dev
|
|
147
|
+
```
|
package/bin/tkb.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tui-kanban-board",
|
|
3
|
+
"description": "Task Management App of TUI",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"module": "src/index.tsx",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"private": false,
|
|
8
|
+
"bin": {
|
|
9
|
+
"tkb": "./bin/tkb.js"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "bun run --watch src/index.tsx",
|
|
13
|
+
"build": "bun build src/index.tsx --outdir dist --target node --format esm"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/bun": "latest"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"typescript": "^5"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@opentui/core": "^0.1.50",
|
|
23
|
+
"@opentui/react": "^0.1.50",
|
|
24
|
+
"react": "^19.2.4"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import { useKeyboard, useRenderer } from "@opentui/react";
|
|
3
|
+
import type { BoardType, Mode } from "./types.ts";
|
|
4
|
+
import {
|
|
5
|
+
saveBoard,
|
|
6
|
+
createTask,
|
|
7
|
+
addTask,
|
|
8
|
+
updateTask,
|
|
9
|
+
deleteTask,
|
|
10
|
+
moveTask,
|
|
11
|
+
createColumn,
|
|
12
|
+
addColumn,
|
|
13
|
+
updateColumn,
|
|
14
|
+
deleteColumn,
|
|
15
|
+
moveColumn,
|
|
16
|
+
} from "./store.ts";
|
|
17
|
+
import { Board } from "./components/Board.tsx";
|
|
18
|
+
import { EditModal } from "./components/EditModal.tsx";
|
|
19
|
+
import { ConfirmModal } from "./components/ConfirmModal.tsx";
|
|
20
|
+
import { PreviewModal } from "./components/PreviewModal.tsx";
|
|
21
|
+
import { ColumnEditModal } from "./components/ColumnEditModal.tsx";
|
|
22
|
+
import { StatusBar } from "./components/StatusBar.tsx";
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
filePath: string;
|
|
26
|
+
initialBoard: BoardType;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* アプリケーションのルートコンポーネント
|
|
31
|
+
* @param filePath - .tkbファイルのパス
|
|
32
|
+
* @param initialBoard - 初期ボードデータ
|
|
33
|
+
*/
|
|
34
|
+
export function App({ filePath, initialBoard }: Props) {
|
|
35
|
+
const renderer = useRenderer();
|
|
36
|
+
const [board, setBoard] = useState(initialBoard);
|
|
37
|
+
const [colIdx, setColIdx] = useState(0);
|
|
38
|
+
const [cardIdx, setCardIdx] = useState(0);
|
|
39
|
+
const [mode, setMode] = useState<Mode>("board");
|
|
40
|
+
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
|
41
|
+
const [editingColId, setEditingColId] = useState<string | null>(null);
|
|
42
|
+
const [statusMsg, setStatusMsg] = useState("");
|
|
43
|
+
|
|
44
|
+
// キーボードハンドラ内で常に最新のboard値を参照するためのref
|
|
45
|
+
const boardRef = useRef(board);
|
|
46
|
+
boardRef.current = board;
|
|
47
|
+
|
|
48
|
+
// EditModalのtextarea値をre-renderなしで追跡するref
|
|
49
|
+
const editValueRef = useRef("");
|
|
50
|
+
|
|
51
|
+
// 初回マウント時はスキップし、board変更時に自動保存
|
|
52
|
+
const isMountedRef = useRef(false);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!isMountedRef.current) {
|
|
55
|
+
isMountedRef.current = true;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
saveBoard(filePath, board);
|
|
60
|
+
showStatus("自動保存しました");
|
|
61
|
+
} catch (e) {
|
|
62
|
+
showStatus(`保存エラー: ${String(e).slice(0, 40)}`);
|
|
63
|
+
}
|
|
64
|
+
}, [board]);
|
|
65
|
+
|
|
66
|
+
const activeColumns = board.columns.filter((c) => !c.archived);
|
|
67
|
+
const safeColIdx = Math.min(colIdx, Math.max(0, activeColumns.length - 1));
|
|
68
|
+
const activeCol = activeColumns[safeColIdx];
|
|
69
|
+
const cardIds = activeCol?.tasksIds ?? [];
|
|
70
|
+
const safeCardIdx = cardIds.length > 0 ? Math.min(cardIdx, cardIds.length - 1) : -1;
|
|
71
|
+
const selectedTaskId = safeCardIdx >= 0 ? (cardIds[safeCardIdx] ?? null) : null;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* ステータスバーにメッセージを2秒間表示する
|
|
75
|
+
* @param msg - 表示するメッセージ
|
|
76
|
+
*/
|
|
77
|
+
const showStatus = (msg: string) => {
|
|
78
|
+
setStatusMsg(msg);
|
|
79
|
+
setTimeout(() => setStatusMsg(""), 2000);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 編集・新規作成モーダルを閉じてボードモードに戻る
|
|
84
|
+
*/
|
|
85
|
+
const closeModal = () => {
|
|
86
|
+
setMode("board");
|
|
87
|
+
setEditingTaskId(null);
|
|
88
|
+
setEditingColId(null);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* モーダルからのテキスト変更を受け取るハンドラ
|
|
93
|
+
* @param value - textareaの現在値
|
|
94
|
+
*/
|
|
95
|
+
const handleEditValueChange = (value: string) => {
|
|
96
|
+
editValueRef.current = value;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* カードの保存処理(新規作成・編集共通)
|
|
101
|
+
* @param description - カードの内容
|
|
102
|
+
*/
|
|
103
|
+
const handleSave = (description: string) => {
|
|
104
|
+
const trimmed = description.trim();
|
|
105
|
+
if (mode === "new" && activeCol) {
|
|
106
|
+
if (trimmed) {
|
|
107
|
+
const task = createTask(trimmed, activeCol.id);
|
|
108
|
+
setBoard((b) => addTask(b, task));
|
|
109
|
+
setCardIdx(cardIds.length);
|
|
110
|
+
}
|
|
111
|
+
} else if (mode === "edit" && editingTaskId) {
|
|
112
|
+
setBoard((b) => updateTask(b, editingTaskId, trimmed || description));
|
|
113
|
+
}
|
|
114
|
+
closeModal();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 列の保存処理(新規作成・編集共通)
|
|
119
|
+
* @param title - 列名
|
|
120
|
+
*/
|
|
121
|
+
const handleColumnSave = (title: string) => {
|
|
122
|
+
const trimmed = title.trim();
|
|
123
|
+
if (!trimmed) {
|
|
124
|
+
closeModal();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (mode === "new-column") {
|
|
128
|
+
const col = createColumn(trimmed);
|
|
129
|
+
setBoard((b) => addColumn(b, col, safeColIdx));
|
|
130
|
+
setColIdx(safeColIdx + 1);
|
|
131
|
+
setCardIdx(0);
|
|
132
|
+
} else if (mode === "edit-column" && editingColId) {
|
|
133
|
+
setBoard((b) => updateColumn(b, editingColId, trimmed));
|
|
134
|
+
}
|
|
135
|
+
closeModal();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// キーボードハンドラは1つに集約(OpenTUIでは複数のuseKeyboardが競合するため)
|
|
139
|
+
useKeyboard((key) => {
|
|
140
|
+
if (mode === "confirm-delete") {
|
|
141
|
+
if (key.name === "y") {
|
|
142
|
+
if (selectedTaskId) {
|
|
143
|
+
setBoard((b) => deleteTask(b, selectedTaskId));
|
|
144
|
+
setCardIdx((i) => Math.max(0, i - 1));
|
|
145
|
+
}
|
|
146
|
+
setMode("board");
|
|
147
|
+
} else if (key.name === "n" || key.name === "escape") {
|
|
148
|
+
setMode("board");
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (mode === "confirm-delete-column") {
|
|
154
|
+
if (key.name === "y") {
|
|
155
|
+
if (activeCol) {
|
|
156
|
+
setBoard((b) => deleteColumn(b, activeCol.id));
|
|
157
|
+
setColIdx((i) => Math.max(0, i - 1));
|
|
158
|
+
setCardIdx(0);
|
|
159
|
+
}
|
|
160
|
+
setMode("board");
|
|
161
|
+
} else if (key.name === "n" || key.name === "escape") {
|
|
162
|
+
setMode("board");
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (mode === "preview") {
|
|
168
|
+
if (key.name === "escape" || key.name === "q") {
|
|
169
|
+
setMode("board");
|
|
170
|
+
} else if (key.name === "e") {
|
|
171
|
+
if (selectedTaskId) {
|
|
172
|
+
editValueRef.current = board.tasks[selectedTaskId]?.description ?? "";
|
|
173
|
+
setEditingTaskId(selectedTaskId);
|
|
174
|
+
setMode("edit");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (mode === "edit" || mode === "new") {
|
|
181
|
+
if (key.name === "escape") {
|
|
182
|
+
closeModal();
|
|
183
|
+
} else if (key.ctrl && key.name === "s") {
|
|
184
|
+
handleSave(editValueRef.current);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (mode === "new-column" || mode === "edit-column") {
|
|
190
|
+
if (key.name === "escape") {
|
|
191
|
+
closeModal();
|
|
192
|
+
} else if (key.ctrl && key.name === "s") {
|
|
193
|
+
handleColumnSave(editValueRef.current);
|
|
194
|
+
}
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ボード操作
|
|
199
|
+
switch (key.name) {
|
|
200
|
+
case "left":
|
|
201
|
+
if (key.ctrl) {
|
|
202
|
+
// Ctrl+← で列を左に移動
|
|
203
|
+
if (activeCol && safeColIdx > 0) {
|
|
204
|
+
const targetGlobalIdx = board.columns.findIndex((c) => c.id === activeCol.id);
|
|
205
|
+
const prevActiveCol = activeColumns[safeColIdx - 1];
|
|
206
|
+
if (prevActiveCol) {
|
|
207
|
+
setBoard((b) => moveColumn(b, activeCol.id, "left"));
|
|
208
|
+
// アーカイブ列が間にある場合もあるため、移動後も同じ列を選択する
|
|
209
|
+
const prevGlobalIdx = board.columns.findIndex((c) => c.id === prevActiveCol.id);
|
|
210
|
+
if (prevGlobalIdx < targetGlobalIdx) {
|
|
211
|
+
setColIdx((i) => i - 1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
} else if (key.shift) {
|
|
216
|
+
if (selectedTaskId && safeColIdx > 0) {
|
|
217
|
+
const targetCol = activeColumns[safeColIdx - 1]!;
|
|
218
|
+
setBoard((b) => moveTask(b, selectedTaskId, targetCol.id));
|
|
219
|
+
setColIdx((i) => i - 1);
|
|
220
|
+
setCardIdx(0);
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
setColIdx((i) => Math.max(0, i - 1));
|
|
224
|
+
setCardIdx(0);
|
|
225
|
+
}
|
|
226
|
+
break;
|
|
227
|
+
|
|
228
|
+
case "right":
|
|
229
|
+
if (key.ctrl) {
|
|
230
|
+
// Ctrl+→ で列を右に移動
|
|
231
|
+
if (activeCol && safeColIdx < activeColumns.length - 1) {
|
|
232
|
+
const targetGlobalIdx = board.columns.findIndex((c) => c.id === activeCol.id);
|
|
233
|
+
const nextActiveCol = activeColumns[safeColIdx + 1];
|
|
234
|
+
if (nextActiveCol) {
|
|
235
|
+
setBoard((b) => moveColumn(b, activeCol.id, "right"));
|
|
236
|
+
const nextGlobalIdx = board.columns.findIndex((c) => c.id === nextActiveCol.id);
|
|
237
|
+
if (nextGlobalIdx > targetGlobalIdx) {
|
|
238
|
+
setColIdx((i) => i + 1);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} else if (key.shift) {
|
|
243
|
+
if (selectedTaskId && safeColIdx < activeColumns.length - 1) {
|
|
244
|
+
const targetCol = activeColumns[safeColIdx + 1]!;
|
|
245
|
+
setBoard((b) => moveTask(b, selectedTaskId, targetCol.id));
|
|
246
|
+
setColIdx((i) => i + 1);
|
|
247
|
+
setCardIdx(0);
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
setColIdx((i) => Math.min(activeColumns.length - 1, i + 1));
|
|
251
|
+
setCardIdx(0);
|
|
252
|
+
}
|
|
253
|
+
break;
|
|
254
|
+
|
|
255
|
+
case "up":
|
|
256
|
+
case "k":
|
|
257
|
+
setCardIdx((i) => Math.max(0, i - 1));
|
|
258
|
+
break;
|
|
259
|
+
|
|
260
|
+
case "down":
|
|
261
|
+
case "j":
|
|
262
|
+
if (cardIds.length > 0) {
|
|
263
|
+
setCardIdx((i) => Math.min(cardIds.length - 1, i + 1));
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
|
|
267
|
+
case "n":
|
|
268
|
+
if (key.shift) {
|
|
269
|
+
// Shift+N: 列を追加(現在の列の右隣に挿入)
|
|
270
|
+
editValueRef.current = "";
|
|
271
|
+
setEditingColId(null);
|
|
272
|
+
setMode("new-column");
|
|
273
|
+
} else if (activeCol) {
|
|
274
|
+
// n: カードを追加
|
|
275
|
+
editValueRef.current = "";
|
|
276
|
+
setEditingTaskId(null);
|
|
277
|
+
setMode("new");
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
280
|
+
|
|
281
|
+
case "return":
|
|
282
|
+
if (selectedTaskId) {
|
|
283
|
+
setMode("preview");
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
286
|
+
|
|
287
|
+
case "e":
|
|
288
|
+
if (key.shift) {
|
|
289
|
+
// Shift+E: 列名を編集
|
|
290
|
+
if (activeCol) {
|
|
291
|
+
editValueRef.current = activeCol.title;
|
|
292
|
+
setEditingColId(activeCol.id);
|
|
293
|
+
setMode("edit-column");
|
|
294
|
+
}
|
|
295
|
+
} else if (selectedTaskId) {
|
|
296
|
+
// e: カードを編集
|
|
297
|
+
editValueRef.current = board.tasks[selectedTaskId]?.description ?? "";
|
|
298
|
+
setEditingTaskId(selectedTaskId);
|
|
299
|
+
setMode("edit");
|
|
300
|
+
}
|
|
301
|
+
break;
|
|
302
|
+
|
|
303
|
+
case "d":
|
|
304
|
+
if (key.shift) {
|
|
305
|
+
// Shift+D: 列を削除
|
|
306
|
+
if (activeCol) setMode("confirm-delete-column");
|
|
307
|
+
} else if (selectedTaskId) {
|
|
308
|
+
// d: カードを削除
|
|
309
|
+
setMode("confirm-delete");
|
|
310
|
+
}
|
|
311
|
+
break;
|
|
312
|
+
|
|
313
|
+
case "q":
|
|
314
|
+
renderer.destroy();
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const editingTask = editingTaskId ? board.tasks[editingTaskId] : undefined;
|
|
320
|
+
const deletingTaskDesc =
|
|
321
|
+
selectedTaskId
|
|
322
|
+
? (board.tasks[selectedTaskId]?.description.split("\n")[0] ?? "このカード")
|
|
323
|
+
: "このカード";
|
|
324
|
+
|
|
325
|
+
/** 列削除確認メッセージ(カードが存在する場合は枚数を警告) */
|
|
326
|
+
const deletingColMessage = (() => {
|
|
327
|
+
if (!activeCol) return "この列を削除しますか?";
|
|
328
|
+
const cardCount = activeCol.tasksIds.length;
|
|
329
|
+
if (cardCount > 0) {
|
|
330
|
+
return `「${activeCol.title}」(カード ${cardCount} 枚)を削除しますか? カードも全て削除されます。`;
|
|
331
|
+
}
|
|
332
|
+
return `「${activeCol.title}」を削除しますか?`;
|
|
333
|
+
})();
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<box
|
|
337
|
+
flexDirection="column"
|
|
338
|
+
width="100%"
|
|
339
|
+
height="100%"
|
|
340
|
+
backgroundColor="#0d1117"
|
|
341
|
+
position="relative"
|
|
342
|
+
>
|
|
343
|
+
<box paddingX={2} paddingTop={1} alignSelf="center">
|
|
344
|
+
<text color="#58a6ff" bold>Trello Kanban Board</text>
|
|
345
|
+
</box>
|
|
346
|
+
<box position="absolute" top={1} right={2}>
|
|
347
|
+
<text color="#484f58">© 2026 Yuki-Kikuya</text>
|
|
348
|
+
</box>
|
|
349
|
+
<Board board={board} colIdx={safeColIdx} cardIdx={safeCardIdx} />
|
|
350
|
+
<StatusBar filePath={filePath} statusMsg={statusMsg} />
|
|
351
|
+
|
|
352
|
+
{(mode === "edit" || mode === "new") && (
|
|
353
|
+
<EditModal
|
|
354
|
+
key={editingTaskId ?? "__new__"}
|
|
355
|
+
initialValue={editingTask?.description ?? ""}
|
|
356
|
+
title={mode === "new" ? "新規カード" : "カードを編集"}
|
|
357
|
+
onValueChange={handleEditValueChange}
|
|
358
|
+
/>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{mode === "preview" && selectedTaskId && (
|
|
362
|
+
<PreviewModal
|
|
363
|
+
content={board.tasks[selectedTaskId]?.description ?? ""}
|
|
364
|
+
title={board.tasks[selectedTaskId]?.description.split("\n")[0] ?? ""}
|
|
365
|
+
/>
|
|
366
|
+
)}
|
|
367
|
+
|
|
368
|
+
{mode === "confirm-delete" && (
|
|
369
|
+
<ConfirmModal
|
|
370
|
+
message={`「${deletingTaskDesc}」を削除しますか?`}
|
|
371
|
+
/>
|
|
372
|
+
)}
|
|
373
|
+
|
|
374
|
+
{(mode === "new-column" || mode === "edit-column") && (
|
|
375
|
+
<ColumnEditModal
|
|
376
|
+
key={editingColId ?? "__new-column__"}
|
|
377
|
+
initialValue={mode === "edit-column" ? (activeColumns.find((c) => c.id === editingColId)?.title ?? "") : ""}
|
|
378
|
+
title={mode === "new-column" ? "列を追加" : "列を編集"}
|
|
379
|
+
onValueChange={handleEditValueChange}
|
|
380
|
+
/>
|
|
381
|
+
)}
|
|
382
|
+
|
|
383
|
+
{mode === "confirm-delete-column" && (
|
|
384
|
+
<ConfirmModal message={deletingColMessage} />
|
|
385
|
+
)}
|
|
386
|
+
</box>
|
|
387
|
+
);
|
|
388
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { BoardType, TaskType } from "../types.ts";
|
|
2
|
+
import { Column } from "./Column.tsx";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
board: BoardType;
|
|
6
|
+
colIdx: number;
|
|
7
|
+
cardIdx: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* カンバンボード全体のレイアウトコンポーネント
|
|
12
|
+
* @param board - ボードデータ
|
|
13
|
+
* @param colIdx - フォーカス中の列インデックス
|
|
14
|
+
* @param cardIdx - フォーカス中のカードインデックス
|
|
15
|
+
*/
|
|
16
|
+
export function Board({ board, colIdx, cardIdx }: Props) {
|
|
17
|
+
const activeColumns = board.columns.filter((c) => !c.archived);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<box flexDirection="row" flexGrow={1} gap={1} paddingX={1} paddingTop={1}>
|
|
21
|
+
{activeColumns.map((col, i) => {
|
|
22
|
+
const tasks = col.tasksIds
|
|
23
|
+
.map((id) => board.tasks[id])
|
|
24
|
+
.filter((t): t is TaskType => t !== undefined);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Column
|
|
28
|
+
key={col.id}
|
|
29
|
+
column={col}
|
|
30
|
+
tasks={tasks}
|
|
31
|
+
isFocused={i === colIdx}
|
|
32
|
+
focusedCardIdx={i === colIdx ? cardIdx : -1}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
})}
|
|
36
|
+
</box>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { TaskType } from "../types.ts";
|
|
2
|
+
import { MarkdownView } from "./MarkdownView.tsx";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
task: TaskType;
|
|
6
|
+
isSelected: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* カンバンボードのカードコンポーネント
|
|
11
|
+
* @param task - 表示するタスクデータ
|
|
12
|
+
* @param isSelected - このカードが選択中か
|
|
13
|
+
*/
|
|
14
|
+
export function Card({ task, isSelected }: Props) {
|
|
15
|
+
return (
|
|
16
|
+
<box
|
|
17
|
+
border
|
|
18
|
+
borderStyle="single"
|
|
19
|
+
borderColor={isSelected ? "#00bfff" : "#3d444d"}
|
|
20
|
+
backgroundColor={isSelected ? "#0d2137" : "#161b22"}
|
|
21
|
+
paddingX={1}
|
|
22
|
+
flexDirection="column"
|
|
23
|
+
>
|
|
24
|
+
<MarkdownView
|
|
25
|
+
content={task.description}
|
|
26
|
+
textFg={isSelected ? "#e6edf3" : "#8b949e"}
|
|
27
|
+
/>
|
|
28
|
+
</box>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useRef, useEffect } from "react";
|
|
2
|
+
import { ScrollBoxRenderable } from "@opentui/core";
|
|
3
|
+
import type { ColumnType, TaskType } from "../types.ts";
|
|
4
|
+
import { Card } from "./Card.tsx";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
column: ColumnType;
|
|
8
|
+
tasks: TaskType[];
|
|
9
|
+
isFocused: boolean;
|
|
10
|
+
focusedCardIdx: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* カンバンボードの列コンポーネント
|
|
15
|
+
* @param column - 列のデータ
|
|
16
|
+
* @param tasks - 列内のタスク一覧
|
|
17
|
+
* @param isFocused - この列にフォーカスが当たっているか
|
|
18
|
+
* @param focusedCardIdx - フォーカス中のカードインデックス(-1はなし)
|
|
19
|
+
*/
|
|
20
|
+
export function Column({ column, tasks, isFocused, focusedCardIdx }: Props) {
|
|
21
|
+
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (isFocused && focusedCardIdx >= 0) {
|
|
25
|
+
const task = tasks[focusedCardIdx];
|
|
26
|
+
if (task) {
|
|
27
|
+
scrollRef.current?.scrollChildIntoView(task.id);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}, [focusedCardIdx, isFocused, tasks]);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<box
|
|
34
|
+
flexDirection="column"
|
|
35
|
+
flexGrow={1}
|
|
36
|
+
border
|
|
37
|
+
borderStyle="rounded"
|
|
38
|
+
borderColor={isFocused ? "#00bfff" : "#3d444d"}
|
|
39
|
+
backgroundColor="#0d1117"
|
|
40
|
+
>
|
|
41
|
+
<box
|
|
42
|
+
border={["bottom"]}
|
|
43
|
+
borderColor={isFocused ? "#00bfff" : "#3d444d"}
|
|
44
|
+
paddingX={1}
|
|
45
|
+
flexDirection="row"
|
|
46
|
+
justifyContent="space-between"
|
|
47
|
+
>
|
|
48
|
+
<text fg={isFocused ? "#e6edf3" : "#8b949e"}>
|
|
49
|
+
<strong>{column.title}</strong>
|
|
50
|
+
</text>
|
|
51
|
+
<text fg="#484f58">{tasks.length}</text>
|
|
52
|
+
</box>
|
|
53
|
+
|
|
54
|
+
<scrollbox ref={scrollRef} flexGrow={1}>
|
|
55
|
+
<box flexDirection="column" gap={1} padding={1}>
|
|
56
|
+
{tasks.map((task, i) => (
|
|
57
|
+
<box key={task.id} id={task.id}>
|
|
58
|
+
<Card
|
|
59
|
+
task={task}
|
|
60
|
+
isSelected={isFocused && i === focusedCardIdx}
|
|
61
|
+
/>
|
|
62
|
+
</box>
|
|
63
|
+
))}
|
|
64
|
+
{tasks.length === 0 && (
|
|
65
|
+
<text fg="#3d444d"> No cards yet</text>
|
|
66
|
+
)}
|
|
67
|
+
</box>
|
|
68
|
+
</scrollbox>
|
|
69
|
+
</box>
|
|
70
|
+
);
|
|
71
|
+
}
|