tokenbill-mcp 1.0.0 → 1.1.1
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 +131 -114
- package/package.json +9 -4
- package/uploader/duet/board.html +704 -0
- package/uploader/duet/core.js +507 -0
- package/uploader/duet/server.js +198 -0
- package/uploader/duet/web.js +164 -0
- package/uploader/index.js +304 -291
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Duet</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* 웹폰트를 불러오지 않는다. 네트워크를 쓰지 않는 것이 이 도구의 원칙이라
|
|
9
|
+
설치돼 있는 글꼴만 쓴다 (Pretendard가 있으면 그것, 없으면 맑은 고딕). */
|
|
10
|
+
:root{
|
|
11
|
+
--bg:#F3F4F7; --panel:#fff; --panel-2:#F8F9FB; --line:#DCDFE6; --line-2:#EBEDF2;
|
|
12
|
+
--ink:#171A21; --ink-2:#4C5261; --ink-3:#8A90A0;
|
|
13
|
+
--accent:#1F4E8C; --accent-ink:#fff;
|
|
14
|
+
--진행중:#2563B8; --진행중-bg:#E3ECFA;
|
|
15
|
+
--대기:#B7791F; --대기-bg:#FBF0DA;
|
|
16
|
+
--중지:#6B7280; --중지-bg:#ECEEF2;
|
|
17
|
+
--확인필요:#C2410C; --확인필요-bg:#FEE9DC;
|
|
18
|
+
--보류:#6B7280; --보류-bg:#ECEEF2;
|
|
19
|
+
--완료:#1E7F4F; --완료-bg:#DDF2E6;
|
|
20
|
+
--취소:#B42318; --취소-bg:#FBE9E7;
|
|
21
|
+
--shadow:0 1px 2px rgba(23,26,33,.06), 0 4px 14px rgba(23,26,33,.06);
|
|
22
|
+
--radius:10px;
|
|
23
|
+
}
|
|
24
|
+
@media (prefers-color-scheme: dark){
|
|
25
|
+
:root{
|
|
26
|
+
--bg:#13161B; --panel:#1B1F26; --panel-2:#20252D; --line:#2E343E; --line-2:#272C35;
|
|
27
|
+
--ink:#ECEEF2; --ink-2:#AEB4C0; --ink-3:#737A88;
|
|
28
|
+
--accent:#7FA6E8; --accent-ink:#0F172A;
|
|
29
|
+
--진행중:#7FA6E8; --진행중-bg:#1C2A40;
|
|
30
|
+
--대기:#E2B25C; --대기-bg:#3A2E15;
|
|
31
|
+
--중지:#A0A6B3; --중지-bg:#2A2F38;
|
|
32
|
+
--확인필요:#F59E6B; --확인필요-bg:#3D2314;
|
|
33
|
+
--보류:#A0A6B3; --보류-bg:#2A2F38;
|
|
34
|
+
--완료:#5FC98F; --완료-bg:#16321F;
|
|
35
|
+
--취소:#F08A80; --취소-bg:#3A1E1B;
|
|
36
|
+
--shadow:0 1px 2px rgba(0,0,0,.4), 0 4px 14px rgba(0,0,0,.35);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
*{box-sizing:border-box}
|
|
40
|
+
[hidden]{display:none!important}
|
|
41
|
+
body{
|
|
42
|
+
margin:0; background:var(--bg); color:var(--ink); font-size:14px; line-height:1.5;
|
|
43
|
+
font-family:"Pretendard","Apple SD Gothic Neo","Malgun Gothic",system-ui,sans-serif;
|
|
44
|
+
}
|
|
45
|
+
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-variant-numeric:tabular-nums}
|
|
46
|
+
button,input,select,textarea{font:inherit;color:inherit}
|
|
47
|
+
button{cursor:pointer}
|
|
48
|
+
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
49
|
+
|
|
50
|
+
.wrap{max-width:1180px;margin:0 auto;padding:28px 24px 80px}
|
|
51
|
+
header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:18px}
|
|
52
|
+
h1{margin:0;font-size:22px;font-weight:700;letter-spacing:-.01em}
|
|
53
|
+
.sub{color:var(--ink-3);font-size:12.5px;margin-top:2px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
54
|
+
.pulse{width:7px;height:7px;border-radius:50%;background:var(--취소);display:inline-block;margin-right:4px}
|
|
55
|
+
.pulse.on{background:var(--완료)}
|
|
56
|
+
|
|
57
|
+
.btn{border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:7px 12px;font-weight:500;font-size:13px}
|
|
58
|
+
.btn:hover{background:var(--panel-2)}
|
|
59
|
+
.btn.primary{background:var(--accent);color:var(--accent-ink);border-color:var(--accent)}
|
|
60
|
+
.btn.primary:hover{filter:brightness(1.08)}
|
|
61
|
+
.btn.ghost{border-color:transparent;background:transparent;color:var(--ink-3);padding:3px 8px;font-size:11.5px}
|
|
62
|
+
.btn.ghost:hover{background:var(--panel-2);color:var(--ink)}
|
|
63
|
+
.btn.small{padding:4px 9px;font-size:12px}
|
|
64
|
+
.btn[aria-pressed="true"]{background:var(--ink);color:var(--bg);border-color:var(--ink)}
|
|
65
|
+
.acts{display:flex;gap:2px;align-items:center}
|
|
66
|
+
|
|
67
|
+
.toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px}
|
|
68
|
+
.chips{display:flex;gap:6px;flex-wrap:wrap}
|
|
69
|
+
.chip{border:1px solid var(--line);background:var(--panel);border-radius:999px;padding:5px 12px;font-size:12.5px;color:var(--ink-2);
|
|
70
|
+
display:inline-flex;gap:6px;align-items:center}
|
|
71
|
+
.chip .dot{width:7px;height:7px;border-radius:50%;display:inline-block}
|
|
72
|
+
.chip .n{color:var(--ink-3)}
|
|
73
|
+
.chip[aria-pressed="true"]{background:var(--ink);color:var(--bg);border-color:var(--ink)}
|
|
74
|
+
.chip[aria-pressed="true"] .n{color:inherit;opacity:.7}
|
|
75
|
+
.spacer{flex:1}
|
|
76
|
+
.search{border:1px solid var(--line);background:var(--panel);border-radius:8px;padding:6px 10px;min-width:220px}
|
|
77
|
+
|
|
78
|
+
.grid{display:flex;flex-direction:column;gap:14px}
|
|
79
|
+
.title{font-weight:600;font-size:15px;line-height:1.35;flex:1;min-width:0;overflow-wrap:break-word;word-break:keep-all;border-radius:4px;padding:0 3px;margin:0 -3px}
|
|
80
|
+
.title[contenteditable]:hover{background:var(--panel-2)}
|
|
81
|
+
.tid{color:var(--ink-3);font-weight:500;font-size:13px;padding-top:1px;flex:none}
|
|
82
|
+
.desc{padding:0 16px 10px 30px}
|
|
83
|
+
.desc div{color:var(--ink-2);font-size:12.5px;white-space:pre-wrap;border-radius:4px;padding:0 3px;margin:0 -3px}
|
|
84
|
+
.desc div[contenteditable]:hover{background:var(--panel-2)}
|
|
85
|
+
.desc div:empty::before{content:"No description";color:var(--ink-3)}
|
|
86
|
+
.warn{padding:0 16px 12px 30px;color:var(--확인필요);font-size:12.5px}
|
|
87
|
+
|
|
88
|
+
.sel{border:1px solid var(--line);background:var(--panel);border-radius:6px;padding:4px 24px 4px 8px;font-size:12.5px;
|
|
89
|
+
font-weight:500;appearance:none;-webkit-appearance:none;
|
|
90
|
+
background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);
|
|
91
|
+
background-position:calc(100% - 12px) 55%, calc(100% - 8px) 55%;background-size:4px 4px;background-repeat:no-repeat}
|
|
92
|
+
.sel.s-진행중{color:var(--진행중);background-color:var(--진행중-bg);border-color:transparent}
|
|
93
|
+
.sel.s-대기{color:var(--대기);background-color:var(--대기-bg);border-color:transparent}
|
|
94
|
+
.sel.s-중지{color:var(--중지);background-color:var(--중지-bg);border-color:transparent}
|
|
95
|
+
.sel.s-확인필요{color:var(--확인필요);background-color:var(--확인필요-bg);border-color:transparent}
|
|
96
|
+
.sel.s-보류{color:var(--보류);background-color:var(--보류-bg);border-color:transparent}
|
|
97
|
+
.sel.s-완료{color:var(--완료);background-color:var(--완료-bg);border-color:transparent}
|
|
98
|
+
.sel.s-취소{color:var(--취소);background-color:var(--취소-bg);border-color:transparent}
|
|
99
|
+
|
|
100
|
+
.sessions{border-top:1px solid var(--line-2)}
|
|
101
|
+
.sess{border-bottom:1px solid var(--line-2)}
|
|
102
|
+
.sess:last-child{border-bottom:0}
|
|
103
|
+
.sess summary{display:grid;grid-template-columns:18px 1fr auto auto;gap:8px;align-items:center;padding:8px 12px 8px 16px;list-style:none}
|
|
104
|
+
.sess summary::-webkit-details-marker{display:none}
|
|
105
|
+
.sess summary:hover{background:var(--panel-2)}
|
|
106
|
+
.sd{width:8px;height:8px;border-radius:50%;justify-self:center;background:var(--중지)}
|
|
107
|
+
.sess.s-진행중 .sd{background:var(--진행중);box-shadow:0 0 0 3px var(--진행중-bg)}
|
|
108
|
+
.sess.s-완료 .sd{background:var(--완료)}
|
|
109
|
+
.nm{min-width:0;display:flex;flex-direction:column;line-height:1.3}
|
|
110
|
+
.nm b{font-weight:500;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
111
|
+
.nm span{font-size:11px;color:var(--ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
112
|
+
.locked{font-size:10px;color:var(--ink-3)}
|
|
113
|
+
.sess.s-완료 .nm b{color:var(--ink-3)}
|
|
114
|
+
.tag{font-size:11px;padding:1px 7px;border-radius:999px;white-space:nowrap}
|
|
115
|
+
.tag.s-진행중{color:var(--진행중);background:var(--진행중-bg)}
|
|
116
|
+
.tag.s-완료{color:var(--완료);background:var(--완료-bg)}
|
|
117
|
+
.tag.s-중지{color:var(--중지);background:var(--중지-bg)}
|
|
118
|
+
.tag.s-확인필요{color:var(--확인필요);background:var(--확인필요-bg)}
|
|
119
|
+
.tag.s-대기{color:var(--대기);background:var(--대기-bg)}
|
|
120
|
+
.tag.s-보류{color:var(--보류);background:var(--보류-bg)}
|
|
121
|
+
.tag.s-취소{color:var(--취소);background:var(--취소-bg)}
|
|
122
|
+
.log{margin:0;padding:0 14px 12px 38px;list-style:none}
|
|
123
|
+
.log li{display:flex;gap:8px;font-size:12.5px;color:var(--ink-2);margin-bottom:3px}
|
|
124
|
+
.log time{color:var(--ink-3);font-size:11.5px;flex:none;padding-top:1px}
|
|
125
|
+
.log .none{color:var(--ink-3)}
|
|
126
|
+
.foot{display:flex;justify-content:space-between;gap:10px;align-items:center;padding:7px 14px 7px 16px;
|
|
127
|
+
border-top:1px solid var(--line-2);font-size:11.5px;color:var(--ink-3)}
|
|
128
|
+
.foot .mono{flex:none}
|
|
129
|
+
.foot .acts{flex:none;margin-left:auto}
|
|
130
|
+
.empty{text-align:center;padding:60px 20px;color:var(--ink-3);border:1px dashed var(--line);border-radius:var(--radius)}
|
|
131
|
+
|
|
132
|
+
dialog{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:12px;padding:0;width:min(520px,92vw);box-shadow:var(--shadow)}
|
|
133
|
+
dialog::backdrop{background:rgba(10,12,16,.45)}
|
|
134
|
+
dialog form{padding:20px}
|
|
135
|
+
dialog h2{margin:0 0 14px;font-size:16px}
|
|
136
|
+
dialog .note{margin:-4px 0 12px;font-size:12px;color:var(--ink-3)}
|
|
137
|
+
.field{display:flex;flex-direction:column;gap:4px;margin-bottom:12px}
|
|
138
|
+
.field label{font-size:12px;color:var(--ink-2);font-weight:500}
|
|
139
|
+
.field input,.field textarea{border:1px solid var(--line);background:var(--panel-2);border-radius:8px;padding:8px 10px}
|
|
140
|
+
.field textarea{min-height:80px;resize:vertical}
|
|
141
|
+
.actions{display:flex;justify-content:flex-end;gap:8px}
|
|
142
|
+
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--ink);color:var(--bg);
|
|
143
|
+
padding:8px 14px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;transition:opacity .2s}
|
|
144
|
+
.toast.show{opacity:1}
|
|
145
|
+
@media (max-width:640px){.wrap{padding:18px 14px 60px}}
|
|
146
|
+
@media (prefers-reduced-motion: reduce){*{transition:none!important}}
|
|
147
|
+
|
|
148
|
+
/* 프로젝트 상자 */
|
|
149
|
+
.pcard{background:var(--panel);border:1px solid var(--line-2);border-radius:var(--radius);box-shadow:var(--shadow);overflow:hidden}
|
|
150
|
+
.phead{display:flex;align-items:center;gap:10px;padding:11px 16px;border-bottom:1px solid var(--line-2);background:var(--panel-2);cursor:pointer;user-select:none}
|
|
151
|
+
.phead:hover{background:var(--line-2)}
|
|
152
|
+
.phead h2{margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em;display:flex;align-items:center;gap:8px}
|
|
153
|
+
.phead h2 .caret{display:inline-block;font-size:10px;color:var(--ink-3);transition:transform .15s}
|
|
154
|
+
.pcard.folded .phead{border-bottom:0}
|
|
155
|
+
.pcard.folded .phead h2 .caret{transform:rotate(-90deg)}
|
|
156
|
+
.pcard.folded .trows,.pcard.folded .pempty{display:none}
|
|
157
|
+
.pc{font-size:12px;color:var(--ink-3)}
|
|
158
|
+
.pempty{padding:14px 16px;color:var(--ink-3);font-size:12.5px}
|
|
159
|
+
|
|
160
|
+
/* 타스크 줄 */
|
|
161
|
+
.trow{border-bottom:1px solid var(--line-2)}
|
|
162
|
+
.trow:last-child{border-bottom:0}
|
|
163
|
+
.trow summary{display:flex;align-items:center;gap:10px;padding:9px 16px 9px 12px;list-style:none;cursor:pointer}
|
|
164
|
+
.trow summary::-webkit-details-marker{display:none}
|
|
165
|
+
.trow summary:hover{background:var(--panel-2)}
|
|
166
|
+
.trow .stripe{width:4px;height:22px;margin:0;border-radius:2px;flex:none}
|
|
167
|
+
.trow .title{font-size:14px;line-height:1.3}
|
|
168
|
+
.trow.s-완료 .title,.trow.s-취소 .title{color:var(--ink-3)}
|
|
169
|
+
.warn-dot{color:var(--확인필요);flex:none}
|
|
170
|
+
.tbody{padding:6px 16px 10px 30px;background:var(--bg)}
|
|
171
|
+
.tbody .desc,.tbody .warn,.tbody .foot{padding-left:0;padding-right:0}
|
|
172
|
+
.tbody .sessions{background:var(--panel);border:1px solid var(--line-2);border-radius:8px;margin-bottom:8px}
|
|
173
|
+
.tbody .foot{border-top:0;padding-top:2px}
|
|
174
|
+
.lock{border:0;background:none;padding:0 2px;font-size:12px;cursor:pointer;line-height:1}
|
|
175
|
+
.lock:hover{filter:brightness(1.2)}
|
|
176
|
+
.x{border:0;background:none;color:var(--ink-3);font-size:16px;line-height:1;padding:0 6px;cursor:pointer;border-radius:4px}
|
|
177
|
+
.x:hover{color:var(--취소);background:var(--취소-bg)}
|
|
178
|
+
</style>
|
|
179
|
+
</head>
|
|
180
|
+
<body>
|
|
181
|
+
<div class="wrap">
|
|
182
|
+
<header>
|
|
183
|
+
<div>
|
|
184
|
+
<h1>Duet</h1>
|
|
185
|
+
<div class="sub"><span class="pulse" id="pulse"></span><span id="state">Connecting…</span></div>
|
|
186
|
+
</div>
|
|
187
|
+
<div style="display:flex;gap:8px">
|
|
188
|
+
<button class="btn" id="feedback" title="Report a bug or suggest something — opens a GitHub issue">Feedback</button>
|
|
189
|
+
<button class="btn" id="archive" aria-pressed="false">Archived</button>
|
|
190
|
+
<button class="btn primary" id="new">+ New Project</button>
|
|
191
|
+
</div>
|
|
192
|
+
</header>
|
|
193
|
+
|
|
194
|
+
<div class="toolbar">
|
|
195
|
+
<div class="chips" id="chips"></div>
|
|
196
|
+
<div class="spacer"></div>
|
|
197
|
+
<input class="search" id="q" type="search" placeholder="Search">
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div class="grid" id="grid"></div>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<!-- 새 프로젝트. 프로젝트는 첫 타스크와 함께 태어난다 — 빈 상자를 만들지 않는다. -->
|
|
204
|
+
<dialog id="dlgProject">
|
|
205
|
+
<form id="frmProject">
|
|
206
|
+
<h2>New Project</h2>
|
|
207
|
+
<div class="field"><label for="pName">Project name</label><input id="pName" required placeholder="e.g. nefss" autocomplete="off"></div>
|
|
208
|
+
<div class="field"><label for="pTitle">First task</label><input id="pTitle" required placeholder="e.g. pc101pm 전환"></div>
|
|
209
|
+
<div class="field"><label for="pDesc">Description (optional)</label><textarea id="pDesc" placeholder="What this task is about"></textarea></div>
|
|
210
|
+
<div class="actions">
|
|
211
|
+
<button type="button" class="btn" id="pCancel">Cancel</button>
|
|
212
|
+
<button type="submit" class="btn primary">Create</button>
|
|
213
|
+
</div>
|
|
214
|
+
</form>
|
|
215
|
+
</dialog>
|
|
216
|
+
|
|
217
|
+
<!-- 프로젝트 상자 안의 새 타스크 -->
|
|
218
|
+
<dialog id="dlgTask">
|
|
219
|
+
<form id="frmTask">
|
|
220
|
+
<h2 id="tHead">New Task</h2>
|
|
221
|
+
<div class="field"><label for="tTitle">Title</label><input id="tTitle" required placeholder="e.g. pc101pm 전환"></div>
|
|
222
|
+
<div class="field"><label for="tDesc">Description (optional)</label><textarea id="tDesc" placeholder="What this task is about"></textarea></div>
|
|
223
|
+
<div class="actions">
|
|
224
|
+
<button type="button" class="btn" id="tCancel">Cancel</button>
|
|
225
|
+
<button type="submit" class="btn primary">Create</button>
|
|
226
|
+
</div>
|
|
227
|
+
</form>
|
|
228
|
+
</dialog>
|
|
229
|
+
<!-- 피드백 = GitHub 이슈. 여기서 적은 글을 새 이슈 화면에 채워서 연다. 보내는 것은 브라우저다. -->
|
|
230
|
+
<dialog id="dlgFeedback">
|
|
231
|
+
<form id="frmFeedback">
|
|
232
|
+
<h2>Feedback</h2>
|
|
233
|
+
<div class="field"><label for="fbTitle">Title</label><input id="fbTitle" required placeholder="What's wrong, or what would help"></div>
|
|
234
|
+
<div class="field"><label for="fbBody">Details (optional)</label><textarea id="fbBody" placeholder="What you did, what you expected, what happened"></textarea></div>
|
|
235
|
+
<p class="note">Opens a new GitHub issue with this filled in. You submit it there.</p>
|
|
236
|
+
<div class="actions">
|
|
237
|
+
<button type="button" class="btn" id="fbCancel">Cancel</button>
|
|
238
|
+
<button type="submit" class="btn primary">Open issue</button>
|
|
239
|
+
</div>
|
|
240
|
+
</form>
|
|
241
|
+
</dialog>
|
|
242
|
+
<div class="toast" id="toast"></div>
|
|
243
|
+
|
|
244
|
+
<script>
|
|
245
|
+
// 파일과 도구는 한글 상태 이름을 쓴다 (Claude가 보고하는 말이다). 화면에서만 영문으로 보여준다.
|
|
246
|
+
const STATUSES = ["대기", "진행중", "확인 필요", "완료", "보류", "취소"];
|
|
247
|
+
const LABEL = { "대기": "Waiting", "진행중": "Running", "확인 필요": "Needs review",
|
|
248
|
+
"완료": "Done", "보류": "On hold", "취소": "Cancelled", "중지": "Stopped" };
|
|
249
|
+
const label = (st) => LABEL[st] || st;
|
|
250
|
+
// 사람이 고를 수 있는 것은 셋 — 끝났다, 잠시 둔다, 안 한다. 대기·진행중·확인 필요는 세션이
|
|
251
|
+
// 없느냐 살아 있느냐 끊겼느냐는 사실이지 고르는 게 아니다. 되돌리려면 🔒를 푼다.
|
|
252
|
+
const HUMAN = ["완료", "보류", "취소"];
|
|
253
|
+
const UNSORTED = "_미분류"; // 프로젝트를 모르는 타스크가 모이는 폴더 이름. 주소에도 이렇게 쓴다
|
|
254
|
+
const ALL = "*";
|
|
255
|
+
// CSS 클래스·변수 이름에는 띄어쓰기를 못 넣는다.
|
|
256
|
+
const slug = (st) => st.replace(/\s+/g, "");
|
|
257
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
258
|
+
let tasks = [];
|
|
259
|
+
let projects = []; // 타스크가 없는 프로젝트 폴더도 상자로 뜬다
|
|
260
|
+
let filter = ALL;
|
|
261
|
+
let query = "";
|
|
262
|
+
let archived = false; // 보관함을 보고 있나
|
|
263
|
+
let drawn = null; // 마지막으로 그린 내용. 같으면 다시 그리지 않는다.
|
|
264
|
+
const opened = new Set(); // 펼쳐 둔 타스크 ref·세션 id
|
|
265
|
+
// 접어 둔 프로젝트. 새로고침해도 접힌 채로 — 화면 코드가 바뀌면 탭이 스스로 다시 뜨기 때문에 브라우저에 둔다.
|
|
266
|
+
const folded = new Set((() => { try { return JSON.parse(localStorage.getItem("duet.folded") || "[]"); } catch (_) { return []; } })());
|
|
267
|
+
const rememberFolded = () => { try { localStorage.setItem("duet.folded", JSON.stringify([...folded])); } catch (_) {} };
|
|
268
|
+
let newTaskProject = ""; // 새 타스크 대화상자가 열린 프로젝트
|
|
269
|
+
let about = { version: "", repo: "", projects: 0 }; // 피드백 본문 끝에 붙일 것
|
|
270
|
+
let page = null; // 이 탭이 처음 받은 화면 도장. 서버 것이 달라지면 화면 코드가 바뀐 것이다
|
|
271
|
+
|
|
272
|
+
const $ = (s) => document.querySelector(s);
|
|
273
|
+
const el = (tag, props = {}, ...kids) => {
|
|
274
|
+
const node = Object.assign(document.createElement(tag), props);
|
|
275
|
+
for (const kid of kids.flat(2)) if (kid != null) node.append(kid);
|
|
276
|
+
return node;
|
|
277
|
+
};
|
|
278
|
+
const when = (stamp) => stamp ? stamp.replace("T", " ").split(".")[0].slice(5) : "";
|
|
279
|
+
const JSON_HEADERS = { "content-type": "application/json" };
|
|
280
|
+
|
|
281
|
+
async function api(path, options) {
|
|
282
|
+
const res = await fetch(path, options);
|
|
283
|
+
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || res.statusText);
|
|
284
|
+
return res.json();
|
|
285
|
+
}
|
|
286
|
+
const post = (path, body) => api(path, { method: "POST", headers: JSON_HEADERS, body: JSON.stringify(body ?? {}) });
|
|
287
|
+
|
|
288
|
+
let toastTimer;
|
|
289
|
+
function toast(message) {
|
|
290
|
+
const node = $("#toast");
|
|
291
|
+
node.textContent = message;
|
|
292
|
+
node.classList.add("show");
|
|
293
|
+
clearTimeout(toastTimer);
|
|
294
|
+
toastTimer = setTimeout(() => node.classList.remove("show"), 2600);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* ---------- 타스크 줄 ---------- */
|
|
298
|
+
|
|
299
|
+
// 살아 있는 세션이 붙어 있는 타스크. 그 창이 일하는 중이라 사람은 손대지 않는다 —
|
|
300
|
+
// 상태는 그 세션이 끝나면 저절로 바뀌고, 제목·설명도 세션이 끝난 뒤에 고친다.
|
|
301
|
+
const busyTask = (task) => task.counts["진행중"] > 0;
|
|
302
|
+
|
|
303
|
+
function statusSelect(task) {
|
|
304
|
+
if (busyTask(task)) {
|
|
305
|
+
return el("span", { className: `tag s-${slug(task.status)}`, textContent: label(task.status),
|
|
306
|
+
title: "A session is working on this. The status updates itself when it finishes." });
|
|
307
|
+
}
|
|
308
|
+
// PMS답게 지금 상태만 보여준다. 고르면 그것이 상태가 된다 (= task.md의 `상태:` 줄).
|
|
309
|
+
const sel = el("select", { className: `sel s-${slug(task.status)}`,
|
|
310
|
+
title: "Pick a status to pin it (🔒). Otherwise it follows the sessions." });
|
|
311
|
+
if (!HUMAN.includes(task.status)) {
|
|
312
|
+
sel.append(el("option", { value: "", textContent: label(task.status), selected: true, disabled: true }));
|
|
313
|
+
}
|
|
314
|
+
for (const st of HUMAN) {
|
|
315
|
+
sel.append(el("option", { value: st, textContent: label(st), selected: task.status === st }));
|
|
316
|
+
}
|
|
317
|
+
sel.onchange = async () => {
|
|
318
|
+
try {
|
|
319
|
+
await post(`/api/tasks/${task.ref}/status`, { status: sel.value });
|
|
320
|
+
toast(`${task.id} → ${label(sel.value)}`);
|
|
321
|
+
refresh(true);
|
|
322
|
+
} catch (e) { toast(e.message); }
|
|
323
|
+
};
|
|
324
|
+
return sel;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 사람이 정해 둔 상태에 붙는 자물쇠. 누르면 그 줄을 지워 다시 세션에서 센다.
|
|
328
|
+
function unlockButton(task) {
|
|
329
|
+
if (!task.override) return null;
|
|
330
|
+
const button = el("button", {
|
|
331
|
+
className: "lock", type: "button", textContent: "🔒",
|
|
332
|
+
title: "Pinned by you. Sessions won't change it. Click to let it follow the sessions again.",
|
|
333
|
+
});
|
|
334
|
+
button.onclick = async (e) => {
|
|
335
|
+
e.stopPropagation();
|
|
336
|
+
try {
|
|
337
|
+
await post(`/api/tasks/${task.ref}/status`, { status: null });
|
|
338
|
+
toast(`${task.id} unpinned`);
|
|
339
|
+
refresh(true);
|
|
340
|
+
} catch (err) { toast(err.message); }
|
|
341
|
+
};
|
|
342
|
+
return button;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 제목·설명 인라인 편집. 포커스를 잃을 때만 보낸다.
|
|
346
|
+
function editable(task, field, text, className) {
|
|
347
|
+
if (busyTask(task)) {
|
|
348
|
+
return el("div", { className, textContent: text, title: "Locked while a session is working on it." });
|
|
349
|
+
}
|
|
350
|
+
const node = el("div", { className, contentEditable: "true", spellcheck: false, textContent: text });
|
|
351
|
+
node.dataset.editing = "";
|
|
352
|
+
node.onfocus = () => { node.dataset.editing = "1"; };
|
|
353
|
+
node.onkeydown = (e) => {
|
|
354
|
+
if (e.key === "Enter" && field === "title") { e.preventDefault(); node.blur(); }
|
|
355
|
+
if (e.key === "Escape") { node.textContent = text; node.blur(); }
|
|
356
|
+
};
|
|
357
|
+
node.onblur = async () => {
|
|
358
|
+
node.dataset.editing = "";
|
|
359
|
+
const value = node.textContent.trim();
|
|
360
|
+
if (value === text) return;
|
|
361
|
+
if (field === "title" && !value) { node.textContent = text; return; }
|
|
362
|
+
try {
|
|
363
|
+
await api(`/api/tasks/${task.ref}`, { method: "PATCH", headers: JSON_HEADERS, body: JSON.stringify({ [field]: value }) });
|
|
364
|
+
toast(`${task.id} ${field} updated`);
|
|
365
|
+
refresh(true);
|
|
366
|
+
} catch (e) { toast(e.message); node.textContent = text; }
|
|
367
|
+
};
|
|
368
|
+
return node;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 끝난 세션만 사람이 바꾼다. 살아 있는 세션은 그 프로세스가 자기 파일의 주인이라
|
|
372
|
+
// 태그만 보여주고 손대지 못하게 한다.
|
|
373
|
+
function sessionControl(task, s) {
|
|
374
|
+
if (s.alive) return el("span", { className: `tag s-${s.status}`, textContent: label(s.status) });
|
|
375
|
+
|
|
376
|
+
const sel = el("select", { className: `sel s-${s.status}`, title: "Count this stopped session as done?" });
|
|
377
|
+
for (const v of ["완료", "중지"]) {
|
|
378
|
+
sel.append(el("option", { value: v, textContent: label(v), selected: s.status === v }));
|
|
379
|
+
}
|
|
380
|
+
sel.onclick = (e) => e.stopPropagation();
|
|
381
|
+
sel.onchange = async (e) => {
|
|
382
|
+
e.stopPropagation();
|
|
383
|
+
try {
|
|
384
|
+
await post(`/api/tasks/${task.ref}/sessions/${s.id}/status`, { status: sel.value });
|
|
385
|
+
toast(`${s.title || s.id} → ${label(sel.value)}`);
|
|
386
|
+
refresh(true);
|
|
387
|
+
} catch (err) { toast(err.message); refresh(true); }
|
|
388
|
+
};
|
|
389
|
+
return sel;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function sessionRow(task, s) {
|
|
393
|
+
const logs = s.progress.map((p) => el("li", {},
|
|
394
|
+
el("time", { className: "mono", textContent: when(p.t) }),
|
|
395
|
+
el("span", { textContent: p.msg + (p.pct != null ? ` (${p.pct}%)` : "") })));
|
|
396
|
+
if (s.summary) {
|
|
397
|
+
logs.push(el("li", {},
|
|
398
|
+
el("time", { className: "mono", textContent: "→" }),
|
|
399
|
+
el("span", { style: `color:var(--${s.status})`, textContent: s.summary })));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 펼쳐 둔 세션은 다시 그려도 펼쳐진 채로. 살아 있는 세션은 30초마다 하트비트를
|
|
403
|
+
// 쓰므로, 기억해 두지 않으면 로그를 읽는 중에 접힌다.
|
|
404
|
+
const row = el("details", { className: `sess s-${s.status}`, open: opened.has(s.id) },
|
|
405
|
+
el("summary", {},
|
|
406
|
+
el("span", { className: "sd" }),
|
|
407
|
+
el("span", { className: "nm" },
|
|
408
|
+
el("b", { textContent: s.title || s.id }),
|
|
409
|
+
el("span", { textContent: `${s.client} · ${s.alive ? "working" : when(s.heartbeat)}` })),
|
|
410
|
+
s.by_human ? el("span", { className: "locked", title: "Set by you", textContent: "🔒" }) : null,
|
|
411
|
+
sessionControl(task, s)),
|
|
412
|
+
el("ul", { className: "log" },
|
|
413
|
+
logs.length ? logs : el("li", { className: "none" }, "No progress reported yet")));
|
|
414
|
+
row.ontoggle = () => { row.open ? opened.add(s.id) : opened.delete(s.id); };
|
|
415
|
+
return row;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function ghost(label, onclick) {
|
|
419
|
+
return el("button", { className: "btn ghost", type: "button", textContent: label, onclick });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// 타스크 한 줄. 누르면 설명·세션이 펼쳐진다.
|
|
423
|
+
function taskRow(task) {
|
|
424
|
+
const total = task.sessions.length;
|
|
425
|
+
|
|
426
|
+
// summary 안의 편집 칸·드롭다운은 누를 때 펼침이 같이 토글되지 않게 막는다.
|
|
427
|
+
const title = editable(task, "title", task.title, "title");
|
|
428
|
+
title.onclick = (e) => e.stopPropagation();
|
|
429
|
+
const sel = statusSelect(task);
|
|
430
|
+
sel.onclick = (e) => e.stopPropagation();
|
|
431
|
+
|
|
432
|
+
const row = el("details", { className: `trow s-${slug(task.status)}`, open: opened.has(task.ref) },
|
|
433
|
+
el("summary", {},
|
|
434
|
+
el("span", { className: "stripe", style: `background:var(--${slug(task.status)})` }),
|
|
435
|
+
el("span", { className: "tid mono", textContent: task.id }),
|
|
436
|
+
title,
|
|
437
|
+
task.warning ? el("span", { className: "warn-dot", title: task.warning, textContent: "⚠" }) : null,
|
|
438
|
+
sel,
|
|
439
|
+
unlockButton(task),
|
|
440
|
+
busyTask(task) || archived ? null : xButton("Delete task", () => deleteTask(task))),
|
|
441
|
+
el("div", { className: "tbody" },
|
|
442
|
+
el("div", { className: "desc" }, editable(task, "description", task.description, "")),
|
|
443
|
+
task.warning ? el("div", { className: "warn", textContent: `⚠ ${task.warning}` }) : null,
|
|
444
|
+
total ? el("div", { className: "sessions" }, task.sessions.map((s) => sessionRow(task, s))) : null,
|
|
445
|
+
el("div", { className: "foot" },
|
|
446
|
+
el("span", { className: "mono", title: task.folder, textContent: task.ref }),
|
|
447
|
+
task.last_activity
|
|
448
|
+
? el("span", { className: "mono", textContent: `Last activity ${when(task.last_activity)}` })
|
|
449
|
+
: null,
|
|
450
|
+
el("span", { className: "acts" }, ghost("▲ Collapse", () => { row.open = false; })))));
|
|
451
|
+
row.ontoggle = () => { row.open ? opened.add(task.ref) : opened.delete(task.ref); };
|
|
452
|
+
return row;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 지우는 손잡이는 × 하나. 살아 있는 세션이 붙어 있으면 서버가 거절한다.
|
|
456
|
+
function xButton(title, onclick) {
|
|
457
|
+
const b = el("button", { className: "x", type: "button", textContent: "×", title });
|
|
458
|
+
b.onclick = (e) => { e.stopPropagation(); e.preventDefault(); onclick(); };
|
|
459
|
+
return b;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function remove(path, message) {
|
|
463
|
+
if (!confirm(message)) return;
|
|
464
|
+
try {
|
|
465
|
+
const res = await api(path, { method: "DELETE" });
|
|
466
|
+
toast(res.note);
|
|
467
|
+
refresh(true);
|
|
468
|
+
} catch (e) { toast(e.message); }
|
|
469
|
+
}
|
|
470
|
+
const deleteTask = (task) =>
|
|
471
|
+
remove(`/api/tasks/${task.ref}`, `[${task.ref}] ${task.title}\nDelete this task and its session history? This cannot be undone.`);
|
|
472
|
+
const deleteProject = (name, count) =>
|
|
473
|
+
remove(`/api/projects/${name || UNSORTED}`, `Delete project '${name || UNSORTED}' and its ${plural(count, "task")}? This cannot be undone.`);
|
|
474
|
+
|
|
475
|
+
/* ---------- 프로젝트 상자 ---------- */
|
|
476
|
+
|
|
477
|
+
async function act(path, body, fallback) {
|
|
478
|
+
try {
|
|
479
|
+
const res = await post(path, body);
|
|
480
|
+
toast(res.note || fallback);
|
|
481
|
+
refresh(true);
|
|
482
|
+
} catch (e) { toast(e.message); }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function projectButtons(name, list) {
|
|
486
|
+
if (archived) {
|
|
487
|
+
return [el("button", { className: "btn small", type: "button", textContent: "Unarchive",
|
|
488
|
+
onclick: () => act("/api/projects/unarchive", { name }, "Restored") })];
|
|
489
|
+
}
|
|
490
|
+
const out = [el("button", { className: "btn small", type: "button", textContent: "+ Task",
|
|
491
|
+
onclick: () => openTaskDialog(name) })];
|
|
492
|
+
// 살아 있는 세션이 있으면 서버가 거절한다. 여기서는 미리 숨기지 않고 이유를 토스트로 보여준다.
|
|
493
|
+
out.push(el("button", { className: "btn small", type: "button", textContent: "Archive",
|
|
494
|
+
title: "Move the whole project folder to the archive. Nothing is deleted.",
|
|
495
|
+
onclick: () => act("/api/projects/archive", { name }, "Archived") }));
|
|
496
|
+
out.push(xButton("Delete project", () => deleteProject(name, list.length)));
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// 프로젝트 하나가 상자 하나. 그 안에 타스크가 줄로 들어간다.
|
|
501
|
+
function projectCard(name, list) {
|
|
502
|
+
const running = list.filter((t) => t.status === "진행중").length;
|
|
503
|
+
const attention = list.filter((t) => t.status === "확인 필요").length;
|
|
504
|
+
const key = name || UNSORTED;
|
|
505
|
+
const head = el("div", { className: "phead", title: "Click to fold or unfold" },
|
|
506
|
+
el("h2", {}, el("span", { className: "caret", textContent: "▼" }), el("span", { textContent: key })),
|
|
507
|
+
el("span", { className: "spacer" }),
|
|
508
|
+
running ? el("span", { className: "tag s-진행중", textContent: `${running} running` }) : null,
|
|
509
|
+
attention ? el("span", { className: "tag s-확인필요", textContent: `${attention} needs review` }) : null,
|
|
510
|
+
el("span", { className: "pc mono", textContent: plural(list.length, "task") }),
|
|
511
|
+
el("span", { className: "acts" }, projectButtons(name, list)));
|
|
512
|
+
const card = el("article", { className: "pcard" + (folded.has(key) ? " folded" : "") },
|
|
513
|
+
head,
|
|
514
|
+
list.length
|
|
515
|
+
? el("div", { className: "trows" }, list.map(taskRow))
|
|
516
|
+
: el("div", { className: "pempty", textContent: "No tasks yet. Add one with + Task." }));
|
|
517
|
+
// 머리 아무 데나 누르면 접힌다. 버튼(+ Task·Archive·×)은 자기 일만 한다.
|
|
518
|
+
head.onclick = (e) => {
|
|
519
|
+
if (e.target.closest("button")) return;
|
|
520
|
+
card.classList.toggle("folded");
|
|
521
|
+
card.classList.contains("folded") ? folded.add(key) : folded.delete(key);
|
|
522
|
+
rememberFolded();
|
|
523
|
+
};
|
|
524
|
+
return card;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/* ---------- 그리기 ---------- */
|
|
528
|
+
|
|
529
|
+
function tally() {
|
|
530
|
+
const c = { [ALL]: tasks.length };
|
|
531
|
+
for (const st of STATUSES) c[st] = 0;
|
|
532
|
+
for (const t of tasks) c[t.status] = (c[t.status] || 0) + 1;
|
|
533
|
+
return c;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function draw() {
|
|
537
|
+
const c = tally();
|
|
538
|
+
const pick = (key) => { filter = filter === key ? ALL : key; drawn = null; draw(); };
|
|
539
|
+
|
|
540
|
+
// 상태 칩이 곧 필터다. 하나 누르면 그것만, 다시 누르면 전부.
|
|
541
|
+
$("#chips").replaceChildren(...[ALL, ...STATUSES].map((s) => {
|
|
542
|
+
const chip = el("button", { className: "chip" },
|
|
543
|
+
s === ALL ? null : el("span", { className: "dot", style: `background:var(--${slug(s)})` }),
|
|
544
|
+
el("span", { textContent: s === ALL ? "All" : label(s) }),
|
|
545
|
+
el("span", { className: "n mono", textContent: String(c[s]) }));
|
|
546
|
+
chip.setAttribute("aria-pressed", String(filter === s));
|
|
547
|
+
chip.onclick = () => pick(s);
|
|
548
|
+
return chip;
|
|
549
|
+
}));
|
|
550
|
+
|
|
551
|
+
const q = query.trim().toLowerCase();
|
|
552
|
+
const hit = (t) => !q || [t.id, t.title, t.description, t.project,
|
|
553
|
+
...t.sessions.flatMap((s) => [s.title, s.client, s.summary || "", ...s.progress.map((p) => p.msg)]),
|
|
554
|
+
].some((text) => String(text).toLowerCase().includes(q));
|
|
555
|
+
const shown = tasks.filter((t) => (filter === ALL || t.status === filter) && hit(t));
|
|
556
|
+
|
|
557
|
+
// 프로젝트별로 묶는다. 모르는 것은 맨 아래.
|
|
558
|
+
const groups = new Map();
|
|
559
|
+
const filtering = filter !== ALL || q;
|
|
560
|
+
if (!filtering) for (const p of projects) groups.set(p, []); // 빈 프로젝트도 상자로
|
|
561
|
+
for (const t of shown) {
|
|
562
|
+
const key = t.project || "";
|
|
563
|
+
(groups.get(key) ?? groups.set(key, []).get(key)).push(t);
|
|
564
|
+
}
|
|
565
|
+
for (const list of groups.values()) list.sort((a, b) => a.id.localeCompare(b.id));
|
|
566
|
+
// 프로젝트는 최근에 움직인 것이 위.
|
|
567
|
+
const latest = (key) => Math.max(0, ...groups.get(key).map((t) => Date.parse(t.last_activity) || 0));
|
|
568
|
+
const order = [...groups.keys()].sort((a, b) => {
|
|
569
|
+
if (!a) return 1;
|
|
570
|
+
if (!b) return -1;
|
|
571
|
+
return (latest(b) - latest(a)) || a.localeCompare(b, "ko");
|
|
572
|
+
});
|
|
573
|
+
const cards = order.map((key) => projectCard(key, groups.get(key)));
|
|
574
|
+
|
|
575
|
+
$("#grid").replaceChildren(...(cards.length ? cards : [
|
|
576
|
+
el("div", { className: "empty", textContent: tasks.length
|
|
577
|
+
? "No tasks match."
|
|
578
|
+
: archived ? "The archive is empty." : "No projects yet. Start with + New Project." }),
|
|
579
|
+
]));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/* ---------- 새로 읽기 ---------- */
|
|
583
|
+
|
|
584
|
+
function busy() {
|
|
585
|
+
// 고치는 중이거나 드롭다운을 만지는 중이면 다시 그리지 않는다. 커서가 사라진다.
|
|
586
|
+
const active = document.activeElement;
|
|
587
|
+
return !!active && (active.dataset?.editing === "1" || active.tagName === "SELECT");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function refresh(force = false) {
|
|
591
|
+
try {
|
|
592
|
+
const data = await api(archived ? "/api/archive" : "/api/tasks");
|
|
593
|
+
const count = archived ? new Set(data.tasks.map((t) => t.project)).size : (data.projects || []).length;
|
|
594
|
+
// 재설치로 화면 파일이 바뀌었다. 데이터는 매번 새로 읽지만 화면 코드는 탭이 들고 있으므로
|
|
595
|
+
// 여기서 한 번 스스로 새로고침한다. 편집 중이면 그 입력이 날아가니 끝날 때까지 미룬다.
|
|
596
|
+
if (page === null) page = data.page;
|
|
597
|
+
else if (data.page && data.page !== page && !busy()) { location.reload(); return; }
|
|
598
|
+
$("#pulse").classList.add("on");
|
|
599
|
+
$("#pulse").title = data.home;
|
|
600
|
+
about = { version: data.version || "", repo: data.repo || "", projects: count }; // 경로는 볼 일이 드물다. 점에 마우스를 올리면 나온다
|
|
601
|
+
$("#state").textContent = `${archived ? "Archive" : "Live"} · ${plural(count, "project")} · ${plural(data.tasks.length, "task")}`;
|
|
602
|
+
// 보관함 안에서는 같은 버튼이 "돌아가기"다. 토글이라는 걸 사람이 알 필요 없다.
|
|
603
|
+
$("#archive").textContent = archived ? "← Board" : (data.archived ? `Archived ${data.archived}` : "Archived");
|
|
604
|
+
|
|
605
|
+
const shape = JSON.stringify([archived, data.projects, data.tasks]);
|
|
606
|
+
if (!force && (busy() || shape === drawn)) return;
|
|
607
|
+
drawn = shape;
|
|
608
|
+
tasks = data.tasks;
|
|
609
|
+
projects = data.projects || [];
|
|
610
|
+
draw();
|
|
611
|
+
} catch (e) {
|
|
612
|
+
$("#pulse").classList.remove("on");
|
|
613
|
+
$("#state").textContent = `Offline — ${e.message}`;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/* ---------- 손잡이 ---------- */
|
|
618
|
+
|
|
619
|
+
$("#q").addEventListener("input", (e) => { query = e.target.value; draw(); });
|
|
620
|
+
$("#archive").addEventListener("click", (e) => {
|
|
621
|
+
archived = !archived;
|
|
622
|
+
e.currentTarget.setAttribute("aria-pressed", String(archived));
|
|
623
|
+
filter = ALL;
|
|
624
|
+
refresh(true);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// 한글 입력 중의 Enter는 IME가 조합을 끝내는 데 쓴다. 그 Enter로 폼이 넘어가면 안 되고,
|
|
628
|
+
// 조합이 끝난 뒤의 Enter는 넘어가야 한다. 브라우저 기본 동작만으로는 이게 어긋난다.
|
|
629
|
+
function submitOnEnter(input, form) {
|
|
630
|
+
input.addEventListener("keydown", (e) => {
|
|
631
|
+
if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); form.requestSubmit(); }
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// 새 프로젝트 = 이름 + 첫 타스크. 폴더는 첫 타스크와 함께 생긴다.
|
|
636
|
+
$("#new").addEventListener("click", () => { $("#frmProject").reset(); $("#dlgProject").showModal(); $("#pName").focus(); });
|
|
637
|
+
$("#pCancel").addEventListener("click", () => $("#dlgProject").close());
|
|
638
|
+
submitOnEnter($("#pName"), $("#frmProject"));
|
|
639
|
+
submitOnEnter($("#pTitle"), $("#frmProject"));
|
|
640
|
+
$("#frmProject").addEventListener("submit", async (e) => {
|
|
641
|
+
e.preventDefault();
|
|
642
|
+
const project = $("#pName").value.trim();
|
|
643
|
+
const title = $("#pTitle").value.trim();
|
|
644
|
+
if (!project || !title) return;
|
|
645
|
+
try {
|
|
646
|
+
const task = await post("/api/tasks", { title, description: $("#pDesc").value, project });
|
|
647
|
+
$("#dlgProject").close();
|
|
648
|
+
toast(`Created ${project} with [${task.id}] ${task.title}`);
|
|
649
|
+
refresh(true);
|
|
650
|
+
} catch (err) { toast(err.message); }
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// 프로젝트 상자 안의 새 타스크
|
|
654
|
+
function openTaskDialog(project) {
|
|
655
|
+
newTaskProject = project;
|
|
656
|
+
$("#frmTask").reset();
|
|
657
|
+
$("#tHead").textContent = `${project || UNSORTED} — New Task`;
|
|
658
|
+
$("#dlgTask").showModal();
|
|
659
|
+
$("#tTitle").focus();
|
|
660
|
+
}
|
|
661
|
+
$("#tCancel").addEventListener("click", () => $("#dlgTask").close());
|
|
662
|
+
submitOnEnter($("#tTitle"), $("#frmTask"));
|
|
663
|
+
$("#frmTask").addEventListener("submit", async (e) => {
|
|
664
|
+
e.preventDefault();
|
|
665
|
+
const title = $("#tTitle").value.trim();
|
|
666
|
+
if (!title) return;
|
|
667
|
+
try {
|
|
668
|
+
const task = await post("/api/tasks", { title, description: $("#tDesc").value, project: newTaskProject });
|
|
669
|
+
$("#dlgTask").close();
|
|
670
|
+
toast(`Created [${task.ref}] ${task.title}`);
|
|
671
|
+
refresh(true);
|
|
672
|
+
} catch (err) { toast(err.message); }
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// 피드백. GitHub 새 이슈 주소에 제목·본문을 실어 새 탭으로 연다. 토큰도 서버 호출도 없다.
|
|
676
|
+
$("#feedback").addEventListener("click", () => { $("#frmFeedback").reset(); $("#dlgFeedback").showModal(); $("#fbTitle").focus(); });
|
|
677
|
+
$("#fbCancel").addEventListener("click", () => $("#dlgFeedback").close());
|
|
678
|
+
submitOnEnter($("#fbTitle"), $("#frmFeedback"));
|
|
679
|
+
$("#frmFeedback").addEventListener("submit", (e) => {
|
|
680
|
+
e.preventDefault();
|
|
681
|
+
const title = $("#fbTitle").value.trim();
|
|
682
|
+
if (!title) return;
|
|
683
|
+
const body = `${$("#fbBody").value.trim()}\n\n---\nDuet ${about.version} · ${archived ? "archive" : "board"} · ${plural(about.projects, "project")} · ${plural(tasks.length, "task")} · ${navigator.userAgent}`;
|
|
684
|
+
const url = `${about.repo}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
|
|
685
|
+
window.open(url, "_blank", "noopener");
|
|
686
|
+
$("#dlgFeedback").close();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
refresh();
|
|
690
|
+
|
|
691
|
+
// 폴더가 바뀔 때만 다시 읽는다. 서버가 /api/events로 알려준다.
|
|
692
|
+
// 연결이 끊기면 브라우저가 알아서 다시 붙고, 그 사이는 15초 폴링으로 버틴다.
|
|
693
|
+
let fallback = null;
|
|
694
|
+
function listen() {
|
|
695
|
+
const es = new EventSource("/api/events");
|
|
696
|
+
es.addEventListener("hello", () => { clearInterval(fallback); fallback = null; refresh(); });
|
|
697
|
+
es.addEventListener("change", () => refresh());
|
|
698
|
+
es.addEventListener("ping", (e) => { if (page && e.data && e.data !== page) refresh(); });
|
|
699
|
+
es.onerror = () => { if (!fallback) fallback = setInterval(refresh, 15000); };
|
|
700
|
+
}
|
|
701
|
+
listen();
|
|
702
|
+
</script>
|
|
703
|
+
</body>
|
|
704
|
+
</html>
|