@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,134 @@
1
+ import { getJson } from "../api.js";
2
+ import { qs, escapeHtml } from "../dom.js";
3
+ import { openFileViewer } from "../components/file-viewer.js";
4
+
5
+ function relativeTime(ts) {
6
+ if (!ts) return "";
7
+ const diff = Date.now() - (typeof ts === "number" ? ts : new Date(ts).getTime());
8
+ const mins = Math.floor(diff / 60000);
9
+ if (mins < 60) return `${mins}분 전`;
10
+ const hours = Math.floor(mins / 60);
11
+ if (hours < 24) return `${hours}시간 전`;
12
+ return `${Math.floor(hours / 24)}일 전`;
13
+ }
14
+
15
+ let _clickHandler = null;
16
+
17
+ async function loadCatalog(page = 1) {
18
+ const params = new URLSearchParams({ page: String(page), limit: "20" });
19
+ [["catalog-source", "source"], ["catalog-status", "status"], ["catalog-lang", "lang"], ["catalog-q", "q"]].forEach(([id, key]) => {
20
+ const value = qs(`#${id}`)?.value?.trim();
21
+ if (value) params.set(key, value);
22
+ });
23
+ const data = await getJson(`/api/reports?${params}`);
24
+ const list = qs("#catalog-list");
25
+ const summary = qs("#catalog-summary");
26
+ const pages = qs("#catalog-pagination");
27
+
28
+ if (summary) {
29
+ const total = data.total || 0;
30
+ const from = total === 0 ? 0 : (page - 1) * (data.limit || 20) + 1;
31
+ const to = Math.min(page * (data.limit || 20), total);
32
+ summary.textContent = total > 0 ? `${total.toLocaleString()}건 중 ${from}–${to} 표시` : "결과 없음";
33
+ }
34
+
35
+ if (list) {
36
+ if (_clickHandler) list.removeEventListener("click", _clickHandler);
37
+ _clickHandler = (e) => {
38
+ const row = e.target.closest(".catalog-row");
39
+ if (!row) return;
40
+
41
+ const hash = row.dataset.hash;
42
+ const rawPath = row.dataset.rawPath || null;
43
+ const mdPath = row.dataset.mdPath || null;
44
+
45
+ const fileLink = e.target.closest("[data-open]");
46
+ if (fileLink) {
47
+ if (fileLink.dataset.open === "raw") {
48
+ openFileViewer(hash, rawPath, null);
49
+ } else if (fileLink.dataset.open === "md") {
50
+ openFileViewer(hash, null, mdPath);
51
+ }
52
+ return;
53
+ }
54
+ openFileViewer(hash, rawPath, mdPath);
55
+ };
56
+ list.addEventListener("click", _clickHandler);
57
+
58
+ list.innerHTML = (data.items || []).map((item) => {
59
+ const hasRaw = !!(item.file_path);
60
+ const hasMd = !!(item.md_path);
61
+ const rawFile = (item.file_path || "").split(/[\\/]/).pop() || "";
62
+ const rawExt = rawFile.split(".").pop().toUpperCase() || "FILE";
63
+
64
+ const metaParts = [
65
+ item.year ? `${item.year}년` : null,
66
+ item.source ? item.source.toUpperCase() : null,
67
+ ].filter(Boolean);
68
+
69
+ const effectiveStatus = (item.status === "indexed" || item.status === "INDEXED") && !item.md_path
70
+ ? "partial" : (item.status || "").toLowerCase();
71
+
72
+ const statusColors = {
73
+ indexed: "var-green",
74
+ failed: "var-red",
75
+ error: "var-red",
76
+ partial: "var-yellow",
77
+ pending: "var-blue",
78
+ };
79
+ const statusColor = statusColors[effectiveStatus] || "";
80
+ const statusLabel = effectiveStatus === "partial" ? "PARTIAL" : (item.status || "").toUpperCase();
81
+
82
+ const mdFile = (item.md_path || "").split(/[\\/]/).pop() || "";
83
+
84
+ return `<div class="catalog-row" role="button" tabindex="0"
85
+ data-hash="${escapeHtml(item.hash || "")}"
86
+ data-raw-path="${escapeHtml(item.file_path || "")}"
87
+ data-md-path="${escapeHtml(item.md_path || "")}">
88
+ <div class="catalog-row-body">
89
+ <div class="catalog-row-title">${escapeHtml(item.title || "제목 없음")}</div>
90
+ ${metaParts.length ? `<div class="catalog-row-meta">${metaParts.map(escapeHtml).join(" · ")}${effectiveStatus ? ` · <span class="catalog-status ${statusColor}">${escapeHtml(statusLabel)}</span>` : ""}</div>` : ""}
91
+ ${hasRaw ? `<div class="catalog-file-name" data-open="raw" title="${escapeHtml(rawFile)}">${escapeHtml(rawFile)}</div>` : ""}
92
+ ${hasMd ? `<div class="catalog-file-name md" data-open="md"><i class="fa-solid fa-arrow-right-long"></i> ${escapeHtml(mdFile)}</div>` : ""}
93
+ </div>
94
+ </div>`;
95
+ }).join("") || '<div class="empty-state">보고서 없음</div>';
96
+ }
97
+
98
+ if (pages) {
99
+ const totalPages = Math.ceil((data.total || 0) / (data.limit || 20));
100
+ pages.innerHTML = Array.from({ length: totalPages }, (_, idx) => idx + 1)
101
+ .map((p) => `<button class="btn sm ${p === page ? "primary" : ""}" data-page="${p}">${p}</button>`).join("");
102
+ pages.querySelectorAll("[data-page]").forEach((button) => button.addEventListener("click", () => loadCatalog(Number(button.dataset.page))));
103
+ }
104
+ }
105
+
106
+ export async function mount() {
107
+ const sourceSelect = qs("#catalog-source");
108
+ if (sourceSelect && sourceSelect.options.length <= 1) {
109
+ sourceSelect.insertAdjacentHTML("beforeend", `
110
+ <option value="prism">PRISM</option>
111
+ <option value="nanet">NANET</option>
112
+ <option value="upload">UPLOAD</option>
113
+ `);
114
+ }
115
+ ["catalog-source", "catalog-status", "catalog-lang"].forEach((id) => qs(`#${id}`)?.addEventListener("change", () => loadCatalog(1)));
116
+ qs("#catalog-q")?.addEventListener("keydown", (e) => { if (e.key === "Enter") loadCatalog(1); });
117
+ document.querySelector('[data-action="catalog-refresh"]')?.addEventListener("click", () => loadCatalog(1));
118
+ document.querySelector('[data-action="catalog-reset"]')?.addEventListener("click", () => {
119
+ ["catalog-source", "catalog-status", "catalog-lang", "catalog-q"].forEach((id) => {
120
+ const el = qs(`#${id}`);
121
+ if (el) el.value = "";
122
+ });
123
+ loadCatalog(1);
124
+ });
125
+ await loadCatalog(1);
126
+ }
127
+
128
+ export async function unmount() {
129
+ const list = qs("#catalog-list");
130
+ if (list && _clickHandler) list.removeEventListener("click", _clickHandler);
131
+ _clickHandler = null;
132
+ }
133
+
134
+ export default { mount, unmount };
@@ -0,0 +1,72 @@
1
+ import { getJson, postJson } from "../api.js";
2
+ import { qs, escapeHtml } from "../dom.js";
3
+
4
+ async function loadFailures() {
5
+ const data = await getJson("/api/failures?dismissed=false");
6
+ const items = data.items || [];
7
+ if (qs("#failures-stat-total")) qs("#failures-stat-total").textContent = String(items.length);
8
+ if (qs("#failures-stat-today")) qs("#failures-stat-today").textContent = String(items.filter((item) => {
9
+ const ts = new Date(item.updated_at || item.failed_at || item.created_at || 0).getTime();
10
+ return Date.now() - ts < 86400000;
11
+ }).length);
12
+ if (qs("#failures-stat-retry")) qs("#failures-stat-retry").textContent = String(items.length);
13
+ if (qs("#failures-stat-resolved")) qs("#failures-stat-resolved").textContent = "-";
14
+ const list = qs("#failures-list");
15
+ if (!list) return;
16
+ list.innerHTML = items.map((item) => `
17
+ <div class="card">
18
+ <div class="fail-row">
19
+ <div class="f-ico danger"><i class="fa-solid fa-triangle-exclamation"></i></div>
20
+ <div>
21
+ <div class="f-title">${escapeHtml(item.title || item.docid || "제목 없음")}</div>
22
+ <div class="f-meta">
23
+ <span class="chip err">${escapeHtml(item.failed_stage || item.status || "error")}</span>
24
+ <span>${item.failed_at ? new Date(item.failed_at).toLocaleString("ko-KR") : ""}</span>
25
+ </div>
26
+ <pre class="f-err">${escapeHtml(item.failed_reason || item.last_error || item.error_message || "")}</pre>
27
+ </div>
28
+ <div class="f-side">
29
+ <button class="btn sm" data-retry-hash="${escapeHtml(item.hash)}">재시도</button>
30
+ <button class="btn sm ghost" data-dismiss-hash="${escapeHtml(item.hash)}">숨김</button>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ `).join("") || '<div class="empty-state">실패 항목 없음</div>';
35
+ list.querySelectorAll("[data-retry-hash]").forEach((button) => button.addEventListener("click", async () => {
36
+ const hash = button.dataset.retryHash;
37
+ button.disabled = true;
38
+ button.textContent = "재시도 중…";
39
+ try {
40
+ const result = await postJson(`/api/failures/${encodeURIComponent(hash)}/retry`);
41
+ if (!result?.ok) throw new Error(result?.error || "재시도 등록 실패");
42
+ button.closest(".card")?.remove();
43
+ } catch (err) {
44
+ console.error("retry failed", err);
45
+ alert(`재시도 실패: ${err.message}`);
46
+ button.disabled = false;
47
+ button.textContent = "재시도";
48
+ } finally {
49
+ setTimeout(() => loadFailures().catch(() => {}), 800);
50
+ }
51
+ }));
52
+ list.querySelectorAll("[data-dismiss-hash]").forEach((button) => button.addEventListener("click", async () => {
53
+ await postJson(`/api/failures/${button.dataset.dismissHash}/dismiss`);
54
+ loadFailures();
55
+ }));
56
+ }
57
+
58
+ export async function mount() {
59
+ qs("#failures-retry-all")?.addEventListener("click", async () => {
60
+ await postJson("/api/failures/retry-all");
61
+ loadFailures();
62
+ });
63
+ qs("#failures-dismiss-all")?.addEventListener("click", async () => {
64
+ await postJson("/api/failures/dismiss-all");
65
+ loadFailures();
66
+ });
67
+ await loadFailures();
68
+ }
69
+
70
+ export async function unmount() {}
71
+
72
+ export default { mount, unmount };