local-kanban 1.6.3
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 +381 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/README.ru.md +144 -0
- package/bin/kb +306 -0
- package/bin/local-kanban +217 -0
- package/ecosystem.example.cjs +25 -0
- package/package.json +52 -0
- package/public/app.js +18 -0
- package/public/boot-fallback.js +3 -0
- package/public/drawer-sync.js +14 -0
- package/public/favicon-180.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon-64.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +6 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-192.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/index.html +138 -0
- package/public/js/board.js +698 -0
- package/public/js/chaos.js +247 -0
- package/public/js/core.js +821 -0
- package/public/js/dash.js +114 -0
- package/public/js/drawer.js +919 -0
- package/public/js/horizon.js +140 -0
- package/public/js/init.js +208 -0
- package/public/js/project.js +338 -0
- package/public/js/settings.js +905 -0
- package/public/js/sidebar.js +640 -0
- package/public/js/sse.js +386 -0
- package/public/manifest.webmanifest +32 -0
- package/public/offline.html +39 -0
- package/public/style.css +1247 -0
- package/public/sw.js +31 -0
- package/scripts/raycast/open-kanban.sh +18 -0
- package/scripts/update.js +43 -0
- package/skills/deploy/SKILL.md +70 -0
- package/skills/kanban/SKILL.md +217 -0
- package/src/backup.js +111 -0
- package/src/bus.js +10 -0
- package/src/config.js +40 -0
- package/src/db.js +321 -0
- package/src/error-handler.js +11 -0
- package/src/file-snapshot.js +22 -0
- package/src/origin-guard.js +32 -0
- package/src/pm2-services.js +31 -0
- package/src/query-params.js +17 -0
- package/src/repo-base.js +23 -0
- package/src/restore.js +153 -0
- package/src/routes/dashboard.js +132 -0
- package/src/routes/events.js +30 -0
- package/src/routes/horizons.js +66 -0
- package/src/routes/projects.js +532 -0
- package/src/routes/skills.js +116 -0
- package/src/routes/system.js +285 -0
- package/src/routes/tasks.js +477 -0
- package/src/schema.sql +135 -0
- package/src/server.js +100 -0
- package/src/skills.js +119 -0
- package/src/sync/github.js +265 -0
- package/src/sync/worker.js +136 -0
- package/src/task-shape.js +29 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
|
|
2
|
+
import { buildSelect, selVal } from './board.js';
|
|
3
|
+
import { $, DASH, api, esc, ic, seg, state, tr } from './core.js';
|
|
4
|
+
import { currentProject, overlayLayer, renderSidebar, renderTopbar, selectProject, styledAlert, styledConfirm } from './sidebar.js';
|
|
5
|
+
import { copyText, refresh } from './sse.js';
|
|
6
|
+
|
|
7
|
+
function parsePm2Services(raw) {
|
|
8
|
+
if (!raw) return [];
|
|
9
|
+
const text = String(raw).trim();
|
|
10
|
+
let list = null;
|
|
11
|
+
if (text.startsWith('[')) {
|
|
12
|
+
try { const v = JSON.parse(text); if (Array.isArray(v)) list = v; } catch { }
|
|
13
|
+
}
|
|
14
|
+
return (list || text.split(',')).map((s) => String(s).trim()).filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function openProjectSettings(proj) {
|
|
18
|
+
const p = proj || currentProject();
|
|
19
|
+
if (!p) return;
|
|
20
|
+
if (state.projSettings === p.slug) { closeProjectSettings(); return; }
|
|
21
|
+
selectProject(p.slug, { settings: true });
|
|
22
|
+
}
|
|
23
|
+
export function closeProjectSettings() {
|
|
24
|
+
if (!state.projSettings) return;
|
|
25
|
+
selectProject(state.projSettings);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const FIELDS = { path: 'ps-path', server: 'ps-server', server_path: 'ps-spath', domain: 'ps-domain' };
|
|
29
|
+
|
|
30
|
+
export async function renderProjectSettings(force) {
|
|
31
|
+
const host = $('projset');
|
|
32
|
+
if (!host) return;
|
|
33
|
+
const p = currentProject();
|
|
34
|
+
if (!p) {
|
|
35
|
+
host.innerHTML = `<div class="ps-wrap"><div class="muted">${tr('This project is gone')}</div></div>`;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!force && host.dataset.slug === p.slug) return;
|
|
39
|
+
host.dataset.slug = p.slug;
|
|
40
|
+
|
|
41
|
+
const cur = p.category || 'Other';
|
|
42
|
+
const cats = [...new Set([...state.categories.map((c) => c.name), ...state.projects.map((x) => x.category || 'Other')])]
|
|
43
|
+
.sort((a, b) => a.localeCompare(b, 'ru'));
|
|
44
|
+
if (!cats.includes(cur)) cats.unshift(cur);
|
|
45
|
+
|
|
46
|
+
host.innerHTML = `<div class="ps-wrap">
|
|
47
|
+
<div class="ps-head">
|
|
48
|
+
<div>
|
|
49
|
+
<div class="ps-title">${tr('Project settings')}</div>
|
|
50
|
+
<div class="ps-sub muted">${esc(p.name || p.slug)} · ${esc(p.prefix || '')}${p.slug !== p.name ? ` · ${esc(p.slug)}` : ''}</div>
|
|
51
|
+
</div>
|
|
52
|
+
<button class="btn-ghost" id="ps-back">${tr('Back to the board')}</button>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div class="panel ps-block">
|
|
56
|
+
<div class="panel-h">${tr('Basics')}</div>
|
|
57
|
+
<label class="pp-field">${tr('Name')}<input id="ps-name" type="text"></label>
|
|
58
|
+
<div class="pp-field">${tr('Section')}
|
|
59
|
+
<div class="pp-select" id="ps-category"></div>
|
|
60
|
+
<input id="ps-category-new" type="text" placeholder="${tr('name of the new section')}" class="hidden">
|
|
61
|
+
</div>
|
|
62
|
+
<label class="pp-field">${tr('Description')}<textarea id="ps-desc" rows="3" placeholder="${tr('what this project is, notes…')}"></textarea></label>
|
|
63
|
+
<label class="pp-field pp-toggle"><input id="ps-pinned" type="checkbox"><span>${tr('Pin to board')}</span></label>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div class="panel ps-block">
|
|
67
|
+
<div class="panel-h">${tr('Paths & deploy')}</div>
|
|
68
|
+
<div class="ps-grid">
|
|
69
|
+
<label class="pp-field">${tr('Local folder')}<input id="ps-path" type="text" placeholder="${tr('/path/to/the/folder — may be empty')}"></label>
|
|
70
|
+
<label class="pp-field">${tr('Domain')}<input id="ps-domain" type="text"></label>
|
|
71
|
+
<label class="pp-field">${tr('SSH host')}<input id="ps-server" type="text" placeholder="${tr('ssh alias or address')}"></label>
|
|
72
|
+
<label class="pp-field">${tr('Server path')}<input id="ps-spath" type="text"></label>
|
|
73
|
+
<label class="pp-field">${tr('pm2 processes (comma-separated)')}<input id="ps-pm2" type="text"></label>
|
|
74
|
+
<div class="pp-field">${tr('Deploy skill')}
|
|
75
|
+
<div class="kbsel" id="ps-skill"></div>
|
|
76
|
+
<small class="ps-hint" id="ps-skill-hint">${tr('the skill Claude follows to ship this project; “deploy” is the shared one. A new one is created in Settings → Skills.')}</small>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="panel ps-block">
|
|
82
|
+
<div class="panel-h">${tr('What Claude reads before working')}</div>
|
|
83
|
+
<div class="kbh-note muted">${tr('The project notes and the deploy skill — Claude opens them before touching the code. Paths are absolute: copy one and paste it into the chat.')}</div>
|
|
84
|
+
<div id="ps-docs" class="ps-docs">…</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="ps-actions">
|
|
88
|
+
<button id="ps-save" class="btn-primary">${tr('Save')}</button>
|
|
89
|
+
<button id="ps-archive" class="btn-ghost" title="${tr('Hide the project from the list')}">${tr('Archive')}</button>
|
|
90
|
+
<span id="ps-saved" class="muted ps-saved"></span>
|
|
91
|
+
${p.slug === 'demo' ? `<button id="ps-demo-del" class="btn-ghost pp-demo-del">${tr('Delete demo project entirely')}</button>` : ''}
|
|
92
|
+
</div>
|
|
93
|
+
</div>`;
|
|
94
|
+
|
|
95
|
+
$('ps-name').value = p.name || '';
|
|
96
|
+
$('ps-desc').value = p.description || '';
|
|
97
|
+
$('ps-pinned').checked = !!p.pinned;
|
|
98
|
+
$('ps-path').value = p.path || '';
|
|
99
|
+
$('ps-server').value = p.server || '';
|
|
100
|
+
$('ps-spath').value = p.server_path || '';
|
|
101
|
+
$('ps-pm2').value = parsePm2Services(p.pm2_services).join(', ');
|
|
102
|
+
$('ps-domain').value = p.domain || '';
|
|
103
|
+
buildSkillSelect(p, null);
|
|
104
|
+
loadSkillOptions(p);
|
|
105
|
+
buildCatSelect(cur, cats);
|
|
106
|
+
|
|
107
|
+
$('ps-back').onclick = closeProjectSettings;
|
|
108
|
+
$('ps-save').onclick = saveProjectSettings;
|
|
109
|
+
$('ps-archive').onclick = archiveProject;
|
|
110
|
+
const demoDeleteBtn = $('ps-demo-del');
|
|
111
|
+
if (demoDeleteBtn) {
|
|
112
|
+
demoDeleteBtn.onclick = async () => {
|
|
113
|
+
if (!await styledConfirm(tr('Delete the demo project and all its tasks?'), { okLabel: tr('Delete'), danger: true })) return;
|
|
114
|
+
await api('DELETE', '/api/projects/demo');
|
|
115
|
+
state.projSettings = null;
|
|
116
|
+
state.slug = DASH;
|
|
117
|
+
await refresh();
|
|
118
|
+
selectProject(state.slug);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
host.querySelectorAll('input').forEach((inp) => {
|
|
122
|
+
inp.onkeydown = (e) => { if (e.key === 'Enter') saveProjectSettings(); };
|
|
123
|
+
});
|
|
124
|
+
renderDocs(p);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildSkillSelect(p, list) {
|
|
128
|
+
const host = $('ps-skill');
|
|
129
|
+
if (!host) return;
|
|
130
|
+
const cur = p.deploy_skill || '';
|
|
131
|
+
const names = list ? list.map((s) => s.name) : (cur ? [cur] : []);
|
|
132
|
+
if (cur && !names.includes(cur)) names.unshift(cur);
|
|
133
|
+
const known = new Set((list || []).map((s) => s.name));
|
|
134
|
+
const options = [{ value: '', label: tr('not set — no deploy') }, ...names.map((name) => ({
|
|
135
|
+
value: name,
|
|
136
|
+
label: name === 'deploy' ? `${name} · ${tr('shared')}` : (list && !known.has(name) ? `${name} · ${tr('not installed')}` : name),
|
|
137
|
+
}))];
|
|
138
|
+
buildSelect(host, { value: cur, options });
|
|
139
|
+
}
|
|
140
|
+
async function loadSkillOptions(p) {
|
|
141
|
+
let info;
|
|
142
|
+
try { info = await api('GET', '/api/skills', null, { quiet: true }); }
|
|
143
|
+
catch { return; }
|
|
144
|
+
if (!$('ps-skill') || $('projset').dataset.slug !== p.slug) return;
|
|
145
|
+
buildSkillSelect(p, info.items || []);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function renderDocs(p) {
|
|
149
|
+
const box = $('ps-docs');
|
|
150
|
+
if (!box) return;
|
|
151
|
+
if (!p.path) {
|
|
152
|
+
box.innerHTML = `<div class="muted">${tr('The project has no local folder — set one above and the notes will show up here.')}</div>`;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
let info;
|
|
156
|
+
try { info = await api('GET', `/api/projects/${seg(p.slug)}/docs`, null, { quiet: true }); }
|
|
157
|
+
catch { box.innerHTML = '—'; return; }
|
|
158
|
+
if (!$('ps-docs') || $('projset').dataset.slug !== p.slug) return;
|
|
159
|
+
|
|
160
|
+
const row = (opts) => `<div class="ps-doc${opts.missing ? ' ps-doc-missing' : ''}">
|
|
161
|
+
<span class="ps-doc-ic">${ic(opts.icon, 13)}</span>
|
|
162
|
+
<span class="ps-doc-name">${esc(opts.title)}${opts.tag ? `<span class="ps-doc-tag">${esc(opts.tag)}</span>` : ''}</span>
|
|
163
|
+
<span class="ps-doc-path" title="${esc(opts.path || '')}">${esc(opts.path || tr('not there'))}</span>
|
|
164
|
+
<span class="ps-doc-act">
|
|
165
|
+
${opts.missing ? '' : `<button class="btn-icon ps-doc-open" data-open="${esc(opts.open)}" title="${tr('Open')}">${ic('eye', 13)}</button>`}
|
|
166
|
+
${opts.path ? `<button class="btn-icon ps-doc-copy" data-copy="${esc(opts.path)}" title="${tr('Copy the path')}">${ic('copy', 13)}</button>` : ''}
|
|
167
|
+
</span>
|
|
168
|
+
</div>`;
|
|
169
|
+
|
|
170
|
+
const parts = info.docs.map((d) => row({
|
|
171
|
+
icon: 'doc',
|
|
172
|
+
title: d.name,
|
|
173
|
+
path: d.path,
|
|
174
|
+
missing: !d.exists,
|
|
175
|
+
tag: d.exists ? '' : tr('no file'),
|
|
176
|
+
open: `doc:${d.name}`,
|
|
177
|
+
}));
|
|
178
|
+
if (info.skill) {
|
|
179
|
+
parts.push(row({
|
|
180
|
+
icon: 'gear',
|
|
181
|
+
title: info.skill.name,
|
|
182
|
+
path: info.skill.real_path,
|
|
183
|
+
missing: !info.skill.exists,
|
|
184
|
+
tag: info.skill.generic ? tr('shared skill') : tr('own skill'),
|
|
185
|
+
open: `skill:${info.skill.name}`,
|
|
186
|
+
}));
|
|
187
|
+
} else {
|
|
188
|
+
parts.push(`<div class="ps-doc ps-doc-missing"><span class="ps-doc-ic">${ic('gear', 13)}</span>`
|
|
189
|
+
+ `<span class="ps-doc-name">${tr('Deploy skill')}<span class="ps-doc-tag">${tr('not set')}</span></span>`
|
|
190
|
+
+ `<span class="ps-doc-path">${tr('the field above takes a skill name — “deploy” is the shared one')}</span><span class="ps-doc-act"></span></div>`);
|
|
191
|
+
}
|
|
192
|
+
box.innerHTML = parts.join('');
|
|
193
|
+
|
|
194
|
+
box.querySelectorAll('.ps-doc-copy').forEach((b) => {
|
|
195
|
+
b.onclick = async () => {
|
|
196
|
+
const was = b.innerHTML;
|
|
197
|
+
b.innerHTML = await copyText(b.dataset.copy) ? '✓' : '✕';
|
|
198
|
+
setTimeout(() => { b.innerHTML = was; }, 1200);
|
|
199
|
+
};
|
|
200
|
+
});
|
|
201
|
+
box.querySelectorAll('.ps-doc-open').forEach((b) => {
|
|
202
|
+
b.onclick = async () => {
|
|
203
|
+
const [kind, name] = b.dataset.open.split(':');
|
|
204
|
+
const url = kind === 'doc' ? `/api/projects/${seg(p.slug)}/docs/${seg(name)}` : `/api/skills/${seg(name)}`;
|
|
205
|
+
try {
|
|
206
|
+
const r = await api('GET', url);
|
|
207
|
+
openFileEditor({
|
|
208
|
+
title: kind === 'doc' ? name : `${name} · SKILL.md`,
|
|
209
|
+
path: r.real_path || r.path,
|
|
210
|
+
text: r.text || '',
|
|
211
|
+
save: (text, confirmPath) => api('PUT', url, { text, confirm_path: confirmPath }),
|
|
212
|
+
onSaved: () => renderDocs(p),
|
|
213
|
+
});
|
|
214
|
+
} catch (e) { styledAlert(e.message || tr('could not read the file')); }
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function openFileEditor({ title, path, text, save, onSaved }) {
|
|
220
|
+
$('file-viewer')?.remove();
|
|
221
|
+
const overlay = document.createElement('div');
|
|
222
|
+
overlay.className = 'modal-overlay';
|
|
223
|
+
overlay.id = 'file-viewer';
|
|
224
|
+
overlay.innerHTML = `<div class="modal fv-modal">
|
|
225
|
+
<div class="fv-head"><span class="fv-title">${esc(title)}</span><button class="btn-icon fv-close" title="${tr('Close')}">✕</button></div>
|
|
226
|
+
<div class="fv-path muted">${esc(path || '')}</div>
|
|
227
|
+
${save
|
|
228
|
+
? `<textarea class="fv-body fv-edit" id="fv-text" spellcheck="false"></textarea>
|
|
229
|
+
<div class="fv-actions">
|
|
230
|
+
<button class="btn-primary" id="fv-save" disabled>${tr('Save to the file')}</button>
|
|
231
|
+
<span class="muted fv-note" id="fv-note"></span>
|
|
232
|
+
</div>`
|
|
233
|
+
: `<pre class="fv-body">${esc(text)}</pre>`}
|
|
234
|
+
</div>`;
|
|
235
|
+
document.body.appendChild(overlay);
|
|
236
|
+
const close = overlayLayer(overlay);
|
|
237
|
+
overlay.querySelector('.fv-close').onclick = close;
|
|
238
|
+
if (!save) return;
|
|
239
|
+
const ta = $('fv-text');
|
|
240
|
+
ta.value = text;
|
|
241
|
+
let saved = text;
|
|
242
|
+
const note = (msg) => { $('fv-note').textContent = msg; };
|
|
243
|
+
const sync = () => { $('fv-save').disabled = ta.value === saved; };
|
|
244
|
+
ta.oninput = () => { sync(); note(ta.value === saved ? '' : tr('changed — not written to the file yet')); };
|
|
245
|
+
$('fv-save').onclick = async () => {
|
|
246
|
+
const ok = await styledConfirm(`${tr('Overwrite this file?')}\n${path}`, { okLabel: tr('Overwrite'), danger: true });
|
|
247
|
+
if (!ok) return;
|
|
248
|
+
$('fv-save').disabled = true;
|
|
249
|
+
try {
|
|
250
|
+
const r = await save(ta.value, path);
|
|
251
|
+
saved = ta.value;
|
|
252
|
+
note(r?.backup_name ? `${tr('saved ✓ · the previous version is kept as')} ${r.backup_name}` : tr('saved ✓'));
|
|
253
|
+
onSaved?.();
|
|
254
|
+
} catch (e) { note(e.message || tr('not saved')); sync(); }
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let catDocHandler = null;
|
|
259
|
+
function closeCatList() {
|
|
260
|
+
$('ps-category')?.classList.remove('open');
|
|
261
|
+
if (catDocHandler) { document.removeEventListener('click', catDocHandler); catDocHandler = null; }
|
|
262
|
+
}
|
|
263
|
+
function buildCatSelect(cur, cats) {
|
|
264
|
+
const box = $('ps-category');
|
|
265
|
+
box.dataset.value = cur;
|
|
266
|
+
const items = [...cats, '__new__'];
|
|
267
|
+
const label = (v) => (v === '__new__' ? tr('New section…') : v);
|
|
268
|
+
box.innerHTML = `<button type="button" class="pp-sel-btn"><span class="pp-sel-val">${esc(label(cur))}</span><span class="pp-sel-chev">▾</span></button>`
|
|
269
|
+
+ `<div class="pp-sel-list">${items.map((v) =>
|
|
270
|
+
`<div class="pp-sel-opt${v === cur ? ' active' : ''}" data-v="${esc(v)}">${v === '__new__' ? `${ic('plus', 12)} ${esc(tr('New section…'))}` : esc(v)}</div>`).join('')}</div>`;
|
|
271
|
+
box.onclick = (e) => {
|
|
272
|
+
const opt = e.target.closest('.pp-sel-opt');
|
|
273
|
+
if (opt) {
|
|
274
|
+
const v = opt.dataset.v; const isNew = v === '__new__';
|
|
275
|
+
box.dataset.value = v;
|
|
276
|
+
box.querySelector('.pp-sel-val').textContent = label(v);
|
|
277
|
+
box.querySelectorAll('.pp-sel-opt').forEach((o) => o.classList.toggle('active', o.dataset.v === v));
|
|
278
|
+
$('ps-category-new').classList.toggle('hidden', !isNew);
|
|
279
|
+
if (isNew) $('ps-category-new').focus();
|
|
280
|
+
closeCatList();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (!e.target.closest('.pp-sel-btn')) return;
|
|
284
|
+
if (box.classList.contains('open')) { closeCatList(); return; }
|
|
285
|
+
box.classList.add('open');
|
|
286
|
+
catDocHandler = (ev) => { if (!box.contains(ev.target)) closeCatList(); };
|
|
287
|
+
setTimeout(() => document.addEventListener('click', catDocHandler), 0);
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function saveProjectSettings() {
|
|
292
|
+
const p = currentProject();
|
|
293
|
+
if (!p) return;
|
|
294
|
+
const body = {};
|
|
295
|
+
const name = $('ps-name').value.trim();
|
|
296
|
+
const description = $('ps-desc').value.trim();
|
|
297
|
+
if (name && name !== p.name) body.name = name;
|
|
298
|
+
if (description !== (p.description || '')) body.description = description;
|
|
299
|
+
let category = $('ps-category').dataset.value;
|
|
300
|
+
if (category === '__new__') category = $('ps-category-new').value.trim();
|
|
301
|
+
if (category && category !== (p.category || 'Other')) body.category = category;
|
|
302
|
+
const pinned = $('ps-pinned').checked ? 1 : 0;
|
|
303
|
+
if (pinned !== (p.pinned ? 1 : 0)) body.pinned = pinned;
|
|
304
|
+
for (const [field, id] of Object.entries(FIELDS)) {
|
|
305
|
+
const v = $(id).value.trim() || null;
|
|
306
|
+
if (v !== (p[field] || null)) body[field] = v;
|
|
307
|
+
}
|
|
308
|
+
const skill = (selVal($('ps-skill')) || '').trim() || null;
|
|
309
|
+
if (skill !== (p.deploy_skill || null)) body.deploy_skill = skill;
|
|
310
|
+
const pm2 = $('ps-pm2').value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
311
|
+
const pm2Str = pm2.length ? JSON.stringify(pm2) : null;
|
|
312
|
+
if (pm2Str !== (p.pm2_services || null)) body.pm2_services = pm2Str;
|
|
313
|
+
if (!Object.keys(body).length) { flashSaved(tr('nothing to save')); return; }
|
|
314
|
+
try { await api('PATCH', `/api/projects/${seg(p.slug)}`, body); }
|
|
315
|
+
catch (e) { flashSaved(e.message || tr('not saved')); return; }
|
|
316
|
+
await refresh();
|
|
317
|
+
renderSidebar();
|
|
318
|
+
renderTopbar();
|
|
319
|
+
await renderProjectSettings(true);
|
|
320
|
+
flashSaved(tr('saved ✓'));
|
|
321
|
+
}
|
|
322
|
+
function flashSaved(text) {
|
|
323
|
+
const el = $('ps-saved');
|
|
324
|
+
if (!el) return;
|
|
325
|
+
el.textContent = text;
|
|
326
|
+
setTimeout(() => { if (el.textContent === text) el.textContent = ''; }, 2200);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function archiveProject() {
|
|
330
|
+
const p = currentProject();
|
|
331
|
+
if (!p) return;
|
|
332
|
+
if (!await styledConfirm(`${tr('Archive')} "${p.name || p.slug}"? ${tr('Tasks are kept, the project leaves the list.')}`, { okLabel: tr('Archive') })) return;
|
|
333
|
+
await api('PATCH', `/api/projects/${seg(p.slug)}`, { archived: 1 });
|
|
334
|
+
state.projSettings = null;
|
|
335
|
+
state.slug = DASH;
|
|
336
|
+
await refresh();
|
|
337
|
+
selectProject(state.slug);
|
|
338
|
+
}
|