@clazic/urban 0.2.5 → 0.2.6

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.
@@ -0,0 +1,196 @@
1
+ import { getJson, postJson, putJson } from "../api.js";
2
+ import { qs, qsa } from "../dom.js";
3
+
4
+ function switchSection(section) {
5
+ qsa("#settings-tab-bar [data-tab]").forEach((button) => button.classList.toggle("active", button.dataset.tab === section));
6
+ qsa("[data-settings-tab]").forEach((panel) => {
7
+ const active = panel.dataset.settingsTab === section;
8
+ panel.classList.toggle("active", active);
9
+ panel.classList.toggle("hidden", !active);
10
+ });
11
+ }
12
+
13
+ async function loadSettings() {
14
+ const data = await getJson("/api/settings");
15
+ const config = data.config || {};
16
+ if (qs("#settings-nanet-userid")) qs("#settings-nanet-userid").value = config["source.nanet.userId"] ?? "";
17
+ if (qs("#settings-nanet-password")) qs("#settings-nanet-password").value = config["source.nanet.password"] ?? "";
18
+ if (qs("#settings-dbpia-b2bAcc")) qs("#settings-dbpia-b2bAcc").value = config["source.dbpia.b2bAcc"] ?? "";
19
+ if (qs("#settings-dbpia-userPass")) qs("#settings-dbpia-userPass").value = config["source.dbpia.userPass"] ?? "";
20
+ if (qs("#settings-dbpia-b2bId")) qs("#settings-dbpia-b2bId").value = config["source.dbpia.b2bId"] ?? "";
21
+ if (qs("#settings-dbpia-b2bName")) qs("#settings-dbpia-b2bName").value = config["source.dbpia.b2bName"] ?? "";
22
+ if (qs("#settings-dbpia-b2bLoginType")) qs("#settings-dbpia-b2bLoginType").value = config["source.dbpia.b2bLoginType"] ?? "";
23
+ if (qs("#settings-data-dir")) qs("#settings-data-dir").value = config["data.dir"] ?? "";
24
+ if (qs("#settings-download-concurrency")) qs("#settings-download-concurrency").value = config["download.concurrency"] ?? "3";
25
+ if (qs("#settings-data-dir-current")) qs("#settings-data-dir-current").textContent = config["_runtime.dataDir"] || "";
26
+ }
27
+
28
+ async function saveSourceAuth() {
29
+ await postJson("/api/settings", {
30
+ settings: {
31
+ "source.nanet.userId": qs("#settings-nanet-userid")?.value.trim() || "",
32
+ "source.nanet.password": qs("#settings-nanet-password")?.value || "",
33
+ "source.dbpia.b2bAcc": qs("#settings-dbpia-b2bAcc")?.value.trim() || "",
34
+ "source.dbpia.userPass": qs("#settings-dbpia-userPass")?.value || "",
35
+ "source.dbpia.b2bId": qs("#settings-dbpia-b2bId")?.value.trim() || "",
36
+ "source.dbpia.b2bName": qs("#settings-dbpia-b2bName")?.value.trim() || "",
37
+ "source.dbpia.b2bLoginType": qs("#settings-dbpia-b2bLoginType")?.value.trim() || "",
38
+ },
39
+ });
40
+ }
41
+
42
+
43
+ async function saveGeneral() {
44
+ await postJson("/api/settings", {
45
+ settings: {
46
+ "lint.scheduleType": qs("#settings-lint-schedule-type")?.value || "disabled",
47
+ "lint.scheduleTime": qs("#settings-lint-schedule-time")?.value || "",
48
+ "lint.scheduleDay": qs("#settings-lint-schedule-day")?.value || "",
49
+ "lint.scheduleCron": qs("#settings-lint-schedule-cron")?.value.trim() || "",
50
+ "lint.autoFix": String(Boolean(qs("#settings-lint-auto-fix")?.checked)),
51
+ "lint.semantic": String(Boolean(qs("#settings-lint-semantic")?.checked)),
52
+ "lint.notifyOnWarning": String(Boolean(qs("#settings-lint-notify")?.checked)),
53
+ "download.concurrency": String(qs("#settings-download-concurrency")?.value || "3"),
54
+ "data.dir": qs("#settings-data-dir")?.value.trim() || "",
55
+ },
56
+ });
57
+ }
58
+
59
+ function toggleKordocPanels(mode) {
60
+ const cli = qs("#kordoc-cli-panel");
61
+ const api = qs("#kordoc-api-panel");
62
+ if (cli) cli.hidden = mode !== "cli";
63
+ if (api) api.hidden = mode !== "api";
64
+ }
65
+
66
+ function renderProviderCard(provider, idx) {
67
+ const enabled = provider.enabled !== false;
68
+ const card = document.createElement("div");
69
+ card.className = "provider-card" + (enabled ? "" : " provider-card--disabled");
70
+ card.dataset.idx = idx;
71
+ card.innerHTML = `
72
+ <div class="provider-card-head">
73
+ <label class="provider-toggle">
74
+ <input type="checkbox" class="provider-enabled" ${enabled ? "checked" : ""} />
75
+ <span class="provider-toggle-track"><span class="provider-toggle-thumb"></span></span>
76
+ <strong>${provider.name || `프로바이더 ${idx + 1}`}</strong>
77
+ </label>
78
+ <button type="button" class="btn ghost btn-sm provider-remove" data-idx="${idx}">삭제</button>
79
+ </div>
80
+ <div class="provider-fields">
81
+ <label>이름<span><input class="input provider-name" type="text" value="${provider.name ?? ""}" placeholder="NVIDIA NIM" /></span></label>
82
+ <label>Base URL<span><input class="input provider-baseUrl" type="text" value="${provider.baseUrl ?? ""}" placeholder="https://..." /></span></label>
83
+ <label>API Key (쉼표 구분)<span><input class="input provider-apiKeys" type="text" value="${(provider.apiKeys ?? []).join(", ")}" placeholder="key1, key2" /></span></label>
84
+ <label>모델 후보 (쉼표 구분)<span><input class="input provider-modelCandidates" type="text" value="${(provider.modelCandidates ?? []).join(", ")}" placeholder="mistralai/mistral-medium-3" /></span></label>
85
+ </div>`;
86
+ card.querySelector(".provider-enabled").addEventListener("change", (e) => {
87
+ card.classList.toggle("provider-card--disabled", !e.target.checked);
88
+ });
89
+ return card;
90
+ }
91
+
92
+ function renderProvidersList(providers) {
93
+ const list = qs("#kordoc-providers-list");
94
+ if (!list) return;
95
+ list.innerHTML = "";
96
+ (providers ?? []).forEach((p, i) => list.appendChild(renderProviderCard(p, i)));
97
+ list.querySelectorAll(".provider-remove").forEach((btn) => btn.addEventListener("click", () => {
98
+ btn.closest(".provider-card").remove();
99
+ list.querySelectorAll(".provider-card").forEach((c, i) => { c.dataset.idx = i; c.querySelector(".provider-remove").dataset.idx = i; });
100
+ }));
101
+ }
102
+
103
+ async function loadKordocSettings() {
104
+ try {
105
+ const data = await getJson("/api/settings/kordoc");
106
+ const mode = data.mode ?? "cli";
107
+ document.querySelectorAll('input[name="kordoc-mode"]').forEach((r) => { r.checked = r.value === mode; });
108
+ toggleKordocPanels(mode);
109
+ const cli = data.cli ?? {};
110
+ if (qs("#kordoc-cli-ocrMode")) qs("#kordoc-cli-ocrMode").value = cli.ocrMode ?? "";
111
+ if (qs("#kordoc-cli-geminiModel")) qs("#kordoc-cli-geminiModel").value = cli.geminiModel ?? "";
112
+ if (qs("#kordoc-cli-claudeModel")) qs("#kordoc-cli-claudeModel").value = cli.claudeModel ?? "";
113
+ if (qs("#kordoc-cli-codexModel")) qs("#kordoc-cli-codexModel").value = cli.codexModel ?? "";
114
+ renderProvidersList(data.providers ?? []);
115
+ } catch (e) {
116
+ console.error("kordoc 설정 불러오기 실패:", e);
117
+ }
118
+ }
119
+
120
+ function collectKordocForm() {
121
+ const mode = document.querySelector('input[name="kordoc-mode"]:checked')?.value ?? "cli";
122
+ const cli = {
123
+ ocrMode: qs("#kordoc-cli-ocrMode")?.value ?? "",
124
+ geminiModel: qs("#kordoc-cli-geminiModel")?.value.trim() ?? "",
125
+ claudeModel: qs("#kordoc-cli-claudeModel")?.value.trim() ?? "",
126
+ codexModel: qs("#kordoc-cli-codexModel")?.value.trim() ?? "",
127
+ };
128
+ const providers = [];
129
+ qs("#kordoc-providers-list")?.querySelectorAll(".provider-card").forEach((card) => {
130
+ const split = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
131
+ providers.push({
132
+ name: card.querySelector(".provider-name")?.value.trim() ?? "",
133
+ baseUrl: card.querySelector(".provider-baseUrl")?.value.trim() ?? "",
134
+ apiKeys: split(card.querySelector(".provider-apiKeys")?.value ?? ""),
135
+ modelCandidates: split(card.querySelector(".provider-modelCandidates")?.value ?? ""),
136
+ enabled: card.querySelector(".provider-enabled")?.checked !== false,
137
+ });
138
+ });
139
+ return { mode, cli, providers };
140
+ }
141
+
142
+ async function saveKordocSettings() {
143
+ const status = qs("#kordoc-save-status");
144
+ if (status) { status.textContent = ""; status.className = "kordoc-inline-status"; }
145
+ try {
146
+ await putJson("/api/settings/kordoc", collectKordocForm());
147
+ if (status) { status.textContent = "저장됨"; status.className = "kordoc-inline-status is-ok"; }
148
+ setTimeout(() => { if (status) status.textContent = ""; }, 3000);
149
+ } catch (e) {
150
+ if (status) { status.textContent = e.message; status.className = "kordoc-inline-status is-error"; }
151
+ }
152
+ }
153
+
154
+ export async function mount() {
155
+ qsa("#settings-tab-bar [data-tab]").forEach((button) => button.addEventListener("click", () => switchSection(button.dataset.tab)));
156
+ switchSection("source-auth");
157
+ qs("#settings-save-source-auth")?.addEventListener("click", saveSourceAuth);
158
+ qs("#settings-save-general")?.addEventListener("click", saveGeneral);
159
+ qs("#settings-save-downloads")?.addEventListener("click", saveGeneral);
160
+ qs("#settings-save-data-dir")?.addEventListener("click", saveGeneral);
161
+ qs("#settings-lint-schedule-type")?.addEventListener("change", () => {
162
+ const type = qs("#settings-lint-schedule-type").value;
163
+ qs("#lint-time-group")?.classList.toggle("hidden", !(type === "daily" || type === "weekly"));
164
+ qs("#lint-day-group")?.classList.toggle("hidden", type !== "weekly");
165
+ qs("#lint-cron-group")?.classList.toggle("hidden", type !== "cron");
166
+ });
167
+ document.querySelectorAll('input[name="kordoc-mode"]').forEach((r) => r.addEventListener("change", () => toggleKordocPanels(r.value)));
168
+ qs("#kordoc-add-provider")?.addEventListener("click", () => {
169
+ const list = qs("#kordoc-providers-list");
170
+ if (!list) return;
171
+ const idx = list.querySelectorAll(".provider-card").length;
172
+ const card = renderProviderCard({}, idx);
173
+ list.appendChild(card);
174
+ card.querySelector(".provider-remove").addEventListener("click", () => {
175
+ card.remove();
176
+ list.querySelectorAll(".provider-card").forEach((c, i) => { c.dataset.idx = i; c.querySelector(".provider-remove").dataset.idx = i; });
177
+ });
178
+ });
179
+ qs("#kordoc-save")?.addEventListener("click", saveKordocSettings);
180
+ qs("#settings-reset-all")?.addEventListener("click", async () => {
181
+ if (!window.confirm("전체 초기화를 실행합니다.\n\n삭제 항목:\n• inbox, wiki, archive, watch 폴더\n• 수집 보고서, 그래프, 큐, 캐시 등 모든 데이터\n\n유지 항목:\n• 스케줄 (schedules)\n• 설정·인증 정보 (secure_config)\n\n이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?")) return;
182
+ try {
183
+ await postJson("/api/reset", {});
184
+ alert("초기화 완료");
185
+ } catch (e) {
186
+ alert(`초기화 실패: ${e.message}`);
187
+ }
188
+ });
189
+ await loadSettings();
190
+ await loadKordocSettings();
191
+ qs("#settings-lint-schedule-type")?.dispatchEvent(new Event("change"));
192
+ }
193
+
194
+ export async function unmount() {}
195
+
196
+ export default { mount, unmount };
@@ -0,0 +1,135 @@
1
+ import { getJson, postJson } from "../api.js";
2
+ import { qs, qsa, escapeHtml } from "../dom.js";
3
+ import { store } from "../store.js";
4
+
5
+ let currentMeta = {};
6
+
7
+ function parseFrontmatter(markdown = "") {
8
+ const match = markdown.match(/^---\n([\s\S]*?)\n---\n?/);
9
+ if (!match) return { body: markdown, meta: {} };
10
+
11
+ const meta = {};
12
+ match[1].split("\n").forEach((line) => {
13
+ const index = line.indexOf(":");
14
+ if (index === -1) return;
15
+ const key = line.slice(0, index).trim();
16
+ const value = line.slice(index + 1).trim();
17
+ meta[key] = value;
18
+ });
19
+
20
+ return {
21
+ meta,
22
+ body: markdown.slice(match[0].length),
23
+ };
24
+ }
25
+
26
+ function extractWikiLinks(markdown = "") {
27
+ return [...markdown.matchAll(/\[\[([^\]]+)\]\]/g)].map((match) => match[1].trim());
28
+ }
29
+
30
+ function updateWikiMeta(slug, markdown, meta = {}) {
31
+ const links = [...new Set(extractWikiLinks(markdown))];
32
+ currentMeta = { slug, meta, links };
33
+
34
+ if (qs("#wiki-meta-slug")) qs("#wiki-meta-slug").textContent = slug || "-";
35
+ if (qs("#wiki-meta-updated")) qs("#wiki-meta-updated").textContent = meta.updated_at || "-";
36
+ if (qs("#wiki-meta-links")) qs("#wiki-meta-links").textContent = String(links.length);
37
+
38
+ const related = qs("#wiki-meta-related");
39
+ if (!related) return;
40
+ related.innerHTML = links.slice(0, 10).map((item) => (
41
+ `<button class="wiki-related-link" type="button" data-wiki-related="${escapeHtml(item)}">${escapeHtml(item)}</button>`
42
+ )).join("") || '<div class="empty-state">연결 문서 없음</div>';
43
+
44
+ qsa("[data-wiki-related]", related).forEach((button) => {
45
+ button.addEventListener("click", () => wikiNavigate(button.dataset.wikiRelated));
46
+ });
47
+ }
48
+
49
+ function filterWikiNav() {
50
+ const keyword = (qs("#wiki-nav-filter")?.value || "").trim().toLowerCase();
51
+ qsa(".wiki-nav-item").forEach((button) => {
52
+ const visible = !keyword || button.textContent.toLowerCase().includes(keyword);
53
+ button.hidden = !visible;
54
+ });
55
+ }
56
+
57
+ async function loadWikiNav() {
58
+ try {
59
+ const data = await getJson("/api/wiki/INDEX");
60
+ const links = extractWikiLinks(data.content || "");
61
+ const nav = qs("#wiki-nav-list");
62
+ if (!nav) return;
63
+ const items = ["INDEX", ...links];
64
+ const seen = new Set();
65
+ nav.innerHTML = items.filter((item) => {
66
+ if (seen.has(item)) return false;
67
+ seen.add(item);
68
+ return true;
69
+ }).map((item) => (
70
+ `<button class="wiki-nav-item${item === store.wikiCurrentSlug ? " active" : ""}" type="button" data-slug="${escapeHtml(item)}">${escapeHtml(item)}</button>`
71
+ )).join("");
72
+ qsa(".wiki-nav-item", nav).forEach((button) => button.addEventListener("click", () => wikiNavigate(button.dataset.slug)));
73
+ filterWikiNav();
74
+ } catch {}
75
+ }
76
+
77
+ async function copyCurrentLink() {
78
+ const slug = store.wikiCurrentSlug || "INDEX";
79
+ const url = `${window.location.origin}/wiki#${encodeURIComponent(slug)}`;
80
+ try {
81
+ await navigator.clipboard.writeText(url);
82
+ } catch {}
83
+ }
84
+
85
+ async function wikiNavigate(slug = "INDEX") {
86
+ store.wikiCurrentSlug = slug;
87
+ const content = qs("#wiki-content");
88
+ const title = qs("#wiki-current-title");
89
+ if (content) content.innerHTML = '<div class="loading-state"><div class="loading" style="margin:0 auto;"></div></div>';
90
+ if (title) title.textContent = slug;
91
+ try {
92
+ const encoded = slug.split("/").map(encodeURIComponent).join("/");
93
+ const data = await getJson(`/api/wiki/${encoded}`);
94
+ const { meta, body } = parseFrontmatter(data.content || "");
95
+ let markdown = body || "";
96
+ markdown = markdown.replace(/\[\[([^\]]+)\]\]/g, (_, item) => `[${item}](#wiki-${item.replace(/\s/g, "_")})`);
97
+ updateWikiMeta(slug, data.content || "", meta);
98
+ if (content) content.innerHTML = window.marked.parse(markdown);
99
+ qsa('a[href^="#wiki-"]', content).forEach((link) => {
100
+ const target = decodeURIComponent(link.getAttribute("href").replace("#wiki-", "").replace(/_/g, " "));
101
+ link.addEventListener("click", (event) => {
102
+ event.preventDefault();
103
+ wikiNavigate(target);
104
+ });
105
+ });
106
+ qsa(".wiki-nav-item").forEach((button) => button.classList.toggle("active", button.dataset.slug === slug));
107
+ } catch (error) {
108
+ updateWikiMeta(slug, "", {});
109
+ if (content) content.innerHTML = `<div class="empty-state">페이지를 찾을 수 없습니다: ${escapeHtml(slug)}</div>`;
110
+ }
111
+ }
112
+
113
+ function bindActions() {
114
+ qs("#wiki-nav-filter")?.addEventListener("input", filterWikiNav);
115
+ qs('[data-action="wiki-copy-link"]')?.addEventListener("click", copyCurrentLink);
116
+ qs('[data-action="wiki-rebuild"]')?.addEventListener("click", async () => {
117
+ await postJson("/api/wiki/rebuild");
118
+ await loadWikiNav();
119
+ await wikiNavigate(store.wikiCurrentSlug || "INDEX");
120
+ });
121
+ qs('[data-wiki-slug="INDEX"]')?.addEventListener("click", () => wikiNavigate("INDEX"));
122
+ }
123
+
124
+ export async function mount() {
125
+ bindActions();
126
+ await loadWikiNav();
127
+ const hashSlug = window.location.hash ? decodeURIComponent(window.location.hash.slice(1)) : null;
128
+ await wikiNavigate(hashSlug || "INDEX");
129
+ }
130
+
131
+ export async function unmount() {
132
+ currentMeta = {};
133
+ }
134
+
135
+ export default { mount, unmount };
@@ -0,0 +1,58 @@
1
+ import { qs, setHtml } from "./dom.js";
2
+ import { store } from "./store.js";
3
+
4
+ const routes = {
5
+ "/": { name: "home", module: "/scripts/pages/home.js" },
6
+ "/search": { name: "search", module: "/scripts/pages/search.js" },
7
+ "/catalog": { name: "catalog", module: "/scripts/pages/catalog.js" },
8
+ "/wiki": { name: "wiki", module: "/scripts/pages/wiki.js" },
9
+ "/graph": { name: "graph", module: "/scripts/pages/graph.js" },
10
+ "/schedules": { name: "schedules", module: "/scripts/pages/schedules.js" },
11
+ "/pending": { name: "pending", module: "/scripts/pages/pending.js" },
12
+ "/failures": { name: "failures", module: "/scripts/pages/failures.js" },
13
+ "/settings": { name: "settings", module: "/scripts/pages/settings.js" },
14
+ };
15
+
16
+ async function getPageHtml(name) {
17
+ if (store.pageHtmlCache.has(name)) return store.pageHtmlCache.get(name);
18
+ const response = await fetch(`/pages/${name}.html`);
19
+ if (!response.ok) throw new Error(`PAGE ${name}`);
20
+ const html = await response.text();
21
+ store.pageHtmlCache.set(name, html);
22
+ return html;
23
+ }
24
+
25
+ async function getPageModule(path) {
26
+ if (store.pageModuleCache.has(path)) return store.pageModuleCache.get(path);
27
+ const mod = await import(path);
28
+ const page = mod.default ?? mod;
29
+ store.pageModuleCache.set(path, page);
30
+ return page;
31
+ }
32
+
33
+ export async function navigateTo(path, push = true) {
34
+ const [route, queryString = ""] = path.split("?");
35
+ const def = routes[route] ?? routes["/"];
36
+ const view = qs("#view");
37
+ if (!view) return;
38
+
39
+ if (store.currentPageModule?.unmount) await store.currentPageModule.unmount();
40
+
41
+ const html = await getPageHtml(def.name);
42
+ setHtml(view, html);
43
+
44
+ const pageCssId = "page-css";
45
+ document.getElementById(pageCssId)?.remove();
46
+ const link = document.createElement("link");
47
+ link.id = pageCssId;
48
+ link.rel = "stylesheet";
49
+ link.href = `/css/pages/${def.name}.css`;
50
+ document.head.appendChild(link);
51
+
52
+ const pageModule = await getPageModule(def.module);
53
+ store.currentRoute = route || "/";
54
+ store.currentPageModule = pageModule;
55
+
56
+ if (push) history.pushState({ path }, "", path);
57
+ if (pageModule.mount) await pageModule.mount(view, new URLSearchParams(queryString));
58
+ }
@@ -0,0 +1,81 @@
1
+ import { qsa, qs } from "./dom.js";
2
+ import { store } from "./store.js";
3
+ import { navigateTo } from "./router.js";
4
+ import { showCommandPalette, hideCommandPalette } from "./components/command-palette.js";
5
+
6
+ export function syncShell() {
7
+ qsa(".nav-item[data-route]").forEach((button) => {
8
+ button.classList.toggle("active", button.dataset.route === store.currentRoute);
9
+ });
10
+ const label = store.routeLabels[store.currentRoute] ?? "홈";
11
+ const labelEl = qs("#topbar-route-label");
12
+ if (labelEl) labelEl.textContent = label;
13
+ }
14
+
15
+ export function bindShell() {
16
+ document.addEventListener("click", (event) => {
17
+ const routeEl = event.target.closest("[data-route]");
18
+ if (!routeEl?.dataset.route) return;
19
+ event.preventDefault();
20
+ window.app?.navigateTo(routeEl.dataset.route);
21
+ });
22
+
23
+ qs("#search-main")?.addEventListener("keydown", (event) => {
24
+ if (event.key !== "Enter") return;
25
+ const q = event.currentTarget.value.trim();
26
+ if (q) window.app?.navigateTo(`/search?q=${encodeURIComponent(q)}`);
27
+ });
28
+
29
+ qs("#cmd-input")?.addEventListener("keydown", (event) => {
30
+ if (event.key === "Escape") hideCommandPalette();
31
+ });
32
+
33
+ qs("#cmd-overlay")?.addEventListener("click", (event) => {
34
+ if (event.target.id === "cmd-overlay") hideCommandPalette();
35
+ });
36
+
37
+ document.addEventListener("keydown", (event) => {
38
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
39
+ event.preventDefault();
40
+ showCommandPalette();
41
+ }
42
+ });
43
+
44
+ qs("#drawer-overlay")?.addEventListener("click", () => {
45
+ document.getElementById("drawer-overlay")?.classList.add("hidden");
46
+ document.getElementById("report-drawer")?.classList.remove("open");
47
+ document.getElementById("stage-track-panel")?.classList.remove("open");
48
+ });
49
+
50
+ qs("#drawer-close-btn")?.addEventListener("click", () => {
51
+ document.getElementById("drawer-overlay")?.classList.add("hidden");
52
+ const rd = document.getElementById("report-drawer");
53
+ rd?.classList.remove("open", "drawer-wide");
54
+ document.getElementById("file-viewer-backdrop")?.remove();
55
+ const tb = document.getElementById("drawer-pdf-toolbar");
56
+ if (tb) { tb.innerHTML = ""; tb.classList.add("hidden"); }
57
+ });
58
+ qs("#stage-track-close-btn")?.addEventListener("click", () => {
59
+ document.getElementById("drawer-overlay")?.classList.add("hidden");
60
+ document.getElementById("stage-track-panel")?.classList.remove("open");
61
+ });
62
+ qs("#stage-toast-close-btn")?.addEventListener("click", () => {
63
+ document.getElementById("stage-toast")?.classList.remove("open");
64
+ });
65
+
66
+ qs("#theme-btn")?.addEventListener("click", () => {
67
+ const isDark = document.documentElement.getAttribute("data-theme") === "dark";
68
+ document.documentElement.setAttribute("data-theme", isDark ? "light" : "dark");
69
+ });
70
+ }
71
+
72
+ export function setSseStatus(connected) {
73
+ store.sseConnected = connected;
74
+ const el = qs("#sse-status-pill");
75
+ if (!el) return;
76
+ el.textContent = connected ? "SSE 연결됨" : "SSE 재연결 중";
77
+ el.style.color = connected ? "var(--accent)" : "var(--label-2)";
78
+ el.style.background = connected ? "var(--accent-weak)" : "var(--fill-1)";
79
+ const homeEl = qs("#home-sse-status");
80
+ if (homeEl) homeEl.textContent = connected ? "정상" : "끊김";
81
+ }
@@ -0,0 +1,36 @@
1
+ import { store } from "./store.js";
2
+
3
+ const subscribers = new Map();
4
+
5
+ export function subscribe(type, handler) {
6
+ const list = subscribers.get(type) ?? new Set();
7
+ list.add(handler);
8
+ subscribers.set(type, list);
9
+ return () => list.delete(handler);
10
+ }
11
+
12
+ function publish(type, payload) {
13
+ const list = subscribers.get(type);
14
+ if (!list) return;
15
+ for (const handler of list) handler(payload);
16
+ }
17
+
18
+ export function connect() {
19
+ if (store.evtSource) return store.evtSource;
20
+ const source = new EventSource("/api/events");
21
+ store.evtSource = source;
22
+
23
+ source.onmessage = (event) => {
24
+ try {
25
+ const data = JSON.parse(event.data);
26
+ publish("message", data);
27
+ if (data?.type) publish(data.type, data);
28
+ } catch (error) {
29
+ console.error("SSE parse error:", error);
30
+ }
31
+ };
32
+
33
+ source.addEventListener("connected", () => publish("connection", { connected: true }));
34
+ source.onerror = () => publish("connection", { connected: false });
35
+ return source;
36
+ }
@@ -0,0 +1,32 @@
1
+ export const store = {
2
+ currentRoute: "/",
3
+ currentPageModule: null,
4
+ evtSource: null,
5
+ sseConnected: false,
6
+ stats: null,
7
+ health: null,
8
+ failuresCount: 0,
9
+ wikiCurrentSlug: "INDEX",
10
+ graph: {
11
+ nodes: [],
12
+ edges: [],
13
+ zoom: 1,
14
+ panX: 0,
15
+ panY: 0,
16
+ },
17
+ pageHtmlCache: new Map(),
18
+ pageModuleCache: new Map(),
19
+ commandItems: [],
20
+ commandSelected: 0,
21
+ routeLabels: {
22
+ "/": "홈",
23
+ "/search": "검색",
24
+ "/catalog": "보고서",
25
+ "/wiki": "위키",
26
+ "/graph": "그래프",
27
+ "/schedules": "스케줄",
28
+ "/pending": "대기 항목",
29
+ "/failures": "실패 현황",
30
+ "/settings": "설정",
31
+ },
32
+ };