fix(M02): 재검증 흠 넷 — 같은 이름 토스트 · 쓸 수 없는 글자 막기(화면·서버 400) · 저장 안 한 고침 버리기 확인 · 덮어쓰기 확인과 개인·회사 지우기 단추
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148XFUpPfpiuTjxF1EK9c95
This commit is contained in:
@@ -17,6 +17,7 @@ import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchUserProjects, type ProjectItem } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
|
||||
import {
|
||||
applyToProject,
|
||||
deleteLayerTemplate,
|
||||
deleteTemplate,
|
||||
fetchSources,
|
||||
listTemplates,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
resetProject,
|
||||
saveProjectAs,
|
||||
saveTemplate,
|
||||
StaleError,
|
||||
type Kind,
|
||||
type Layer,
|
||||
type TemplateInfo,
|
||||
@@ -78,10 +80,14 @@ const store = {
|
||||
|
||||
export interface SideOptions {
|
||||
isAdmin: boolean;
|
||||
/** 회사 관리자(회사 층 지우기 · 회사 공식으로 저장) */
|
||||
isCompanyAdmin?: boolean;
|
||||
/** 로그인한 사용자 이름 — 프로젝트 기본값(내가 만든 최근 것)에 씀 */
|
||||
userName?: string;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SideHandle {
|
||||
@@ -89,6 +95,10 @@ export interface SideHandle {
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */
|
||||
const badName = (name: string): boolean =>
|
||||
/[\\/:*?"<>|\x00-\x1f]|\.\./.test(name) || name.startsWith(".");
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
const failed = (error: unknown): void =>
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
@@ -144,7 +154,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
const layerButtons = LAYERS.map(({ layer: id, label }) => {
|
||||
const button = createButton({ label: label(), variant: "ghost" });
|
||||
button.disabled = id === "system" && !opt.isAdmin;
|
||||
button.addEventListener("click", () => {
|
||||
button.addEventListener("click", async () => {
|
||||
if (id !== layer && !(await opt.confirmLeave())) return;
|
||||
layer = id;
|
||||
store.set(LAYER_KEY, id);
|
||||
opt.onOpen(null);
|
||||
@@ -173,7 +184,11 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
};
|
||||
|
||||
const projectField = createSelectField({ label: L("M02_Project"), options: [] });
|
||||
projectField.select.addEventListener("change", () => {
|
||||
projectField.select.addEventListener("change", async () => {
|
||||
if (!(await opt.confirmLeave())) {
|
||||
projectField.select.value = projectId;
|
||||
return;
|
||||
}
|
||||
projectId = projectField.select.value;
|
||||
remember(projectId);
|
||||
opt.onOpen(null);
|
||||
@@ -221,7 +236,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
text: info.이름,
|
||||
attrs: { type: "button", title: info.수정일 },
|
||||
});
|
||||
b.addEventListener("click", () => select(info));
|
||||
b.addEventListener("click", () => void select(info));
|
||||
return b;
|
||||
})
|
||||
: [el("p", { className: "m02-side__empty", text: L("M02_NoTemplates") })]),
|
||||
@@ -235,14 +250,19 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
layerButtons.forEach(({ id, button }) => button.classList.toggle("is-active", id === layer));
|
||||
projectField.root.hidden = !isProject();
|
||||
const editable = layer === "system" && canEdit(layer, opt.isAdmin);
|
||||
editRow.hidden = layer !== "system";
|
||||
const canDelete =
|
||||
editable || layer === "personal" || (layer === "company" && !!opt.isCompanyAdmin);
|
||||
editRow.hidden = isProject();
|
||||
btnNew.hidden = btnCopy.hidden = layer !== "system";
|
||||
btnNew.disabled = !editable;
|
||||
btnCopy.disabled = btnDel.disabled = !editable || !currentInfo();
|
||||
btnCopy.disabled = !editable || !currentInfo();
|
||||
btnDel.disabled = !canDelete || !currentInfo();
|
||||
projectRow.hidden = !isProject();
|
||||
projectRow.querySelectorAll("button").forEach((b) => (b.disabled = !projectId));
|
||||
}
|
||||
|
||||
function select(info: TemplateInfo): void {
|
||||
async function select(info: TemplateInfo): Promise<void> {
|
||||
if (!isOpen(info) && !(await opt.confirmLeave())) return;
|
||||
opt.onOpen({ layer, projectId: pid(), kind: info.종류, name: info.이름 });
|
||||
paintList();
|
||||
paintButtons();
|
||||
@@ -298,11 +318,16 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
ok.addEventListener("click", async () => {
|
||||
const text = name.input.value.trim();
|
||||
if (!text) return showToast(L("M02_NameNeeded"), "error");
|
||||
if (badName(text)) return showToast(L("M02_BadName"), "error");
|
||||
ok.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
await onOk(kind.select.value as Kind, text);
|
||||
close();
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
if (error instanceof StaleError) showToast(L("M02_NameExists"), "error");
|
||||
else failed(error);
|
||||
} finally {
|
||||
ok.disabled = false;
|
||||
}
|
||||
});
|
||||
body.append(...(withKind ? [kind.root] : []), name.root, ok);
|
||||
@@ -315,7 +340,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
showToast(L("M02_Created"), "success");
|
||||
await refresh();
|
||||
const info = items.find((i) => i.종류 === kind && i.이름 === name);
|
||||
if (info) select(info);
|
||||
if (info) await select(info);
|
||||
};
|
||||
|
||||
btnNew.addEventListener("click", () =>
|
||||
@@ -339,7 +364,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
if (!info) return;
|
||||
if (!(await showConfirmDialog(L("M02_DeleteConfirm").replace("{value}", info.이름)))) return;
|
||||
try {
|
||||
await deleteTemplate(info.종류, info.이름, info.판);
|
||||
if (layer === "system") await deleteTemplate(info.종류, info.이름, info.판);
|
||||
else await deleteLayerTemplate(layer, info.종류, info.이름);
|
||||
showToast(L("M02_Deleted"), "success");
|
||||
opt.onOpen(null);
|
||||
await refresh();
|
||||
@@ -350,6 +376,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
|
||||
/* --- 프로젝트 층 단추 다섯 — 길은 계약 · 아직 없으면 404 를 토스트로 --- */
|
||||
const act = async (run: () => Promise<unknown>): Promise<void> => {
|
||||
if (!(await opt.confirmLeave())) return;
|
||||
try {
|
||||
await run();
|
||||
showToast(L("M02_Done"), "success");
|
||||
@@ -364,9 +391,21 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
b.addEventListener("click", onClick);
|
||||
projectRow.append(b);
|
||||
};
|
||||
const saveOpenAs = (to: "personal" | "company"): void => {
|
||||
const saveOpenAs = async (to: "personal" | "company"): Promise<void> => {
|
||||
const cur = opt.getOpen();
|
||||
if (!cur) return void showToast(L("M02_PickTemplate"), "error");
|
||||
try {
|
||||
const rows = await listTemplates(to, null);
|
||||
const same = rows.some((r) => r.종류 === cur.kind && r.이름 === cur.name);
|
||||
if (
|
||||
same &&
|
||||
!(await showConfirmDialog(L("M02_OverwriteConfirm").replace("{value}", cur.name)))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
return failed(error);
|
||||
}
|
||||
void act(() => saveProjectAs(projectId, to, cur.kind, cur.name));
|
||||
};
|
||||
projectButton(L("M02_ProjectReset"), async () => {
|
||||
@@ -407,8 +446,8 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
},
|
||||
});
|
||||
});
|
||||
projectButton(L("M02_ProjectSavePersonal"), () => saveOpenAs("personal"));
|
||||
projectButton(L("M02_ProjectSaveCompany"), () => saveOpenAs("company"));
|
||||
projectButton(L("M02_ProjectSavePersonal"), () => void saveOpenAs("personal"));
|
||||
projectButton(L("M02_ProjectSaveCompany"), () => void saveOpenAs("company"));
|
||||
|
||||
void refresh();
|
||||
return { root, refresh };
|
||||
|
||||
Reference in New Issue
Block a user