project-mgr 0.3.2 → 0.5.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/CHANGELOG.md +34 -0
- package/README.md +50 -1
- package/README_CN.md +79 -2
- package/dist/cli.js +63 -4
- package/dist/db.d.ts +21 -1
- package/dist/db.js +488 -22
- package/dist/domain.d.ts +15 -3
- package/dist/domain.js +29 -1
- package/dist/server.js +98 -14
- package/dist/web/locales/en.js +16 -0
- package/dist/web/locales/zh.d.ts +16 -0
- package/dist/web/locales/zh.js +16 -0
- package/dist/web/script.js +109 -6
- package/dist/web/styles.d.ts +1 -1
- package/dist/web/styles.js +2 -0
- package/dist/web/ui.js +12 -0
- package/package.json +1 -1
- package/skills/project-mgr/SKILL.md +23 -3
package/dist/domain.js
CHANGED
|
@@ -16,6 +16,8 @@ const keys = new Set([
|
|
|
16
16
|
"progress",
|
|
17
17
|
"blockers",
|
|
18
18
|
"nextSteps",
|
|
19
|
+
"nextTodoUids",
|
|
20
|
+
"nextStepsSourceUid",
|
|
19
21
|
"decisions",
|
|
20
22
|
"tags",
|
|
21
23
|
"occurredAt",
|
|
@@ -66,6 +68,14 @@ function textList(value, field) {
|
|
|
66
68
|
throw new ValidationError(`${field} must be an array of at most 100 strings`);
|
|
67
69
|
return value.map((item) => text(item, field, true));
|
|
68
70
|
}
|
|
71
|
+
function uidList(value, field) {
|
|
72
|
+
if (!Array.isArray(value) || value.length > 100)
|
|
73
|
+
throw new ValidationError(`${field} must be an array of at most 100 uuids`);
|
|
74
|
+
const uids = value.map((item) => validateUid(item));
|
|
75
|
+
if (new Set(uids).size !== uids.length)
|
|
76
|
+
throw new ValidationError(`${field} contains a duplicate uid`);
|
|
77
|
+
return uids;
|
|
78
|
+
}
|
|
69
79
|
export function parseTime(value, field) {
|
|
70
80
|
if (typeof value !== "string" ||
|
|
71
81
|
!/^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$/.test(value) ||
|
|
@@ -120,6 +130,18 @@ export function validateStatusEntry(value, defaultSource = "human") {
|
|
|
120
130
|
const externalRef = text(input.externalRef, "externalRef");
|
|
121
131
|
if (externalRef && externalRef.length > 200)
|
|
122
132
|
throw new ValidationError("externalRef must be at most 200 characters");
|
|
133
|
+
const nextSteps = textList(input.nextSteps, "nextSteps");
|
|
134
|
+
const nextStepsSourceUid = input.nextStepsSourceUid === undefined
|
|
135
|
+
? undefined
|
|
136
|
+
: validateUid(input.nextStepsSourceUid);
|
|
137
|
+
// null and omission both mean legacy rows: the refs field is absent, not [].
|
|
138
|
+
const nextTodoUids = input.nextTodoUids === undefined || input.nextTodoUids === null
|
|
139
|
+
? undefined
|
|
140
|
+
: uidList(input.nextTodoUids, "nextTodoUids");
|
|
141
|
+
if (nextTodoUids !== undefined && nextSteps.length > 0)
|
|
142
|
+
throw new ValidationError("cannot combine nextSteps text with nextTodoUids");
|
|
143
|
+
if (nextTodoUids !== undefined && nextStepsSourceUid !== undefined)
|
|
144
|
+
throw new ValidationError("invalid next step provenance");
|
|
123
145
|
return {
|
|
124
146
|
...(input.uid === undefined ? {} : { uid: validateUid(input.uid) }),
|
|
125
147
|
project,
|
|
@@ -127,7 +149,9 @@ export function validateStatusEntry(value, defaultSource = "human") {
|
|
|
127
149
|
summary: text(input.summary, "summary", true),
|
|
128
150
|
progress: text(input.progress, "progress"),
|
|
129
151
|
blockers: textList(input.blockers, "blockers"),
|
|
130
|
-
nextSteps
|
|
152
|
+
nextSteps,
|
|
153
|
+
...(nextTodoUids !== undefined ? { nextTodoUids } : {}),
|
|
154
|
+
...(nextStepsSourceUid !== undefined ? { nextStepsSourceUid } : {}),
|
|
131
155
|
decisions: textList(input.decisions, "decisions"),
|
|
132
156
|
tags: textList(input.tags, "tags"),
|
|
133
157
|
occurredAt: input.occurredAt === undefined
|
|
@@ -150,6 +174,10 @@ export function toPortableStatusEntry(entry) {
|
|
|
150
174
|
occurredAt: entry.occurredAt,
|
|
151
175
|
source: entry.source,
|
|
152
176
|
};
|
|
177
|
+
if (entry.nextStepsSourceUid !== undefined)
|
|
178
|
+
result.nextStepsSourceUid = entry.nextStepsSourceUid;
|
|
179
|
+
if (entry.nextTodoUids !== undefined)
|
|
180
|
+
result.nextTodoUids = entry.nextTodoUids;
|
|
153
181
|
if (entry.progress !== undefined)
|
|
154
182
|
result.progress = entry.progress;
|
|
155
183
|
if (entry.externalRef !== undefined)
|
package/dist/server.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
2
|
import { createServer, } from "node:http";
|
|
3
3
|
import { URL } from "node:url";
|
|
4
|
-
import { countEntries, getEntry, history, listProjects, openLedger, } from "./db.js";
|
|
4
|
+
import { countEntries, createTodo, listTodos, updateTodo, convertNextStep, completeActivity, getEntry, history, listProjects, openLedger, } from "./db.js";
|
|
5
|
+
import { ValidationError } from "./domain.js";
|
|
5
6
|
import { getWebUIHTML } from "./web/ui.js";
|
|
6
7
|
export function openBrowser(url) {
|
|
7
8
|
const platform = process.platform;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
exec(command, () => {
|
|
19
|
-
/* ignore background exec errors */
|
|
9
|
+
const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
10
|
+
const args = platform === "darwin"
|
|
11
|
+
? [url]
|
|
12
|
+
: platform === "win32"
|
|
13
|
+
? ["/c", "start", "", url]
|
|
14
|
+
: [url];
|
|
15
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
16
|
+
child.on("error", () => {
|
|
17
|
+
/* ignore background launch errors */
|
|
20
18
|
});
|
|
19
|
+
// Detach so a long-lived browser does not keep this process alive.
|
|
20
|
+
child.unref();
|
|
21
21
|
}
|
|
22
22
|
function parseQueryParams(url) {
|
|
23
23
|
const params = {};
|
|
@@ -34,6 +34,11 @@ function sendJson(res, statusCode, data) {
|
|
|
34
34
|
res.end(JSON.stringify(data));
|
|
35
35
|
}
|
|
36
36
|
export function handleRequest(req, res, db) {
|
|
37
|
+
if (req.method === "POST" &&
|
|
38
|
+
/^\/api\/(todos(?:\/[^/]+)?|entries\/[^/]+\/(complete|todos))$/.test((req.url || "").split("?")[0])) {
|
|
39
|
+
void handleMutation(req, res, db);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
37
42
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
38
43
|
sendJson(res, 405, { error: "Method not allowed" });
|
|
39
44
|
return;
|
|
@@ -61,6 +66,20 @@ export function handleRequest(req, res, db) {
|
|
|
61
66
|
res.end(getWebUIHTML());
|
|
62
67
|
return;
|
|
63
68
|
}
|
|
69
|
+
if (pathname === "/api/todos") {
|
|
70
|
+
const status = query.status;
|
|
71
|
+
if (status !== undefined &&
|
|
72
|
+
status !== "open" &&
|
|
73
|
+
status !== "completed" &&
|
|
74
|
+
status !== "cancelled") {
|
|
75
|
+
sendJson(res, 400, { error: "invalid todo status" });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
sendJson(res, 200, listTodos(db, query.project, status === undefined
|
|
79
|
+
? {}
|
|
80
|
+
: { status: status }));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
64
83
|
if (pathname === "/api/projects") {
|
|
65
84
|
const projects = listProjects(db, { query: query.query });
|
|
66
85
|
sendJson(res, 200, projects);
|
|
@@ -162,3 +181,68 @@ export function startServer(options = {}) {
|
|
|
162
181
|
});
|
|
163
182
|
});
|
|
164
183
|
}
|
|
184
|
+
async function handleMutation(req, res, db) {
|
|
185
|
+
try {
|
|
186
|
+
const host = req.headers.host;
|
|
187
|
+
if (!host ||
|
|
188
|
+
!/^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(host) ||
|
|
189
|
+
(req.headers.origin !== undefined &&
|
|
190
|
+
req.headers.origin !== `http://${host}`) ||
|
|
191
|
+
(req.headers["sec-fetch-site"] !== undefined &&
|
|
192
|
+
!["same-origin", "none"].includes(String(req.headers["sec-fetch-site"])))) {
|
|
193
|
+
sendJson(res, 403, {
|
|
194
|
+
error: "Only same-origin local writes are allowed",
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (req.headers["content-type"] !== "application/json") {
|
|
199
|
+
sendJson(res, 415, { error: "JSON required" });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const chunks = [];
|
|
203
|
+
let size = 0;
|
|
204
|
+
for await (const chunk of req) {
|
|
205
|
+
const bytes = Buffer.from(chunk);
|
|
206
|
+
size += bytes.length;
|
|
207
|
+
if (size > 32768) {
|
|
208
|
+
sendJson(res, 413, { error: "Request too large" });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
chunks.push(bytes);
|
|
212
|
+
}
|
|
213
|
+
const input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
214
|
+
const parts = new URL(req.url, `http://${host}`).pathname
|
|
215
|
+
.split("/")
|
|
216
|
+
.map(decodeURIComponent);
|
|
217
|
+
let result;
|
|
218
|
+
if (parts[2] === "todos")
|
|
219
|
+
result = parts[3]
|
|
220
|
+
? updateTodo(db, parts[3], input)
|
|
221
|
+
: createTodo(db, input);
|
|
222
|
+
else {
|
|
223
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
224
|
+
throw new ValidationError("expected an object");
|
|
225
|
+
const data = input;
|
|
226
|
+
if (parts[4] === "complete") {
|
|
227
|
+
if (Object.keys(data).length)
|
|
228
|
+
throw new ValidationError("unexpected fields");
|
|
229
|
+
result = completeActivity(db, parts[3]);
|
|
230
|
+
}
|
|
231
|
+
else if (Object.keys(data).length === 1 && "index" in data)
|
|
232
|
+
result = convertNextStep(db, parts[3], data.index);
|
|
233
|
+
else {
|
|
234
|
+
if (Object.keys(data).some((key) => key !== "title"))
|
|
235
|
+
throw new ValidationError("unexpected fields");
|
|
236
|
+
result = createTodo(db, { title: data.title, entryUid: parts[3] });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
sendJson(res, 200, result);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
sendJson(res, error instanceof ValidationError ||
|
|
243
|
+
error instanceof SyntaxError ||
|
|
244
|
+
error instanceof URIError
|
|
245
|
+
? 400
|
|
246
|
+
: 500, { error: error instanceof Error ? error.message : "Request failed" });
|
|
247
|
+
}
|
|
248
|
+
}
|
package/dist/web/locales/en.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
export const en = {
|
|
2
|
+
savedRefreshError: "Saved, but the list could not refresh. Refresh before retrying.",
|
|
3
|
+
todos: "Todos",
|
|
4
|
+
todoTitle: "Todo title",
|
|
5
|
+
addTodo: "Add Todo",
|
|
6
|
+
complete: "Mark completed",
|
|
7
|
+
convertTodo: "Convert to Todo",
|
|
8
|
+
noTodos: "No Todos",
|
|
9
|
+
noProject: "No project",
|
|
10
|
+
sourceEntry: "Source activity",
|
|
11
|
+
reopen: "Reopen",
|
|
12
|
+
cancelTodo: "Cancel Todo",
|
|
13
|
+
todo_open: "Open",
|
|
14
|
+
todo_completed: "Completed",
|
|
15
|
+
todo_cancelled: "Cancelled",
|
|
16
|
+
viewTodos: "View Todos",
|
|
17
|
+
chooseProject: "Assign project",
|
|
2
18
|
htmlLang: "en",
|
|
3
19
|
pageTitle: "Project Manager - Projects & Activity Feed",
|
|
4
20
|
appTitle: "Project Manager",
|
package/dist/web/locales/zh.d.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
export declare const zh: {
|
|
2
|
+
readonly savedRefreshError: "已保存,但列表刷新失败。请先刷新页面,不要重复创建。";
|
|
3
|
+
readonly todos: "待办";
|
|
4
|
+
readonly todoTitle: "待办内容";
|
|
5
|
+
readonly addTodo: "新增待办";
|
|
6
|
+
readonly complete: "标为已完成";
|
|
7
|
+
readonly convertTodo: "转为待办";
|
|
8
|
+
readonly noTodos: "暂无待办";
|
|
9
|
+
readonly noProject: "不关联项目";
|
|
10
|
+
readonly sourceEntry: "来源动态";
|
|
11
|
+
readonly reopen: "重新打开";
|
|
12
|
+
readonly cancelTodo: "取消待办";
|
|
13
|
+
readonly todo_open: "待办";
|
|
14
|
+
readonly todo_completed: "已完成";
|
|
15
|
+
readonly todo_cancelled: "已取消";
|
|
16
|
+
readonly viewTodos: "查看待办";
|
|
17
|
+
readonly chooseProject: "关联项目";
|
|
2
18
|
readonly htmlLang: "zh-CN";
|
|
3
19
|
readonly pageTitle: "项目管理 - 项目与动态概览";
|
|
4
20
|
readonly appTitle: "项目管理";
|
package/dist/web/locales/zh.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
export const zh = {
|
|
2
|
+
savedRefreshError: "已保存,但列表刷新失败。请先刷新页面,不要重复创建。",
|
|
3
|
+
todos: "待办",
|
|
4
|
+
todoTitle: "待办内容",
|
|
5
|
+
addTodo: "新增待办",
|
|
6
|
+
complete: "标为已完成",
|
|
7
|
+
convertTodo: "转为待办",
|
|
8
|
+
noTodos: "暂无待办",
|
|
9
|
+
noProject: "不关联项目",
|
|
10
|
+
sourceEntry: "来源动态",
|
|
11
|
+
reopen: "重新打开",
|
|
12
|
+
cancelTodo: "取消待办",
|
|
13
|
+
todo_open: "待办",
|
|
14
|
+
todo_completed: "已完成",
|
|
15
|
+
todo_cancelled: "已取消",
|
|
16
|
+
viewTodos: "查看待办",
|
|
17
|
+
chooseProject: "关联项目",
|
|
2
18
|
htmlLang: "zh-CN",
|
|
3
19
|
pageTitle: "项目管理 - 项目与动态概览",
|
|
4
20
|
appTitle: "项目管理",
|
package/dist/web/script.js
CHANGED
|
@@ -2,11 +2,13 @@ import { getClientI18nScript } from "./i18n.js";
|
|
|
2
2
|
export function getWebUIScript(initialLang) {
|
|
3
3
|
return `${getClientI18nScript(initialLang)}
|
|
4
4
|
|
|
5
|
+
let todosData = [];
|
|
5
6
|
let projectsData = [];
|
|
6
7
|
let historyData = [];
|
|
7
8
|
let focusedEntry = null;
|
|
8
9
|
let activeProjectFilter = null;
|
|
9
10
|
let activeGroupMode = 'grouped';
|
|
11
|
+
let initialNavigation = true;
|
|
10
12
|
|
|
11
13
|
function getTodayDateString() {
|
|
12
14
|
const now = new Date();
|
|
@@ -32,13 +34,15 @@ export function getWebUIScript(initialLang) {
|
|
|
32
34
|
|
|
33
35
|
async function fetchData() {
|
|
34
36
|
try {
|
|
35
|
-
const [projRes, histRes, statsRes] = await Promise.all([
|
|
37
|
+
const [projRes, histRes, statsRes, todosRes] = await Promise.all([
|
|
36
38
|
fetch('/api/projects'),
|
|
37
39
|
fetch('/api/history?limit=1000'),
|
|
38
|
-
fetch('/api/stats')
|
|
40
|
+
fetch('/api/stats'),
|
|
41
|
+
fetch('/api/todos')
|
|
39
42
|
]);
|
|
40
43
|
|
|
41
|
-
if (projRes.ok)
|
|
44
|
+
if (![projRes, histRes, statsRes, todosRes].every(response => response.ok)) throw new Error(t('entryLoadError'));
|
|
45
|
+
projectsData = await projRes.json();
|
|
42
46
|
if (histRes.ok) historyData = await histRes.json();
|
|
43
47
|
if (statsRes.ok) {
|
|
44
48
|
const stats = await statsRes.json();
|
|
@@ -47,24 +51,32 @@ export function getWebUIScript(initialLang) {
|
|
|
47
51
|
document.getElementById('statUpdates').textContent = stats.totalEntries;
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
if (!todosRes.ok) throw new Error('Todos unavailable');
|
|
55
|
+
todosData = await todosRes.json();
|
|
50
56
|
render();
|
|
51
57
|
if (typeof location !== 'undefined') {
|
|
52
58
|
const ref = new URLSearchParams(location.search).get('entry');
|
|
53
59
|
if (ref) {
|
|
54
|
-
switchTab('activity');
|
|
60
|
+
if (initialNavigation) switchTab('activity');
|
|
55
61
|
const response = await fetch('/api/entries/' + encodeURIComponent(ref));
|
|
56
62
|
if (!response.ok) throw new Error('Entry unavailable');
|
|
57
63
|
focusedEntry = await response.json();
|
|
58
64
|
render();
|
|
59
65
|
}
|
|
60
66
|
}
|
|
67
|
+
initialNavigation = false;
|
|
68
|
+
return true;
|
|
61
69
|
} catch (err) {
|
|
70
|
+
document.getElementById('actionError').textContent = t('entryLoadError');
|
|
62
71
|
console.error('Failed to load data', err);
|
|
63
72
|
document.getElementById('timelineContainer').textContent = t('entryLoadError');
|
|
73
|
+
return false;
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
function switchTab(tab) {
|
|
78
|
+
document.getElementById('tabTodos').classList.toggle('active', tab === 'todos');
|
|
79
|
+
document.getElementById('viewTodos').classList.toggle('active', tab === 'todos');
|
|
68
80
|
document.getElementById('tabProjects').classList.toggle('active', tab === 'projects');
|
|
69
81
|
document.getElementById('tabActivity').classList.toggle('active', tab === 'activity');
|
|
70
82
|
document.getElementById('viewProjects').classList.toggle('active', tab === 'projects');
|
|
@@ -172,11 +184,20 @@ export function getWebUIScript(initialLang) {
|
|
|
172
184
|
</div>
|
|
173
185
|
\` : ''}
|
|
174
186
|
|
|
175
|
-
\${item.
|
|
187
|
+
\${pinnedTodos(item).length ? \`
|
|
176
188
|
<div class="section-box">
|
|
177
189
|
<div class="section-title next-steps">\${escapeHtml(t('nextStepsTitle'))}</div>
|
|
178
190
|
<ul class="bullet-list">
|
|
179
|
-
\${item.
|
|
191
|
+
\${pinnedTodos(item).map(todo => \`<li><strong>\${escapeHtml(todo.title)}</strong> <span class="status-badge status-\${escapeHtml(todo.status)}">\${escapeHtml(t('todo_' + todo.status))}</span> <code>\${escapeHtml(todo.uid)}</code></li>\`).join('')}
|
|
192
|
+
</ul>
|
|
193
|
+
</div>
|
|
194
|
+
\` : ''}
|
|
195
|
+
|
|
196
|
+
\${!item.nextTodoUids && item.nextSteps && item.nextSteps.length ? \`
|
|
197
|
+
<div class="section-box">
|
|
198
|
+
<div class="section-title next-steps">\${escapeHtml(t('nextStepsTitle'))}</div>
|
|
199
|
+
<ul class="bullet-list">
|
|
200
|
+
\${item.nextSteps.map((n, index) => todosData.some(todo => todo.entryUid === (item.nextStepsSourceUid || item.uid) && todo.stepIndex === index) ? '' : \`<li>\${escapeHtml(n)} <button class="btn btn-sm" data-action="convert-todo" data-entry="\${escapeHtml(item.uid)}" data-index="\${index}">\${escapeHtml(t('convertTodo'))}</button></li>\`).join('')}
|
|
180
201
|
</ul>
|
|
181
202
|
</div>
|
|
182
203
|
\` : ''}
|
|
@@ -196,6 +217,11 @@ export function getWebUIScript(initialLang) {
|
|
|
196
217
|
</div>
|
|
197
218
|
\` : ''}
|
|
198
219
|
\${item.completedBy ? '</details>' : ''}
|
|
220
|
+
<div class="section-box">
|
|
221
|
+
<button class="btn" data-action="add-todo" data-entry="\${escapeHtml(item.uid)}">\${escapeHtml(t('addTodo'))}</button>
|
|
222
|
+
\${status !== 'completed' ? \`<button class="btn" data-action="complete-entry" data-entry="\${escapeHtml(item.uid)}">\${escapeHtml(t('complete'))}</button>\` : ''}
|
|
223
|
+
\${todosData.filter(todo => (todo.entryUid === item.uid || todo.entryUid === item.nextStepsSourceUid) && !(item.nextTodoUids || []).includes(todo.uid)).map(renderTodo).join('')}
|
|
224
|
+
</div>
|
|
199
225
|
</div>
|
|
200
226
|
\`;
|
|
201
227
|
}
|
|
@@ -214,6 +240,7 @@ export function getWebUIScript(initialLang) {
|
|
|
214
240
|
const query = document.getElementById('searchInput').value.trim().toLowerCase();
|
|
215
241
|
const selectedDate = document.getElementById('activityDateInput').value;
|
|
216
242
|
|
|
243
|
+
renderTodos(query);
|
|
217
244
|
// Render Active Filter Chip
|
|
218
245
|
const filterBar = document.getElementById('filterBarContainer');
|
|
219
246
|
const chip = document.getElementById('activeFilterChip');
|
|
@@ -265,12 +292,14 @@ export function getWebUIScript(initialLang) {
|
|
|
265
292
|
<div class="latest-summary">
|
|
266
293
|
<div class="latest-summary-title">\${escapeHtml(t('latestUpdate'))} (\${formatTime(p.latestStatus.occurredAt)})</div>
|
|
267
294
|
<div>\${escapeHtml(p.latestStatus.summary)}</div>
|
|
295
|
+
\${Array.isArray(p.latestStatus.nextTodos) && p.latestStatus.nextTodos.length ? \`<ul class="bullet-list">\${p.latestStatus.nextTodos.map(todo => \`<li>\${escapeHtml(todo.title)} <span class="status-badge status-\${escapeHtml(todo.status)}">\${escapeHtml(t('todo_' + todo.status))}</span></li>\`).join('')}</ul>\` : ''}
|
|
268
296
|
</div>
|
|
269
297
|
\` : ''}
|
|
270
298
|
</div>
|
|
271
299
|
</div>
|
|
272
300
|
<div class="card-footer">
|
|
273
301
|
<span>\${escapeHtml(t('createdLabel'))} \${formatTime(p.createdAt)}</span>
|
|
302
|
+
<button class="btn" data-action="project-todos" data-project="\${escapeHtml(p.project)}">\${escapeHtml(t('viewTodos'))}</button>
|
|
274
303
|
<a class="link-btn" data-action="filter-project" data-project="\${escapeHtml(p.project)}">\${escapeHtml(t('viewActivity'))}</a>
|
|
275
304
|
</div>
|
|
276
305
|
</div>
|
|
@@ -352,5 +381,79 @@ export function getWebUIScript(initialLang) {
|
|
|
352
381
|
.replace(/'/g, ''');
|
|
353
382
|
}
|
|
354
383
|
|
|
384
|
+
|
|
385
|
+
function projectOptions(selected) {
|
|
386
|
+
return '<option value="">' + escapeHtml(t('noProject')) + '</option>' + projectsData.map(p =>
|
|
387
|
+
'<option value="' + escapeHtml(p.project) + '"' + (selected === p.project ? ' selected' : '') + '>' + escapeHtml(p.displayName) + '</option>').join('');
|
|
388
|
+
}
|
|
389
|
+
function pinnedTodos(item) {
|
|
390
|
+
if (!Array.isArray(item.nextTodoUids)) return [];
|
|
391
|
+
const embedded = Array.isArray(item.nextTodos) ? item.nextTodos : [];
|
|
392
|
+
return item.nextTodoUids.map((uid) => {
|
|
393
|
+
const live = todosData.find((todo) => todo.uid === uid);
|
|
394
|
+
const snapshot = embedded.find((todo) => todo.uid === uid);
|
|
395
|
+
return live || snapshot || { uid, title: uid, status: 'open', project: null };
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
function renderTodo(todo) {
|
|
399
|
+
return '<div class="section-box"><strong>' + escapeHtml(todo.title) + '</strong> · ' + escapeHtml(t('todo_' + todo.status)) +
|
|
400
|
+
(todo.project ? ' · ' + escapeHtml(todo.project) : '') +
|
|
401
|
+
(todo.entryUid ? ' <a class="link-btn" href="?entry=' + encodeURIComponent(todo.entryUid) + '">' + escapeHtml(t('sourceEntry')) + '</a>' :
|
|
402
|
+
' <label>' + escapeHtml(t('chooseProject')) + ' <select data-todo-project="' + escapeHtml(todo.uid) + '">' + projectOptions(todo.project) + '</select></label>') +
|
|
403
|
+
' <button class="btn" data-action="todo-status" data-uid="' + escapeHtml(todo.uid) + '" data-status="' + (todo.status === 'open' ? 'completed' : 'open') + '">' + escapeHtml(t(todo.status === 'open' ? 'complete' : 'reopen')) + '</button>' +
|
|
404
|
+
(todo.status === 'open' ? ' <button class="btn" data-action="todo-status" data-uid="' + escapeHtml(todo.uid) + '" data-status="cancelled">' + escapeHtml(t('cancelTodo')) + '</button>' : '') + '</div>';
|
|
405
|
+
}
|
|
406
|
+
function renderTodos(query) {
|
|
407
|
+
const select = document.getElementById('todoProject');
|
|
408
|
+
select.innerHTML = projectOptions(select.value);
|
|
409
|
+
const todos = todosData.filter(todo => (!activeProjectFilter || todo.project === activeProjectFilter) &&
|
|
410
|
+
(!query || (todo.title + ' ' + (todo.project || '')).toLowerCase().includes(query)));
|
|
411
|
+
document.getElementById('todosContainer').innerHTML = todos.length ? todos.map(renderTodo).join('') : '<p>' + escapeHtml(t('noTodos')) + '</p>';
|
|
412
|
+
}
|
|
413
|
+
async function mutate(path, data) {
|
|
414
|
+
const response = await fetch(path, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data)});
|
|
415
|
+
const result = await response.json();
|
|
416
|
+
if (!response.ok) throw new Error(result.error || 'Request failed');
|
|
417
|
+
if (!await fetchData()) { const error = new Error(t('savedRefreshError')); error.saved = true; throw error; }
|
|
418
|
+
}
|
|
419
|
+
async function runAction(element, action) {
|
|
420
|
+
if (element.disabled) return;
|
|
421
|
+
element.disabled = true;
|
|
422
|
+
document.getElementById('actionError').textContent = '';
|
|
423
|
+
try { await action(); }
|
|
424
|
+
catch (error) { document.getElementById('actionError').textContent = error.message; }
|
|
425
|
+
finally { element.disabled = false; }
|
|
426
|
+
}
|
|
427
|
+
document.getElementById('todoForm').addEventListener('submit', function(event) {
|
|
428
|
+
event.preventDefault();
|
|
429
|
+
const button = this.querySelector('button');
|
|
430
|
+
runAction(button, async () => {
|
|
431
|
+
try {
|
|
432
|
+
await mutate('/api/todos', {title: document.getElementById('todoTitle').value, project: document.getElementById('todoProject').value || null});
|
|
433
|
+
document.getElementById('todoTitle').value = '';
|
|
434
|
+
} catch (error) {
|
|
435
|
+
if (error.saved) document.getElementById('todoTitle').value = '';
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
document.addEventListener('change', function(event) {
|
|
441
|
+
const select = event.target.closest('[data-todo-project]');
|
|
442
|
+
if (select) runAction(select, () => mutate('/api/todos/' + select.dataset.todoProject, {project: select.value || null}));
|
|
443
|
+
});
|
|
444
|
+
document.addEventListener('click', function(event) {
|
|
445
|
+
const button = event.target.closest('[data-action]');
|
|
446
|
+
if (!button) return;
|
|
447
|
+
const data = button.dataset;
|
|
448
|
+
if (data.action === 'project-todos') { activeProjectFilter = data.project; switchTab('todos'); render(); document.getElementById('todoProject').value = data.project; return; }
|
|
449
|
+
if (data.action === 'complete-entry') runAction(button, () => mutate('/api/entries/' + data.entry + '/complete', {}));
|
|
450
|
+
if (data.action === 'convert-todo') runAction(button, () => mutate('/api/entries/' + data.entry + '/todos', {index: Number(data.index)}));
|
|
451
|
+
if (data.action === 'todo-status') runAction(button, () => mutate('/api/todos/' + data.uid, {status: data.status}));
|
|
452
|
+
if (data.action === 'add-todo') {
|
|
453
|
+
const title = window.prompt(t('todoTitle'));
|
|
454
|
+
if (title && title.trim()) runAction(button, () => mutate('/api/entries/' + data.entry + '/todos', {title}));
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
355
458
|
fetchData();`;
|
|
356
459
|
}
|
package/dist/web/styles.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const WEB_UI_STYLES = " :root {\n --bg: #0d1117;\n --card-bg: #161b22;\n --border: #30363d;\n --text: #c9d1d9;\n --text-muted: #8b949e;\n --heading: #f0f6fc;\n --accent: #58a6ff;\n --accent-hover: #79c0ff;\n --success: #3fb950;\n --warning: #d29922;\n --danger: #f85149;\n --purple: #bc8cff;\n --cyan: #39c5cf;\n --radius: 8px;\n }\n\n * {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg);\n color: var(--text);\n line-height: 1.5;\n padding: 0;\n margin: 0;\n min-height: 100vh;\n }\n\n header {\n background-color: var(--card-bg);\n border-bottom: 1px solid var(--border);\n position: sticky;\n top: 0;\n z-index: 100;\n padding: 1rem 1.5rem;\n }\n\n .header-container {\n max-width: 1200px;\n margin: 0 auto;\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n }\n\n .brand {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n }\n\n .logo-icon {\n width: 32px;\n height: 32px;\n background: linear-gradient(135deg, var(--accent), var(--purple));\n border-radius: 8px;\n display: flex;\n align-items: center;\n justify-content: center;\n color: #fff;\n font-weight: bold;\n font-size: 1.1rem;\n }\n\n h1 {\n font-size: 1.25rem;\n font-weight: 600;\n color: var(--heading);\n }\n\n .header-right {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n flex-wrap: wrap;\n }\n\n .stats-bar {\n display: flex;\n gap: 0.75rem;\n flex-wrap: wrap;\n }\n\n .lang-switch {\n display: inline-flex;\n gap: 0.2rem;\n background: var(--bg);\n padding: 0.2rem;\n border-radius: 6px;\n border: 1px solid var(--border);\n }\n\n .lang-btn {\n background: transparent;\n border: none;\n color: var(--text-muted);\n padding: 0.25rem 0.65rem;\n font-size: 0.8rem;\n font-weight: 500;\n border-radius: 4px;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .lang-btn:hover {\n color: var(--heading);\n }\n\n .lang-btn.active {\n background: var(--accent);\n color: #ffffff;\n font-weight: 600;\n }\n\n .stat-pill {\n background: rgba(255, 255, 255, 0.05);\n border: 1px solid var(--border);\n border-radius: 20px;\n padding: 0.25rem 0.75rem;\n font-size: 0.85rem;\n display: flex;\n align-items: center;\n gap: 0.35rem;\n }\n\n .stat-val {\n font-weight: 600;\n color: var(--heading);\n }\n\n .controls {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n flex-wrap: wrap;\n width: 100%;\n max-width: 1200px;\n margin: 1rem auto 0 auto;\n padding: 0 1.5rem;\n }\n\n .search-box {\n flex: 1;\n min-width: 240px;\n position: relative;\n }\n\n .search-input {\n width: 100%;\n background: var(--card-bg);\n border: 1px solid var(--border);\n color: var(--text);\n padding: 0.5rem 0.75rem 0.5rem 2.25rem;\n border-radius: var(--radius);\n font-size: 0.9rem;\n outline: none;\n transition: border-color 0.2s;\n }\n\n .search-input:focus {\n border-color: var(--accent);\n }\n\n .search-icon {\n position: absolute;\n left: 0.75rem;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-muted);\n pointer-events: none;\n font-size: 0.85rem;\n }\n\n .nav-tabs {\n display: flex;\n gap: 0.5rem;\n background: var(--card-bg);\n padding: 0.25rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n }\n\n .tab-btn {\n background: transparent;\n border: none;\n color: var(--text-muted);\n padding: 0.4rem 1rem;\n font-size: 0.875rem;\n font-weight: 500;\n border-radius: 6px;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .tab-btn:hover {\n color: var(--heading);\n }\n\n .tab-btn.active {\n background: var(--accent);\n color: #ffffff;\n }\n\n .btn[hidden] { display: none !important; }\n .btn {\n background: var(--card-bg);\n border: 1px solid var(--border);\n color: var(--text);\n padding: 0.45rem 0.85rem;\n border-radius: var(--radius);\n font-size: 0.875rem;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n transition: all 0.2s;\n }\n\n .btn:hover {\n border-color: var(--text-muted);\n color: var(--heading);\n }\n\n .btn-sm {\n padding: 0.3rem 0.6rem;\n font-size: 0.8rem;\n }\n\n .btn-primary {\n background: var(--accent);\n color: #ffffff;\n border-color: var(--accent);\n margin-top: 0.75rem;\n }\n\n .btn-primary:hover {\n background: var(--accent-hover);\n color: #ffffff;\n }\n\n main {\n max-width: 1200px;\n margin: 1.5rem auto;\n padding: 0 1.5rem;\n }\n\n .view-panel {\n display: none;\n }\n\n .view-panel.active {\n display: block;\n }\n\n /* Activity Feed Toolbar & Grouping Toggle */\n .activity-toolbar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 0.75rem 1rem;\n margin-bottom: 1.25rem;\n flex-wrap: wrap;\n }\n\n .date-picker-wrapper {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n font-size: 0.9rem;\n color: var(--heading);\n font-weight: 500;\n flex-wrap: wrap;\n }\n\n .date-input {\n background: var(--bg);\n border: 1px solid var(--border);\n color: var(--heading);\n padding: 0.35rem 0.65rem;\n border-radius: 6px;\n font-size: 0.875rem;\n outline: none;\n font-family: inherit;\n }\n\n .date-input:focus {\n border-color: var(--accent);\n }\n\n .view-mode-toggle {\n display: flex;\n gap: 0.25rem;\n background: var(--bg);\n padding: 0.2rem;\n border-radius: 6px;\n border: 1px solid var(--border);\n }\n\n .mode-btn {\n background: transparent;\n border: none;\n color: var(--text-muted);\n padding: 0.3rem 0.75rem;\n font-size: 0.8rem;\n font-weight: 500;\n border-radius: 4px;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .mode-btn:hover {\n color: var(--heading);\n }\n\n .mode-btn.active {\n background: var(--accent);\n color: #ffffff;\n font-weight: 600;\n }\n\n /* Project Grouping Container */\n .project-group-card {\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.25rem;\n margin-bottom: 1.25rem;\n }\n\n .project-group-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n border-bottom: 1px solid var(--border);\n padding-bottom: 0.75rem;\n margin-bottom: 1rem;\n }\n\n .project-group-title {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n font-size: 1.1rem;\n font-weight: 600;\n color: var(--heading);\n }\n\n .project-group-count {\n font-size: 0.8rem;\n color: var(--text-muted);\n background: rgba(255, 255, 255, 0.06);\n padding: 0.15rem 0.55rem;\n border-radius: 12px;\n }\n\n /* Projects Grid */\n .projects-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));\n gap: 1.25rem;\n }\n\n .project-card {\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.25rem;\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n transition: transform 0.15s, border-color 0.15s;\n }\n\n .project-card:hover {\n border-color: var(--text-muted);\n transform: translateY(-2px);\n }\n\n .card-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 0.75rem;\n margin-bottom: 0.75rem;\n }\n\n .project-title {\n font-size: 1.1rem;\n font-weight: 600;\n color: var(--heading);\n }\n\n .project-slug {\n font-size: 0.8rem;\n color: var(--text-muted);\n font-family: monospace;\n }\n\n .completion-evidence { margin: 12px 0; line-height: 1.7; }\n details > summary { cursor: pointer; margin: 12px 0; }\n .status-badge {\n font-size: 0.75rem;\n font-weight: 600;\n text-transform: uppercase;\n padding: 0.2rem 0.55rem;\n border-radius: 12px;\n display: inline-block;\n white-space: nowrap;\n }\n\n .status-active { background: rgba(63, 185, 80, 0.15); color: var(--success); border: 1px solid rgba(63, 185, 80, 0.3); }\n .status-planned { background: rgba(88, 166, 255, 0.15); color: var(--accent); border: 1px solid rgba(88, 166, 255, 0.3); }\n .status-idea { background: rgba(188, 140, 255, 0.15); color: var(--purple); border: 1px solid rgba(188, 140, 255, 0.3); }\n .status-blocked { background: rgba(248, 81, 73, 0.15); color: var(--danger); border: 1px solid rgba(248, 81, 73, 0.3); }\n .status-paused { background: rgba(210, 153, 34, 0.15); color: var(--warning); border: 1px solid rgba(210, 153, 34, 0.3); }\n .status-completed { background: rgba(57, 197, 207, 0.15); color: var(--cyan); border: 1px solid rgba(57, 197, 207, 0.3); }\n .status-archived { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); border: 1px solid rgba(139, 148, 158, 0.3); }\n\n .card-body {\n margin-bottom: 1rem;\n font-size: 0.9rem;\n }\n\n .card-description {\n color: var(--text);\n margin-bottom: 0.5rem;\n }\n\n .card-notes {\n color: var(--text-muted);\n font-size: 0.85rem;\n font-style: italic;\n border-left: 2px solid var(--border);\n padding-left: 0.5rem;\n margin-top: 0.5rem;\n }\n\n .latest-summary {\n background: rgba(0, 0, 0, 0.2);\n padding: 0.6rem;\n border-radius: 6px;\n margin-top: 0.75rem;\n font-size: 0.85rem;\n }\n\n .latest-summary-title {\n font-size: 0.75rem;\n color: var(--text-muted);\n text-transform: uppercase;\n margin-bottom: 0.25rem;\n }\n\n .card-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n font-size: 0.85rem;\n color: var(--text-muted);\n border-top: 1px solid var(--border);\n padding-top: 0.75rem;\n margin-top: auto;\n }\n\n .link-btn {\n color: var(--accent);\n text-decoration: none;\n cursor: pointer;\n font-weight: 500;\n }\n\n .link-btn:hover {\n text-decoration: underline;\n }\n\n /* Timeline View */\n .timeline {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n position: relative;\n }\n\n .timeline-item {\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.25rem;\n position: relative;\n }\n\n .timeline-header {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 0.5rem;\n margin-bottom: 0.75rem;\n }\n\n .timeline-meta {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n flex-wrap: wrap;\n }\n\n .project-tag {\n background: rgba(88, 166, 255, 0.1);\n color: var(--accent);\n border: 1px solid rgba(88, 166, 255, 0.2);\n padding: 0.15rem 0.5rem;\n border-radius: 4px;\n font-family: monospace;\n font-size: 0.85rem;\n cursor: pointer;\n }\n\n .time-stamp {\n font-size: 0.8rem;\n color: var(--text-muted);\n }\n\n .source-badge {\n font-size: 0.75rem;\n color: var(--text-muted);\n background: rgba(255, 255, 255, 0.05);\n padding: 0.1rem 0.4rem;\n border-radius: 4px;\n }\n\n .entry-summary {\n font-size: 1rem;\n font-weight: 500;\n color: var(--heading);\n margin-bottom: 0.5rem;\n }\n\n .entry-progress {\n font-size: 0.9rem;\n color: var(--text);\n margin-bottom: 0.75rem;\n }\n\n .section-box {\n margin-top: 0.5rem;\n font-size: 0.875rem;\n }\n\n .section-title {\n font-weight: 600;\n font-size: 0.8rem;\n text-transform: uppercase;\n margin-bottom: 0.25rem;\n display: flex;\n align-items: center;\n gap: 0.25rem;\n }\n\n .section-title.blockers { color: var(--danger); }\n .section-title.next-steps { color: var(--accent); }\n .section-title.decisions { color: var(--purple); }\n\n .bullet-list {\n list-style: disc inside;\n color: var(--text);\n padding-left: 0.25rem;\n }\n\n .bullet-list li {\n margin-bottom: 0.2rem;\n }\n\n .tags-container {\n display: flex;\n flex-wrap: wrap;\n gap: 0.35rem;\n margin-top: 0.75rem;\n }\n\n .pill-tag {\n background: rgba(255, 255, 255, 0.06);\n border: 1px solid var(--border);\n color: var(--text-muted);\n font-size: 0.75rem;\n padding: 0.1rem 0.45rem;\n border-radius: 12px;\n }\n\n .empty-state {\n text-align: center;\n padding: 3rem 1rem;\n color: var(--text-muted);\n }\n\n .empty-state h3 {\n color: var(--heading);\n margin-bottom: 0.5rem;\n }\n\n .filter-bar {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n margin-bottom: 1rem;\n font-size: 0.875rem;\n color: var(--text-muted);\n }\n\n .filter-chip {\n background: rgba(88, 166, 255, 0.15);\n color: var(--accent);\n padding: 0.2rem 0.6rem;\n border-radius: 12px;\n display: inline-flex;\n align-items: center;\n gap: 0.3rem;\n }\n\n .filter-chip button {\n background: none;\n border: none;\n color: var(--accent);\n cursor: pointer;\n font-weight: bold;\n }";
|
|
1
|
+
export declare const WEB_UI_STYLES = " :root {\n --bg: #0d1117;\n --card-bg: #161b22;\n --border: #30363d;\n --text: #c9d1d9;\n --text-muted: #8b949e;\n --heading: #f0f6fc;\n --accent: #58a6ff;\n --accent-hover: #79c0ff;\n --success: #3fb950;\n --warning: #d29922;\n --danger: #f85149;\n --purple: #bc8cff;\n --cyan: #39c5cf;\n --radius: 8px;\n }\n\n * {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n background-color: var(--bg);\n color: var(--text);\n line-height: 1.5;\n padding: 0;\n margin: 0;\n min-height: 100vh;\n }\n\n header {\n background-color: var(--card-bg);\n border-bottom: 1px solid var(--border);\n position: sticky;\n top: 0;\n z-index: 100;\n padding: 1rem 1.5rem;\n }\n\n .header-container {\n max-width: 1200px;\n margin: 0 auto;\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n }\n\n .brand {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n }\n\n .logo-icon {\n width: 32px;\n height: 32px;\n background: linear-gradient(135deg, var(--accent), var(--purple));\n border-radius: 8px;\n display: flex;\n align-items: center;\n justify-content: center;\n color: #fff;\n font-weight: bold;\n font-size: 1.1rem;\n }\n\n h1 {\n font-size: 1.25rem;\n font-weight: 600;\n color: var(--heading);\n }\n\n .header-right {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n flex-wrap: wrap;\n }\n\n .stats-bar {\n display: flex;\n gap: 0.75rem;\n flex-wrap: wrap;\n }\n\n .lang-switch {\n display: inline-flex;\n gap: 0.2rem;\n background: var(--bg);\n padding: 0.2rem;\n border-radius: 6px;\n border: 1px solid var(--border);\n }\n\n .lang-btn {\n background: transparent;\n border: none;\n color: var(--text-muted);\n padding: 0.25rem 0.65rem;\n font-size: 0.8rem;\n font-weight: 500;\n border-radius: 4px;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .lang-btn:hover {\n color: var(--heading);\n }\n\n .lang-btn.active {\n background: var(--accent);\n color: #ffffff;\n font-weight: 600;\n }\n\n .stat-pill {\n background: rgba(255, 255, 255, 0.05);\n border: 1px solid var(--border);\n border-radius: 20px;\n padding: 0.25rem 0.75rem;\n font-size: 0.85rem;\n display: flex;\n align-items: center;\n gap: 0.35rem;\n }\n\n .stat-val {\n font-weight: 600;\n color: var(--heading);\n }\n\n .controls {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n flex-wrap: wrap;\n width: 100%;\n max-width: 1200px;\n margin: 1rem auto 0 auto;\n padding: 0 1.5rem;\n }\n\n .search-box {\n flex: 1;\n min-width: 240px;\n position: relative;\n }\n\n .search-input {\n width: 100%;\n background: var(--card-bg);\n border: 1px solid var(--border);\n color: var(--text);\n padding: 0.5rem 0.75rem 0.5rem 2.25rem;\n border-radius: var(--radius);\n font-size: 0.9rem;\n outline: none;\n transition: border-color 0.2s;\n }\n\n .search-input:focus {\n border-color: var(--accent);\n }\n\n .search-icon {\n position: absolute;\n left: 0.75rem;\n top: 50%;\n transform: translateY(-50%);\n color: var(--text-muted);\n pointer-events: none;\n font-size: 0.85rem;\n }\n\n .nav-tabs {\n display: flex;\n gap: 0.5rem;\n background: var(--card-bg);\n padding: 0.25rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n }\n\n .tab-btn {\n background: transparent;\n border: none;\n color: var(--text-muted);\n padding: 0.4rem 1rem;\n font-size: 0.875rem;\n font-weight: 500;\n border-radius: 6px;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .tab-btn:hover {\n color: var(--heading);\n }\n\n .tab-btn.active {\n background: var(--accent);\n color: #ffffff;\n }\n\n .btn[hidden] { display: none !important; }\n .btn {\n background: var(--card-bg);\n border: 1px solid var(--border);\n color: var(--text);\n padding: 0.45rem 0.85rem;\n border-radius: var(--radius);\n font-size: 0.875rem;\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n transition: all 0.2s;\n }\n\n .btn:hover {\n border-color: var(--text-muted);\n color: var(--heading);\n }\n\n .btn-sm {\n padding: 0.3rem 0.6rem;\n font-size: 0.8rem;\n }\n\n .btn-primary {\n background: var(--accent);\n color: #ffffff;\n border-color: var(--accent);\n margin-top: 0.75rem;\n }\n\n .btn-primary:hover {\n background: var(--accent-hover);\n color: #ffffff;\n }\n\n main {\n max-width: 1200px;\n margin: 1.5rem auto;\n padding: 0 1.5rem;\n }\n\n .view-panel {\n display: none;\n }\n\n .view-panel.active {\n display: block;\n }\n\n /* Activity Feed Toolbar & Grouping Toggle */\n .activity-toolbar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 0.75rem 1rem;\n margin-bottom: 1.25rem;\n flex-wrap: wrap;\n }\n\n .date-picker-wrapper {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n font-size: 0.9rem;\n color: var(--heading);\n font-weight: 500;\n flex-wrap: wrap;\n }\n\n .date-input {\n background: var(--bg);\n border: 1px solid var(--border);\n color: var(--heading);\n padding: 0.35rem 0.65rem;\n border-radius: 6px;\n font-size: 0.875rem;\n outline: none;\n font-family: inherit;\n }\n\n .date-input:focus {\n border-color: var(--accent);\n }\n\n .view-mode-toggle {\n display: flex;\n gap: 0.25rem;\n background: var(--bg);\n padding: 0.2rem;\n border-radius: 6px;\n border: 1px solid var(--border);\n }\n\n .mode-btn {\n background: transparent;\n border: none;\n color: var(--text-muted);\n padding: 0.3rem 0.75rem;\n font-size: 0.8rem;\n font-weight: 500;\n border-radius: 4px;\n cursor: pointer;\n transition: all 0.2s;\n }\n\n .mode-btn:hover {\n color: var(--heading);\n }\n\n .mode-btn.active {\n background: var(--accent);\n color: #ffffff;\n font-weight: 600;\n }\n\n /* Project Grouping Container */\n .project-group-card {\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.25rem;\n margin-bottom: 1.25rem;\n }\n\n .project-group-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n border-bottom: 1px solid var(--border);\n padding-bottom: 0.75rem;\n margin-bottom: 1rem;\n }\n\n .project-group-title {\n display: flex;\n align-items: center;\n gap: 0.6rem;\n font-size: 1.1rem;\n font-weight: 600;\n color: var(--heading);\n }\n\n .project-group-count {\n font-size: 0.8rem;\n color: var(--text-muted);\n background: rgba(255, 255, 255, 0.06);\n padding: 0.15rem 0.55rem;\n border-radius: 12px;\n }\n\n /* Projects Grid */\n .projects-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));\n gap: 1.25rem;\n }\n\n .project-card {\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.25rem;\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n transition: transform 0.15s, border-color 0.15s;\n }\n\n .project-card:hover {\n border-color: var(--text-muted);\n transform: translateY(-2px);\n }\n\n .card-header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 0.75rem;\n margin-bottom: 0.75rem;\n }\n\n .project-title {\n font-size: 1.1rem;\n font-weight: 600;\n color: var(--heading);\n }\n\n .project-slug {\n font-size: 0.8rem;\n color: var(--text-muted);\n font-family: monospace;\n }\n\n .completion-evidence { margin: 12px 0; line-height: 1.7; }\n details > summary { cursor: pointer; margin: 12px 0; }\n .status-badge {\n font-size: 0.75rem;\n font-weight: 600;\n text-transform: uppercase;\n padding: 0.2rem 0.55rem;\n border-radius: 12px;\n display: inline-block;\n white-space: nowrap;\n }\n\n .status-active { background: rgba(63, 185, 80, 0.15); color: var(--success); border: 1px solid rgba(63, 185, 80, 0.3); }\n .status-planned { background: rgba(88, 166, 255, 0.15); color: var(--accent); border: 1px solid rgba(88, 166, 255, 0.3); }\n .status-idea { background: rgba(188, 140, 255, 0.15); color: var(--purple); border: 1px solid rgba(188, 140, 255, 0.3); }\n .status-blocked { background: rgba(248, 81, 73, 0.15); color: var(--danger); border: 1px solid rgba(248, 81, 73, 0.3); }\n .status-paused { background: rgba(210, 153, 34, 0.15); color: var(--warning); border: 1px solid rgba(210, 153, 34, 0.3); }\n .status-completed { background: rgba(57, 197, 207, 0.15); color: var(--cyan); border: 1px solid rgba(57, 197, 207, 0.3); }\n .status-archived { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); border: 1px solid rgba(139, 148, 158, 0.3); }\n .status-open { background: rgba(63, 185, 80, 0.15); color: var(--success); border: 1px solid rgba(63, 185, 80, 0.3); }\n .status-cancelled { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); border: 1px solid rgba(139, 148, 158, 0.3); }\n\n .card-body {\n margin-bottom: 1rem;\n font-size: 0.9rem;\n }\n\n .card-description {\n color: var(--text);\n margin-bottom: 0.5rem;\n }\n\n .card-notes {\n color: var(--text-muted);\n font-size: 0.85rem;\n font-style: italic;\n border-left: 2px solid var(--border);\n padding-left: 0.5rem;\n margin-top: 0.5rem;\n }\n\n .latest-summary {\n background: rgba(0, 0, 0, 0.2);\n padding: 0.6rem;\n border-radius: 6px;\n margin-top: 0.75rem;\n font-size: 0.85rem;\n }\n\n .latest-summary-title {\n font-size: 0.75rem;\n color: var(--text-muted);\n text-transform: uppercase;\n margin-bottom: 0.25rem;\n }\n\n .card-footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n font-size: 0.85rem;\n color: var(--text-muted);\n border-top: 1px solid var(--border);\n padding-top: 0.75rem;\n margin-top: auto;\n }\n\n .link-btn {\n color: var(--accent);\n text-decoration: none;\n cursor: pointer;\n font-weight: 500;\n }\n\n .link-btn:hover {\n text-decoration: underline;\n }\n\n /* Timeline View */\n .timeline {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n position: relative;\n }\n\n .timeline-item {\n background: var(--card-bg);\n border: 1px solid var(--border);\n border-radius: var(--radius);\n padding: 1.25rem;\n position: relative;\n }\n\n .timeline-header {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n justify-content: space-between;\n gap: 0.5rem;\n margin-bottom: 0.75rem;\n }\n\n .timeline-meta {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n flex-wrap: wrap;\n }\n\n .project-tag {\n background: rgba(88, 166, 255, 0.1);\n color: var(--accent);\n border: 1px solid rgba(88, 166, 255, 0.2);\n padding: 0.15rem 0.5rem;\n border-radius: 4px;\n font-family: monospace;\n font-size: 0.85rem;\n cursor: pointer;\n }\n\n .time-stamp {\n font-size: 0.8rem;\n color: var(--text-muted);\n }\n\n .source-badge {\n font-size: 0.75rem;\n color: var(--text-muted);\n background: rgba(255, 255, 255, 0.05);\n padding: 0.1rem 0.4rem;\n border-radius: 4px;\n }\n\n .entry-summary {\n font-size: 1rem;\n font-weight: 500;\n color: var(--heading);\n margin-bottom: 0.5rem;\n }\n\n .entry-progress {\n font-size: 0.9rem;\n color: var(--text);\n margin-bottom: 0.75rem;\n }\n\n .section-box {\n margin-top: 0.5rem;\n font-size: 0.875rem;\n }\n\n .section-title {\n font-weight: 600;\n font-size: 0.8rem;\n text-transform: uppercase;\n margin-bottom: 0.25rem;\n display: flex;\n align-items: center;\n gap: 0.25rem;\n }\n\n .section-title.blockers { color: var(--danger); }\n .section-title.next-steps { color: var(--accent); }\n .section-title.decisions { color: var(--purple); }\n\n .bullet-list {\n list-style: disc inside;\n color: var(--text);\n padding-left: 0.25rem;\n }\n\n .bullet-list li {\n margin-bottom: 0.2rem;\n }\n\n .tags-container {\n display: flex;\n flex-wrap: wrap;\n gap: 0.35rem;\n margin-top: 0.75rem;\n }\n\n .pill-tag {\n background: rgba(255, 255, 255, 0.06);\n border: 1px solid var(--border);\n color: var(--text-muted);\n font-size: 0.75rem;\n padding: 0.1rem 0.45rem;\n border-radius: 12px;\n }\n\n .empty-state {\n text-align: center;\n padding: 3rem 1rem;\n color: var(--text-muted);\n }\n\n .empty-state h3 {\n color: var(--heading);\n margin-bottom: 0.5rem;\n }\n\n .filter-bar {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n margin-bottom: 1rem;\n font-size: 0.875rem;\n color: var(--text-muted);\n }\n\n .filter-chip {\n background: rgba(88, 166, 255, 0.15);\n color: var(--accent);\n padding: 0.2rem 0.6rem;\n border-radius: 12px;\n display: inline-flex;\n align-items: center;\n gap: 0.3rem;\n }\n\n .filter-chip button {\n background: none;\n border: none;\n color: var(--accent);\n cursor: pointer;\n font-weight: bold;\n }";
|
package/dist/web/styles.js
CHANGED
|
@@ -426,6 +426,8 @@ export const WEB_UI_STYLES = ` :root {
|
|
|
426
426
|
.status-paused { background: rgba(210, 153, 34, 0.15); color: var(--warning); border: 1px solid rgba(210, 153, 34, 0.3); }
|
|
427
427
|
.status-completed { background: rgba(57, 197, 207, 0.15); color: var(--cyan); border: 1px solid rgba(57, 197, 207, 0.3); }
|
|
428
428
|
.status-archived { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); border: 1px solid rgba(139, 148, 158, 0.3); }
|
|
429
|
+
.status-open { background: rgba(63, 185, 80, 0.15); color: var(--success); border: 1px solid rgba(63, 185, 80, 0.3); }
|
|
430
|
+
.status-cancelled { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); border: 1px solid rgba(139, 148, 158, 0.3); }
|
|
429
431
|
|
|
430
432
|
.card-body {
|
|
431
433
|
margin-bottom: 1rem;
|
package/dist/web/ui.js
CHANGED
|
@@ -45,6 +45,7 @@ ${WEB_UI_STYLES}
|
|
|
45
45
|
<div class="nav-tabs">
|
|
46
46
|
<button class="tab-btn active" id="tabProjects" data-i18n="projects" onclick="switchTab('projects')">${dict.projects}</button>
|
|
47
47
|
<button class="tab-btn" id="tabActivity" data-i18n="activityFeed" onclick="switchTab('activity')">${dict.activityFeed}</button>
|
|
48
|
+
<button class="tab-btn" id="tabTodos" data-i18n="todos" onclick="switchTab('todos')">${dict.todos}</button>
|
|
48
49
|
</div>
|
|
49
50
|
<button class="btn" id="btnRefresh" data-i18n="refresh" onclick="refreshData()">${dict.refresh}</button>
|
|
50
51
|
</div>
|
|
@@ -54,6 +55,17 @@ ${WEB_UI_STYLES}
|
|
|
54
55
|
<span id="labelFilterByProject" data-i18n="filteringByProject">${dict.filteringByProject}</span> <span id="activeFilterChip" class="filter-chip"></span>
|
|
55
56
|
</div>
|
|
56
57
|
|
|
58
|
+
<div id="actionError" role="alert"></div>
|
|
59
|
+
<div id="viewTodos" class="view-panel">
|
|
60
|
+
<form id="todoForm" class="section-box">
|
|
61
|
+
<label for="todoTitle" data-i18n="todoTitle">${dict.todoTitle}</label>
|
|
62
|
+
<input id="todoTitle" class="search-input" required maxlength="2000" />
|
|
63
|
+
<label for="todoProject" data-i18n="projects">${dict.projects}</label>
|
|
64
|
+
<select id="todoProject" class="date-input"></select>
|
|
65
|
+
<button class="btn" type="submit" data-i18n="addTodo">${dict.addTodo}</button>
|
|
66
|
+
</form>
|
|
67
|
+
<div id="todosContainer"></div>
|
|
68
|
+
</div>
|
|
57
69
|
<!-- Projects View -->
|
|
58
70
|
<div id="viewProjects" class="view-panel active">
|
|
59
71
|
<div id="projectsContainer" class="projects-grid"></div>
|