@polderlabs/openkan 0.4.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 +226 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/agents/openkan.md +254 -0
- package/bin/install-agent.mjs +63 -0
- package/bin/ok.mjs +17 -0
- package/bin/openkan.mjs +10 -0
- package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
- package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
- package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
- package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
- package/dist/agents/openkan.md +254 -0
- package/dist/bin/install-agent.mjs +76 -0
- package/dist/bin/ok-install.js +58 -0
- package/dist/bin/ok.js +138 -0
- package/dist/bin/openkan.js +804 -0
- package/dist/commands/organize.md +15 -0
- package/dist/kanban/agent-profile.js +8 -0
- package/dist/kanban/archive.js +49 -0
- package/dist/kanban/bizar.js +242 -0
- package/dist/kanban/board.js +367 -0
- package/dist/kanban/bulk.js +139 -0
- package/dist/kanban/changelog.js +186 -0
- package/dist/kanban/chat.js +1280 -0
- package/dist/kanban/claude-state.js +974 -0
- package/dist/kanban/comments.js +80 -0
- package/dist/kanban/docs.js +144 -0
- package/dist/kanban/fs.js +163 -0
- package/dist/kanban/git.js +196 -0
- package/dist/kanban/images.js +140 -0
- package/dist/kanban/import.js +295 -0
- package/dist/kanban/inputs.js +94 -0
- package/dist/kanban/insights.js +140 -0
- package/dist/kanban/io.js +75 -0
- package/dist/kanban/mdx-render.js +348 -0
- package/dist/kanban/mdx.js +231 -0
- package/dist/kanban/projects.js +545 -0
- package/dist/kanban/search.js +121 -0
- package/dist/kanban/server.js +3296 -0
- package/dist/kanban/tags.js +124 -0
- package/dist/kanban/template.js +145 -0
- package/dist/kanban/tsx-sandbox.js +187 -0
- package/dist/kanban/watcher.js +270 -0
- package/dist/ok/commands/goal.js +65 -0
- package/dist/ok/commands/index.js +87 -0
- package/dist/ok/commands/init.js +15 -0
- package/dist/ok/commands/plan.js +155 -0
- package/dist/ok/commands/prd.js +202 -0
- package/dist/ok/commands/progress.js +31 -0
- package/dist/ok/commands/task.js +377 -0
- package/dist/ok/ids.js +98 -0
- package/dist/ok/lock.js +156 -0
- package/dist/ok/migrate.js +197 -0
- package/dist/ok/schemas.js +402 -0
- package/dist/ok/storage.js +222 -0
- package/dist/skills/openkan/SKILL.md +111 -0
- package/dist/skills/openkan/agents/openai.yaml +4 -0
- package/dist/skills/openkan/examples/simple-task.mdx +34 -0
- package/dist/skills/openkan/examples/with-ask.mdx +32 -0
- package/dist/skills/openkan/examples/with-choice.mdx +51 -0
- package/dist/skills/openkan/examples/with-preview.mdx +54 -0
- package/dist/skills/openkan/references/api.md +169 -0
- package/dist/skills/openkan/templates/task.mdx +46 -0
- package/dist/web/api.js +257 -0
- package/dist/web/app.js +4251 -0
- package/dist/web/bizar.js +39 -0
- package/dist/web/brand/agent-activity-sprite.svg +1 -0
- package/dist/web/brand/banner-docs.svg +24 -0
- package/dist/web/brand/banner.svg +32 -0
- package/dist/web/brand/empty-sessions.svg +17 -0
- package/dist/web/brand/empty-tasks.svg +17 -0
- package/dist/web/brand/favicon.svg +9 -0
- package/dist/web/brand/infinity-loader-animated.svg +220 -0
- package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
- package/dist/web/brand/logo-wordmark.svg +10 -0
- package/dist/web/brand/logo.svg +9 -0
- package/dist/web/brand/pixel-infinity-track.svg +1 -0
- package/dist/web/brand/social-card.svg +26 -0
- package/dist/web/changelog-view.js +456 -0
- package/dist/web/charts.js +269 -0
- package/dist/web/chat-sidebar.js +2397 -0
- package/dist/web/chat-status-motion.js +154 -0
- package/dist/web/claude-pane.js +820 -0
- package/dist/web/command-palette.js +381 -0
- package/dist/web/contributors-view.js +317 -0
- package/dist/web/cross-tab.js +102 -0
- package/dist/web/docs-view.js +168 -0
- package/dist/web/experience.css +165 -0
- package/dist/web/goals-view.js +45 -0
- package/dist/web/home-view.js +113 -0
- package/dist/web/images.js +311 -0
- package/dist/web/index.html +485 -0
- package/dist/web/insights.js +217 -0
- package/dist/web/keyboard.js +446 -0
- package/dist/web/mdx-viewer.js +600 -0
- package/dist/web/path-picker.js +787 -0
- package/dist/web/preview-frame.html +187 -0
- package/dist/web/settings.js +582 -0
- package/dist/web/style.css +8545 -0
- package/dist/web/task-view.js +1759 -0
- package/dist/web/vendor/gsap.min.js +11 -0
- package/dist/web/workspace.css +1513 -0
- package/package.json +71 -0
- package/skills/openkan/SKILL.md +111 -0
- package/skills/openkan/agents/openai.yaml +4 -0
- package/skills/openkan/examples/simple-task.mdx +34 -0
- package/skills/openkan/examples/with-ask.mdx +32 -0
- package/skills/openkan/examples/with-choice.mdx +51 -0
- package/skills/openkan/examples/with-preview.mdx +54 -0
- package/skills/openkan/references/api.md +169 -0
- package/skills/openkan/templates/task.mdx +46 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
// OpenKan — settings dialog (M10 + M13 sidebar restructure).
|
|
2
|
+
// window.OpenKanSettings = { open(), close(), refresh(), showToast(), applyTheme(), currentTheme(), cycleTheme() }
|
|
3
|
+
//
|
|
4
|
+
// Layout:
|
|
5
|
+
// ┌───────────────────────────────────────────────────┐
|
|
6
|
+
// │ Settings [×] │
|
|
7
|
+
// ├─────────────┬─────────────────────────────────────┤
|
|
8
|
+
// │ Project │ Project settings │
|
|
9
|
+
// │ Server │ │
|
|
10
|
+
// │ UI │ [Default agent] [_________] │
|
|
11
|
+
// │ Sandbox │ [Default model] [_________] │
|
|
12
|
+
// │ Import │ [Default column ▼] │
|
|
13
|
+
// │ Contribs │ │
|
|
14
|
+
// │ Advanced │ │
|
|
15
|
+
// └─────────────┴─────────────────────────────────────┘
|
|
16
|
+
//
|
|
17
|
+
// Uses /api/config-sections (Thor is shipping it). Falls back to /api/settings
|
|
18
|
+
// with the legacy single-blob layout when the sections endpoint isn't
|
|
19
|
+
// implemented yet — keeps the dialog usable during the rollout.
|
|
20
|
+
//
|
|
21
|
+
// Wire shape compatibility: as of the M10/M13 server rollout, /api/config-sections
|
|
22
|
+
// returns { sections: [{ id, label, fields: [{ key, label, type, value, ... }] }] }
|
|
23
|
+
// (server uses `label` for the section/field display text and `key` for the
|
|
24
|
+
// stable field identifier). The original client spec used `title`/`name`. We
|
|
25
|
+
// accept BOTH shapes here by normalising in `loadSections()` so older clients
|
|
26
|
+
// and newer servers can talk to each other without one party breaking first.
|
|
27
|
+
|
|
28
|
+
(() => {
|
|
29
|
+
"use strict";
|
|
30
|
+
|
|
31
|
+
const { api } = window.OpenKanAPI;
|
|
32
|
+
|
|
33
|
+
// ─── Theme persistence (separate from /api/settings so it works without
|
|
34
|
+
// a backend round-trip) ───────────────────────────────────────────────
|
|
35
|
+
const THEME_KEY = "openkan:theme";
|
|
36
|
+
const VALID_THEMES = new Set(["dark", "light", "system"]);
|
|
37
|
+
|
|
38
|
+
function applyTheme(theme) {
|
|
39
|
+
const t = VALID_THEMES.has(theme) ? theme : "dark";
|
|
40
|
+
document.documentElement.setAttribute("data-theme", t);
|
|
41
|
+
try { localStorage.setItem(THEME_KEY, t); } catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function currentTheme() {
|
|
45
|
+
try {
|
|
46
|
+
const t = localStorage.getItem(THEME_KEY);
|
|
47
|
+
if (VALID_THEMES.has(t)) return t;
|
|
48
|
+
} catch {}
|
|
49
|
+
return "dark";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cycleTheme() {
|
|
53
|
+
const order = ["dark", "light", "system"];
|
|
54
|
+
const cur = currentTheme().toLowerCase();
|
|
55
|
+
const next = order[(order.indexOf(cur) + 1) % order.length] || "dark";
|
|
56
|
+
applyTheme(next);
|
|
57
|
+
try { window.OpenKanCrossTab?.publish?.("theme.changed", { theme: next }); } catch {}
|
|
58
|
+
return next;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Apply on boot — this must happen before the first paint to avoid a
|
|
62
|
+
// flash, but the script is `defer`red, so the page may already be light.
|
|
63
|
+
// We accept that brief flash; the alternative (an inline script) would
|
|
64
|
+
// break the no-inline-script Content-Security-Policy in production.
|
|
65
|
+
applyTheme(currentTheme());
|
|
66
|
+
|
|
67
|
+
// ─── DOM helpers ───────────────────────────────────────────────────────
|
|
68
|
+
const $ = (id) => document.getElementById(id);
|
|
69
|
+
const backdrop = $("settings-backdrop");
|
|
70
|
+
const body = $("settings-body");
|
|
71
|
+
const saveBtn = $("settings-save");
|
|
72
|
+
const navEl = $("settings-nav");
|
|
73
|
+
const titleEl = $("settings-title");
|
|
74
|
+
|
|
75
|
+
function el(tag, cls, props = {}) {
|
|
76
|
+
const e = document.createElement(tag);
|
|
77
|
+
if (cls) e.className = cls;
|
|
78
|
+
for (const [k, v] of Object.entries(props)) {
|
|
79
|
+
if (k === "text") e.textContent = v;
|
|
80
|
+
else if (k === "html") e.innerHTML = v;
|
|
81
|
+
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
|
|
82
|
+
else e.setAttribute(k, v);
|
|
83
|
+
}
|
|
84
|
+
return e;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function showToast(message, kind = "success") {
|
|
88
|
+
const container = $("toast-container");
|
|
89
|
+
if (!container) {
|
|
90
|
+
try { alert(message); } catch {}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const toast = el("div", `toast toast-${kind}`, { text: message });
|
|
94
|
+
container.append(toast);
|
|
95
|
+
// 4s auto-dismiss (UX spec). Mark as leaving so CSS animates opacity +
|
|
96
|
+
// 8px slide-down together, matching the entry animation's direction.
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
toast.classList.add("toast-leaving");
|
|
99
|
+
setTimeout(() => toast.remove(), 220);
|
|
100
|
+
}, 4000);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Section model ──────────────────────────────────────────────────────
|
|
104
|
+
// A "section" is what the sidebar shows and what the right pane renders.
|
|
105
|
+
// We support two shapes:
|
|
106
|
+
// 1. /api/config-sections returns [{ id, title, fields: [...] }, ...]
|
|
107
|
+
// 2. /api/config-sections returns { sections: [...] } — unwrap.
|
|
108
|
+
// Each field: { name, label, type, value, options?, placeholder?, hint?,
|
|
109
|
+
// min?, max?, step?, disabled?, readOnly? }
|
|
110
|
+
// Types: text | number | select | radio | checkbox | readonly
|
|
111
|
+
//
|
|
112
|
+
// The server (kanban/server.ts) uses `label` for the section/field display
|
|
113
|
+
// name and `key` for the stable field id; we normalise both shapes into the
|
|
114
|
+
// canonical `{ id, title, fields: [{ name, label, type, value, ... }] }`
|
|
115
|
+
// model below so renderNav/renderField only have to know one shape.
|
|
116
|
+
|
|
117
|
+
/** @type {Array<{id:string,title:string,hint?:string,fields:any[]}>|null} */
|
|
118
|
+
let sections = null;
|
|
119
|
+
/** @type {string|null} */
|
|
120
|
+
let activeSectionId = null;
|
|
121
|
+
/** @type {Object<string,any>} */
|
|
122
|
+
let workingValues = {}; // collected from inputs as the user types
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Normalise a single section into our canonical shape.
|
|
126
|
+
* Accepts both server (`label`/`key`) and legacy client spec (`title`/`name`).
|
|
127
|
+
* @param {any} raw
|
|
128
|
+
* @returns {{id:string,title:string,hint?:string,fields:any[]}}
|
|
129
|
+
*/
|
|
130
|
+
function normalizeSection(raw) {
|
|
131
|
+
if (!raw || typeof raw !== "object") return null;
|
|
132
|
+
const id = String(raw.id || "").trim();
|
|
133
|
+
if (!id) return null;
|
|
134
|
+
const title = String(
|
|
135
|
+
raw.title ?? raw.label ?? raw.name ?? id,
|
|
136
|
+
).trim() || id;
|
|
137
|
+
const hint = raw.hint ? String(raw.hint) : undefined;
|
|
138
|
+
const fieldsIn = Array.isArray(raw.fields) ? raw.fields : [];
|
|
139
|
+
const fields = fieldsIn
|
|
140
|
+
.filter((f) => f && typeof f === "object")
|
|
141
|
+
.map((f) => {
|
|
142
|
+
const name = String(f.name ?? f.key ?? "").trim();
|
|
143
|
+
// Some sections have no fields (e.g. contributors) — keep them so the
|
|
144
|
+
// sidebar still lists the section but renderSection() will simply
|
|
145
|
+
// render no inputs.
|
|
146
|
+
if (!name) return { ...f, name: String(f.label || f.id || "") };
|
|
147
|
+
return {
|
|
148
|
+
name,
|
|
149
|
+
label: String(f.label ?? f.name ?? f.key ?? name),
|
|
150
|
+
type: String(f.type || "text"),
|
|
151
|
+
value: f.value ?? "",
|
|
152
|
+
options: Array.isArray(f.options) ? f.options : undefined,
|
|
153
|
+
placeholder: f.placeholder ? String(f.placeholder) : undefined,
|
|
154
|
+
hint: f.hint ? String(f.hint) : f.description ? String(f.description) : undefined,
|
|
155
|
+
min: f.min,
|
|
156
|
+
max: f.max,
|
|
157
|
+
step: f.step,
|
|
158
|
+
readOnly: !!f.readOnly,
|
|
159
|
+
};
|
|
160
|
+
})
|
|
161
|
+
// Drop any field we couldn't give a stable `name` to — those can't be
|
|
162
|
+
// round-tripped to the server safely. We log so the user/dev can spot
|
|
163
|
+
// bad data, but we don't crash the dialog.
|
|
164
|
+
.filter((f) => f.name);
|
|
165
|
+
return { id, title, hint, fields };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Legacy fallback sections — used when /api/config-sections 404s.
|
|
169
|
+
function legacySectionsFromSettings(s) {
|
|
170
|
+
const project = s?.project || {};
|
|
171
|
+
return [
|
|
172
|
+
{
|
|
173
|
+
id: "project",
|
|
174
|
+
title: "Project",
|
|
175
|
+
hint: "Defaults used when creating a new task in this project.",
|
|
176
|
+
fields: [
|
|
177
|
+
{ name: "defaultAgent", label: "Default agent", type: "text", value: project.defaultAgent || "", placeholder: "build" },
|
|
178
|
+
{ name: "defaultModel", label: "Default model", type: "text", value: project.defaultModel || "", placeholder: "provider/model" },
|
|
179
|
+
{ name: "defaultColumn", label: "Default column", type: "select",
|
|
180
|
+
value: project.defaultColumn || "todo",
|
|
181
|
+
options: [
|
|
182
|
+
{ v: "backlog", t: "Backlog" },
|
|
183
|
+
{ v: "todo", t: "To Do" },
|
|
184
|
+
{ v: "doing", t: "In Progress" },
|
|
185
|
+
{ v: "review", t: "Review" },
|
|
186
|
+
{ v: "done", t: "Done" },
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
{ name: "autoArchiveDays", label: "Auto-archive done tasks after (days)", type: "number", value: project.autoArchiveDays ?? 0, min: 0, step: 1, hint: "0 = never" },
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
id: "server",
|
|
194
|
+
title: "Server",
|
|
195
|
+
hint: "Server connection details (read-only at runtime).",
|
|
196
|
+
fields: [
|
|
197
|
+
{ name: "host", label: "Host", type: "readonly", value: project.host || "" },
|
|
198
|
+
{ name: "port", label: "Port", type: "readonly", value: project.port ?? "" },
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: "ui",
|
|
203
|
+
title: "UI",
|
|
204
|
+
fields: [
|
|
205
|
+
{ name: "theme", label: "Theme", type: "radio", value: currentTheme(),
|
|
206
|
+
options: [
|
|
207
|
+
{ v: "dark", t: "Dark" },
|
|
208
|
+
{ v: "light", t: "Light" },
|
|
209
|
+
{ v: "system", t: "System" },
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function loadSections() {
|
|
218
|
+
// Try the new endpoint first. Fall back to legacy /api/settings shape
|
|
219
|
+
// when the endpoint isn't implemented yet. Each branch leaves a
|
|
220
|
+
// console.debug breadcrumb so an empty dialog in the wild is debuggable
|
|
221
|
+
// without a code dive.
|
|
222
|
+
console.debug("[settings] loadSections: trying /api/config-sections");
|
|
223
|
+
let data = null;
|
|
224
|
+
let primaryErr = null;
|
|
225
|
+
try {
|
|
226
|
+
data = await api("GET", "/api/config-sections");
|
|
227
|
+
console.debug("[settings] /api/config-sections returned:", data);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
primaryErr = err;
|
|
230
|
+
console.debug("[settings] /api/config-sections failed:", err?.message || err);
|
|
231
|
+
}
|
|
232
|
+
if (data != null) {
|
|
233
|
+
const list = Array.isArray(data)
|
|
234
|
+
? data
|
|
235
|
+
: Array.isArray(data?.sections)
|
|
236
|
+
? data.sections
|
|
237
|
+
: null;
|
|
238
|
+
if (list && list.length > 0) {
|
|
239
|
+
const normalised = list.map(normalizeSection).filter(Boolean);
|
|
240
|
+
if (normalised.length > 0) {
|
|
241
|
+
console.debug("[settings] using /api/config-sections (normalised)", {
|
|
242
|
+
count: normalised.length,
|
|
243
|
+
ids: normalised.map((s) => s.id),
|
|
244
|
+
});
|
|
245
|
+
return normalised;
|
|
246
|
+
}
|
|
247
|
+
console.debug(
|
|
248
|
+
"[settings] /api/config-sections returned data but every section failed to normalise; falling back",
|
|
249
|
+
);
|
|
250
|
+
} else {
|
|
251
|
+
console.debug(
|
|
252
|
+
"[settings] /api/config-sections returned an empty/invalid list; falling back",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Fallback 1: /api/settings (legacy single-blob).
|
|
257
|
+
console.debug("[settings] loadSections: trying legacy /api/settings");
|
|
258
|
+
try {
|
|
259
|
+
const s = await api("GET", "/api/settings");
|
|
260
|
+
console.debug("[settings] /api/settings returned:", s);
|
|
261
|
+
const out = legacySectionsFromSettings(s || {});
|
|
262
|
+
console.debug("[settings] using legacy /api/settings shape", {
|
|
263
|
+
count: out.length,
|
|
264
|
+
ids: out.map((sec) => sec.id),
|
|
265
|
+
});
|
|
266
|
+
return out;
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.debug(
|
|
269
|
+
"[settings] /api/settings also failed:",
|
|
270
|
+
err?.message || err,
|
|
271
|
+
primaryErr ? `(primary was: ${primaryErr.message || primaryErr})` : "",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
// Fallback 2: no backend at all — defaults so the dialog is still usable.
|
|
275
|
+
console.debug("[settings] loadSections: using hard-coded defaults");
|
|
276
|
+
return legacySectionsFromSettings({});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── Rendering ──────────────────────────────────────────────────────────
|
|
280
|
+
function renderNav() {
|
|
281
|
+
if (!navEl || !sections) return;
|
|
282
|
+
navEl.innerHTML = "";
|
|
283
|
+
for (const sec of sections) {
|
|
284
|
+
const item = el("button", "settings-nav-item" + (sec.id === activeSectionId ? " active" : ""), {
|
|
285
|
+
type: "button",
|
|
286
|
+
role: "tab",
|
|
287
|
+
"aria-selected": sec.id === activeSectionId ? "true" : "false",
|
|
288
|
+
"data-section": sec.id,
|
|
289
|
+
});
|
|
290
|
+
item.append(el("span", "settings-nav-label", { text: sec.title }));
|
|
291
|
+
item.append(el("span", "settings-nav-count", { text: `${sec.fields.length} ${sec.fields.length === 1 ? "setting" : "settings"}` }));
|
|
292
|
+
item.addEventListener("click", () => {
|
|
293
|
+
activeSectionId = sec.id;
|
|
294
|
+
renderNav();
|
|
295
|
+
renderSection();
|
|
296
|
+
});
|
|
297
|
+
navEl.append(item);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function renderSection() {
|
|
302
|
+
if (!body) return;
|
|
303
|
+
if (!sections || sections.length === 0) {
|
|
304
|
+
// Defensive: if the loader returned nothing usable, show a clear
|
|
305
|
+
// message instead of leaving the pane blank (which was the original
|
|
306
|
+
// symptom of the config-sections bug).
|
|
307
|
+
body.innerHTML = "";
|
|
308
|
+
body.append(
|
|
309
|
+
el("p", "settings-section-hint", {
|
|
310
|
+
text: "No settings available. The /api/config-sections and /api/settings endpoints both failed to return any sections. Check the server logs and reload.",
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
313
|
+
if (titleEl) titleEl.textContent = "Settings";
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const sec = sections.find((s) => s.id === activeSectionId) || sections[0];
|
|
317
|
+
if (!sec) return;
|
|
318
|
+
if (titleEl) titleEl.textContent = sec.title;
|
|
319
|
+
|
|
320
|
+
body.innerHTML = "";
|
|
321
|
+
|
|
322
|
+
if (sec.hint) {
|
|
323
|
+
body.append(el("p", "settings-section-hint", { text: sec.hint }));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!sec.fields || sec.fields.length === 0) {
|
|
327
|
+
body.append(
|
|
328
|
+
el("p", "settings-section-hint", {
|
|
329
|
+
text: "This section has no configurable fields.",
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
} else {
|
|
333
|
+
for (const field of sec.fields) {
|
|
334
|
+
body.append(renderField(sec, field));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// If this is the "ui" section (legacy), expose a "Saved locally" note
|
|
339
|
+
// since theme is not persisted server-side yet.
|
|
340
|
+
if (sec.id === "ui") {
|
|
341
|
+
body.append(el("p", "settings-section-hint", {
|
|
342
|
+
text: "Theme is stored locally and applied immediately.",
|
|
343
|
+
}));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function renderField(sec, field) {
|
|
348
|
+
const wrap = el("div", "settings-field");
|
|
349
|
+
wrap.append(el("label", "settings-field-label", {
|
|
350
|
+
for: `settings-field-${sec.id}-${field.name}`,
|
|
351
|
+
text: field.label || field.name,
|
|
352
|
+
}));
|
|
353
|
+
|
|
354
|
+
const inputId = `settings-field-${sec.id}-${field.name}`;
|
|
355
|
+
let control;
|
|
356
|
+
switch (field.type) {
|
|
357
|
+
case "select": {
|
|
358
|
+
control = el("select", "settings-input", { id: inputId, name: field.name });
|
|
359
|
+
for (const opt of field.options || []) {
|
|
360
|
+
const o = el("option", null, { value: opt.v ?? opt.value, text: opt.t ?? opt.label });
|
|
361
|
+
if (String(field.value ?? "") === String(opt.v ?? opt.value)) o.selected = true;
|
|
362
|
+
control.append(o);
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case "radio": {
|
|
367
|
+
control = el("div", "settings-radio-row");
|
|
368
|
+
for (const opt of field.options || []) {
|
|
369
|
+
const id = `${inputId}-${opt.v ?? opt.value}`;
|
|
370
|
+
const lbl = el("label", null, { for: id });
|
|
371
|
+
const radio = el("input", null, {
|
|
372
|
+
type: "radio",
|
|
373
|
+
name: field.name,
|
|
374
|
+
id,
|
|
375
|
+
value: opt.v ?? opt.value,
|
|
376
|
+
});
|
|
377
|
+
if (String(field.value ?? "") === String(opt.v ?? opt.value)) radio.checked = true;
|
|
378
|
+
radio.addEventListener("change", () => {
|
|
379
|
+
if (field.name === "theme") applyTheme(String(opt.v ?? opt.value));
|
|
380
|
+
});
|
|
381
|
+
lbl.append(radio, ` ${opt.t ?? opt.label}`);
|
|
382
|
+
control.append(lbl);
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
case "boolean": {
|
|
387
|
+
control = el("label", "settings-switch");
|
|
388
|
+
const checkbox = el("input", null, { type: "checkbox", id: inputId, name: field.name });
|
|
389
|
+
checkbox.checked = Boolean(field.value);
|
|
390
|
+
control.append(checkbox, el("span", "settings-switch-track"), el("span", "settings-switch-copy", { text: field.hint || "Enable this setting" }));
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
case "number": {
|
|
394
|
+
control = el("input", "settings-input", {
|
|
395
|
+
type: "number",
|
|
396
|
+
id: inputId,
|
|
397
|
+
name: field.name,
|
|
398
|
+
value: String(field.value ?? ""),
|
|
399
|
+
});
|
|
400
|
+
if (field.min !== undefined) control.setAttribute("min", String(field.min));
|
|
401
|
+
if (field.max !== undefined) control.setAttribute("max", String(field.max));
|
|
402
|
+
if (field.step !== undefined) control.setAttribute("step", String(field.step));
|
|
403
|
+
if (field.placeholder) control.setAttribute("placeholder", field.placeholder);
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
case "readonly":
|
|
407
|
+
case "text":
|
|
408
|
+
default: {
|
|
409
|
+
control = el("input", field.type === "readonly" ? "settings-input settings-readonly" : "settings-input", {
|
|
410
|
+
type: field.type === "readonly" ? "text" : "text",
|
|
411
|
+
id: inputId,
|
|
412
|
+
name: field.name,
|
|
413
|
+
value: String(field.value ?? ""),
|
|
414
|
+
disabled: field.type === "readonly" ? "true" : null,
|
|
415
|
+
});
|
|
416
|
+
if (field.placeholder) control.setAttribute("placeholder", field.placeholder);
|
|
417
|
+
if (field.readOnly) control.setAttribute("readonly", "readonly");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (control) wrap.append(control);
|
|
422
|
+
if (field.hint) wrap.append(el("div", "settings-field-hint", { text: field.hint }));
|
|
423
|
+
|
|
424
|
+
return wrap;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function collectValues() {
|
|
428
|
+
const out = {};
|
|
429
|
+
if (!body) return out;
|
|
430
|
+
for (const sec of sections || []) {
|
|
431
|
+
for (const field of sec.fields) {
|
|
432
|
+
const id = `settings-field-${sec.id}-${field.name}`;
|
|
433
|
+
const el_ = body.querySelector(`#${CSS.escape(id)}`);
|
|
434
|
+
if (!el_) continue;
|
|
435
|
+
const tag = (el_.tagName || "").toLowerCase();
|
|
436
|
+
if (tag === "input" && el_.type === "radio") {
|
|
437
|
+
// Skip — handled via the selected radio inside .settings-radio-row.
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (tag === "input" && el_.type === "checkbox") {
|
|
441
|
+
out[field.name] = el_.checked;
|
|
442
|
+
} else if (tag === "input" && el_.type === "number") {
|
|
443
|
+
out[field.name] = Number(el_.value || 0);
|
|
444
|
+
} else {
|
|
445
|
+
out[field.name] = String(el_.value || "");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Collect radios separately (multiple radios per field name).
|
|
449
|
+
const radios = body.querySelectorAll(`input[type="radio"][name]:checked`);
|
|
450
|
+
const seen = new Set();
|
|
451
|
+
for (const r of radios) {
|
|
452
|
+
if (seen.has(r.name)) continue;
|
|
453
|
+
seen.add(r.name);
|
|
454
|
+
out[r.name] = r.value;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ─── Open / close ──────────────────────────────────────────────────────
|
|
461
|
+
function close() {
|
|
462
|
+
if (!backdrop) return;
|
|
463
|
+
backdrop.hidden = true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function open() {
|
|
467
|
+
if (!backdrop) return;
|
|
468
|
+
if (body) body.innerHTML = '<div class="settings-readonly">Loading…</div>';
|
|
469
|
+
backdrop.hidden = false;
|
|
470
|
+
console.debug("[settings] open: dialog shown, fetching sections");
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
sections = await loadSections();
|
|
474
|
+
} catch (err) {
|
|
475
|
+
// loadSections() catches its own errors; this catch is for unexpected
|
|
476
|
+
// synchronous throws only. Show a clear in-dialog message instead of
|
|
477
|
+
// an indefinitely-spinning "Loading…".
|
|
478
|
+
console.error("[settings] open: loadSections threw unexpectedly:", err);
|
|
479
|
+
sections = [];
|
|
480
|
+
if (body) {
|
|
481
|
+
body.innerHTML = "";
|
|
482
|
+
body.append(
|
|
483
|
+
el("p", "settings-section-hint", {
|
|
484
|
+
text: `Failed to load settings: ${err?.message || err}`,
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
if (titleEl) titleEl.textContent = "Settings";
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
console.debug("[settings] open: loaded sections", {
|
|
493
|
+
count: sections?.length || 0,
|
|
494
|
+
ids: (sections || []).map((s) => s.id),
|
|
495
|
+
active: activeSectionId,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
activeSectionId = sections?.[0]?.id || null;
|
|
499
|
+
|
|
500
|
+
renderNav();
|
|
501
|
+
renderSection();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ─── Wire close + save ────────────────────────────────────────────────
|
|
505
|
+
if (backdrop) {
|
|
506
|
+
backdrop.addEventListener("click", (e) => {
|
|
507
|
+
if (e.target === backdrop) close();
|
|
508
|
+
});
|
|
509
|
+
backdrop.querySelectorAll("[data-close-settings]").forEach((b) =>
|
|
510
|
+
b.addEventListener("click", close),
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
document.addEventListener("keydown", (e) => {
|
|
514
|
+
if (e.key === "Escape" && backdrop && !backdrop.hidden) close();
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
if (saveBtn) {
|
|
518
|
+
saveBtn.addEventListener("click", async () => {
|
|
519
|
+
saveBtn.disabled = true;
|
|
520
|
+
try {
|
|
521
|
+
const payload = collectValues();
|
|
522
|
+
|
|
523
|
+
// First try the new endpoint. Then fall back to /api/settings.
|
|
524
|
+
// The legacy endpoint is opt-in during the M10/M13 rollout — most
|
|
525
|
+
// installations only have one. We treat any non-2xx as an error.
|
|
526
|
+
let saved = null;
|
|
527
|
+
let lastError = null;
|
|
528
|
+
const active = sections?.find((section) => section.id === activeSectionId);
|
|
529
|
+
const entries = (active?.fields || []).map((field) => ({ key: field.name, value: payload[field.name] }));
|
|
530
|
+
try {
|
|
531
|
+
saved = await api("PATCH", `/api/config-sections/${encodeURIComponent(activeSectionId || "")}`, entries);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
lastError = err;
|
|
534
|
+
try {
|
|
535
|
+
saved = await api("PATCH", "/api/settings", payload);
|
|
536
|
+
lastError = null;
|
|
537
|
+
} catch (err2) { lastError = err2; }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (saved == null) {
|
|
541
|
+
// Both endpoints failed. Persist locally as a last resort, but
|
|
542
|
+
// surface the original error and keep the dialog open so the user
|
|
543
|
+
// can retry once the server is back. Closing silently here would
|
|
544
|
+
// look like success — the toast would be the only signal.
|
|
545
|
+
try { localStorage.setItem("openkan:settings", JSON.stringify(payload)); } catch {}
|
|
546
|
+
showToast(
|
|
547
|
+
`Save failed (kept locally): ${lastError?.message || "unknown error"}`,
|
|
548
|
+
"error",
|
|
549
|
+
);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// The theme is local-only — apply already happened on radio change.
|
|
554
|
+
// If we got here, the theme was part of `payload`; re-apply to keep
|
|
555
|
+
// state in sync.
|
|
556
|
+
if (payload.theme) applyTheme(String(payload.theme));
|
|
557
|
+
showToast("Settings saved", "success");
|
|
558
|
+
close();
|
|
559
|
+
window.dispatchEvent(new CustomEvent("openkan:settings-saved", { detail: saved || payload }));
|
|
560
|
+
} catch (err) {
|
|
561
|
+
// collectValues() or the CustomEvent dispatch failed — keep the
|
|
562
|
+
// dialog open so the user can retry.
|
|
563
|
+
showToast(`Save failed: ${err.message}`, "error");
|
|
564
|
+
} finally {
|
|
565
|
+
saveBtn.disabled = false;
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ─── External wiring (gear icon in topbar) ─────────────────────────────
|
|
571
|
+
document.addEventListener("click", (e) => {
|
|
572
|
+
const btn = e.target.closest("#settings-btn");
|
|
573
|
+
if (!btn) return;
|
|
574
|
+
e.preventDefault();
|
|
575
|
+
open();
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
window.OpenKanSettings = {
|
|
579
|
+
open, close, refresh: open,
|
|
580
|
+
applyTheme, currentTheme, cycleTheme, showToast,
|
|
581
|
+
};
|
|
582
|
+
})();
|