- 그림(또는 빈 그림 자리) 더블클릭 → 숨은 파일 고르기(png · jpg · webp) → 새 판으로 그 항목만 다시 그림
· 그림 한 번 누름 = 아무 일 없음 · 그림 아래 설명 더블클릭 = 그림 줄 글 고침
- POST /api/help/{page}/img (multipart · name = 그 그림 이름) — require_system_admin(403) · 확장자 ·
5 MB · Pillow 로 진짜 그림 확인 · 폭 1800 px 넘으면 줄임 · WebP 로 그 이름에 원자 쓰기 ·
_manual.json(손으로 바꾼 그림 이름 — 자동 캡처가 안 덮음)에 더함
- 시험: WebP · 줄임 · manual · 확장자 · 크기 · 그림 아님 · 경로 탈출 · 403
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
7.1 KiB
TypeScript
174 lines
7.1 KiB
TypeScript
/* =============================================================================
|
|
* ui_template_help_edit.ts
|
|
* [?] 사용자 가이드 바로 고침(538-3) — 시스템 관리자만(서버도 시스템 관리자만 받음).
|
|
* 덩어리(제목 · 문단 · 목록 · 표 · 주의 상자 · 그림 줄) 더블클릭 → 그 덩어리 원문 마크다운 textarea 로 바꿔 끼움.
|
|
* 바깥 누름 · Ctrl+Enter = 저장(안 바뀌었으면 요청 안 함) · Esc = 취소 · 한글 조합 중 Enter · Esc 무시 ·
|
|
* 고침이 열린 채 모달을 닫으면 저장(`flush`).
|
|
* 저장 = `PUT /api/help/{page}/{key}` {version, start, end, text} → 그 항목만 다시 그림 ·
|
|
* 409 = 「다른 관리자가 먼저 고침」 → 다시 불러옴 + 내 글은 그 절 머리에 보여 둠.
|
|
* 그림 바꾸기(538-4) = 그림(또는 빈 그림 자리) 더블클릭 → 파일 고르기 → `POST /api/help/{page}/img`
|
|
* (그 그림 이름에 WebP 로 덮음) → 새 판으로 그 항목만 다시 그림. 그림 한 번 누름 = 아무 일 없음.
|
|
* ========================================================================== */
|
|
|
|
import { API_BASE_URL } from "@config/config_frontend";
|
|
import { el, showToast } from "./ui_template_elements";
|
|
import type { HelpGuide, HelpItem, HelpPage } from "./ui_template_help";
|
|
|
|
const TEXT = {
|
|
conflict: "다른 관리자가 먼저 고침 — 다시 불러옴",
|
|
failed: "저장 못 함 — 다시 불러옴",
|
|
mine: "내가 쓴 글 — 저장 안 됨 · 복사해 다시 넣음",
|
|
imgFailed: "그림 못 바꿈",
|
|
};
|
|
|
|
type Render = (page: HelpPage, item: HelpItem) => string;
|
|
|
|
export function attachHelpEditing(
|
|
article: HTMLElement,
|
|
pages: HelpPage[],
|
|
render: Render,
|
|
): { flush: () => Promise<boolean> } {
|
|
let open: (() => Promise<void>) | null = null;
|
|
let pending: Promise<void> = Promise.resolve();
|
|
article.classList.add("is-editable");
|
|
|
|
const find = (section: HTMLElement): { page: HelpPage; item: HelpItem } | null => {
|
|
const page = pages.find((p) => p.page === section.dataset.page);
|
|
const item = page?.items.find((i) => i.key === section.dataset.key);
|
|
return page && item ? { page, item } : null;
|
|
};
|
|
|
|
/** 서버의 지금 글로 그 항목 · 그림 판을 바꿔 둠 */
|
|
const reload = async (page: HelpPage, item: HelpItem): Promise<void> => {
|
|
const response = await fetch(`${API_BASE_URL}/help`, {
|
|
credentials: "include",
|
|
cache: "no-store",
|
|
});
|
|
if (!response.ok) return;
|
|
const fresh = ((await response.json()) as HelpGuide).pages.find((p) => p.page === page.page);
|
|
const freshItem = fresh?.items.find((i) => i.key === item.key);
|
|
if (fresh) page.images = fresh.images;
|
|
if (freshItem) Object.assign(item, freshItem);
|
|
};
|
|
|
|
const keepMine = (section: HTMLElement, text: string): void => {
|
|
const area = el("textarea", { className: "ui-help__edit", attrs: { readonly: "" } });
|
|
area.value = text;
|
|
section.prepend(el("div", { className: "ui-help__mine", children: [TEXT.mine, area] }));
|
|
};
|
|
|
|
const edit = (section: HTMLElement, block: HTMLElement): void => {
|
|
const found = find(section);
|
|
if (!found) return;
|
|
const { page, item } = found;
|
|
const [start, end] = (block.dataset.src ?? "").split("-").map(Number);
|
|
const before = item.text
|
|
.split(/\r?\n/)
|
|
.slice(start, end + 1)
|
|
.join("\n");
|
|
const area = el("textarea", { className: "ui-help__edit" });
|
|
area.value = before;
|
|
const fit = (): void => {
|
|
area.style.height = "auto";
|
|
area.style.height = `${area.scrollHeight + 2}px`;
|
|
};
|
|
let done = false;
|
|
const finish = async (keep: boolean): Promise<void> => {
|
|
if (done) return;
|
|
done = true;
|
|
open = null;
|
|
const text = area.value;
|
|
if (!keep || text === before) return void area.replaceWith(block);
|
|
area.disabled = true;
|
|
const response = await fetch(
|
|
`${API_BASE_URL}/help/${page.page}/${encodeURIComponent(item.key)}`,
|
|
{
|
|
method: "PUT",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ version: item.version, start, end, text }),
|
|
},
|
|
).catch(() => null);
|
|
if (response?.ok) {
|
|
Object.assign(item, ((await response.json()) as { item: HelpItem }).item);
|
|
section.innerHTML = render(page, item);
|
|
return;
|
|
}
|
|
showToast(response?.status === 409 ? TEXT.conflict : TEXT.failed, "warning");
|
|
await reload(page, item).catch(() => undefined);
|
|
section.innerHTML = render(page, item);
|
|
keepMine(section, text);
|
|
};
|
|
area.addEventListener("input", fit);
|
|
area.addEventListener("keydown", (ev) => {
|
|
if (ev.isComposing || ev.keyCode === 229) return;
|
|
if (ev.key === "Escape") {
|
|
ev.preventDefault(); // 모달 Esc 닫기도 막음
|
|
ev.stopPropagation();
|
|
void finish(false);
|
|
} else if (ev.key === "Enter" && (ev.ctrlKey || ev.metaKey)) {
|
|
ev.preventDefault();
|
|
pending = finish(true);
|
|
}
|
|
});
|
|
area.addEventListener("blur", () => (pending = finish(true)));
|
|
open = () => (pending = finish(true));
|
|
block.replaceWith(area);
|
|
fit();
|
|
area.focus();
|
|
};
|
|
|
|
// 그림 고르기 — 하나를 돌려 씀 · 고른 뒤 값 비움(같은 파일 다시 골라도 change)
|
|
const picker = el("input", {
|
|
attrs: { type: "file", accept: "image/png,image/jpeg,image/webp" },
|
|
});
|
|
let pickFor: { section: HTMLElement; name: string } | null = null;
|
|
picker.addEventListener("change", async () => {
|
|
const file = picker.files?.[0];
|
|
picker.value = "";
|
|
const found = pickFor && find(pickFor.section);
|
|
if (!file || !pickFor || !found) return;
|
|
const { section, name } = pickFor;
|
|
const form = new FormData();
|
|
form.append("name", name);
|
|
form.append("file", file);
|
|
const response = await fetch(`${API_BASE_URL}/help/${found.page.page}/img`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
body: form,
|
|
}).catch(() => null);
|
|
const result = (await response?.json().catch(() => null)) as {
|
|
version?: string;
|
|
detail?: string;
|
|
} | null;
|
|
if (!response?.ok || !result?.version)
|
|
return void showToast(result?.detail ?? TEXT.imgFailed, "error");
|
|
found.page.images = { ...found.page.images, [name]: result.version };
|
|
section.innerHTML = render(found.page, found.item);
|
|
});
|
|
|
|
article.addEventListener("dblclick", (ev) => {
|
|
const target = ev.target as HTMLElement;
|
|
const section = target.closest<HTMLElement>(".ui-help__item");
|
|
if (!section || open || target.closest("textarea, .ui-help__mine")) return;
|
|
const figure = target.closest<HTMLElement>("figure[data-img]");
|
|
if (figure && !target.closest("figcaption")) {
|
|
pickFor = { section, name: figure.dataset.img ?? "" };
|
|
return void picker.click();
|
|
}
|
|
const block = target.closest<HTMLElement>("[data-src]");
|
|
if (!block || !section.contains(block)) return;
|
|
ev.preventDefault();
|
|
window.getSelection()?.removeAllRanges();
|
|
edit(section, block);
|
|
});
|
|
|
|
return {
|
|
flush: async () => {
|
|
if (open) await open();
|
|
await pending;
|
|
return true;
|
|
},
|
|
};
|
|
}
|