feat: xavis ai_platform 기능 이전 및 ncue 환경 전환

xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영.
@xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-05-26 22:27:48 +09:00
parent 7bee72f287
commit 073a8343dd
84 changed files with 10883 additions and 1043 deletions

771
views/ai-cases-compose.ejs Normal file
View File

@@ -0,0 +1,771 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<%- include('partials/favicon') %>
<title>AI 활용 사례 &gt; 작성하기 - XAVIS</title>
<link rel="stylesheet" href="https://uicdn.toast.com/editor/3.2.2/toastui-editor.min.css" />
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
<div class="content-area">
<header class="topbar">
<h1>AI 활용 사례 &gt; <%= editPayload ? "수정" : "작성" %></h1>
<a class="top-action-link top-action-link--ghost" href="/ai-cases">목록</a>
</header>
<main class="container container-narrow use-case-compose">
<p class="use-case-compose__hint">
<strong>작성자: </strong> <%= typeof submitterEmail !== "undefined" ? submitterEmail : "" %>
</p>
<form id="use-case-form" class="use-case-compose__form" novalidate>
<div class="use-case-field">
<label for="uctitle">제목 <span class="req">*</span></label>
<input
type="text"
id="uctitle"
name="title"
class="use-case-title-input"
required
maxlength="200"
placeholder="제목을 입력하세요"
autocomplete="off"
autofocus
/>
</div>
<div class="use-case-section use-case-section--rich use-case-section--merged">
<label class="label-block" for="edUseCaseBody">본문 (STAR: 1~4번) <span class="req">*</span></label>
<p class="field-hint use-case-merge-hint">한 편집기 안에서 1.Situation → 4.Result 순서로 작성합니다.<br>구역을 나누는 제목·블록을 삭제하면 저장이 실패할 수 있습니다.</p>
<div id="edUseCaseBody" class="use-case-editor-host use-case-editor-host--merged" aria-label="STAR 본문"></div>
</div>
<p class="use-case-charcount" id="ucCharLine" aria-live="polite">0 / <%= maxBodyTotal %>자</p>
<div class="use-case-compose__meta">
<div class="use-case-field use-case-field--tags">
<label for="uctags">활용 AI</label>
<input
type="text"
id="uctags"
name="tags"
class="use-case-tag-input"
placeholder="쉼표로 구분 (예, Claude, Slack, Canvas)"
autocomplete="off"
/>
<p class="field-hint">활용한 AI 툴을 입력해주세요.</p>
</div>
<div class="use-case-field" id="ucThumbField">
<span class="label-block">썸네일 이미지 <% if (!editPayload) { %><span class="req">*</span><% } else { %><span class="opt">(변경·추가 시에만)</span><% } %></span>
<p class="field-hint" id="ucThumbFieldHint">최대 <%= maxThumbCount %>개, 개당 5MB, 가급 1:1 비율 권장</p>
<% if (typeof editPayload !== "undefined" && editPayload) { %>
<p class="field-hint use-case-attach-hint">이미지 우측 상단 <strong>X</strong>로 삭제할 수 있습니다. 새 이미지는 아래에서 고르면 목록 끝에 추가됩니다(합계 최대 <%= maxThumbCount %>개).</p>
<% } %>
<div class="use-case-dropzone" id="ucThumbZone">
<input type="file" id="ucThumb" name="thumbnail" accept="image/*" class="use-case-file-input" multiple />
<label for="ucThumb" class="use-case-dropzone__label"
>클릭하거나 드래그하여 썸네일을 선택하세요 (최대 <%= maxThumbCount %>개)</label
>
</div>
<div class="use-case-thumb-grid" id="ucThumbPreviewGrid" hidden>
<% if (typeof editPayload !== "undefined" && editPayload && editPayload.existingThumbnails && editPayload.existingThumbnails.length) { %>
<% editPayload.existingThumbnails.forEach(function (t) { var fn = (t.originalName || "썸네일").replace(/[<>"]/g, "") || (t.relativePath && t.relativePath.split("/").pop()) || "썸네일"; %>
<figure class="use-case-thumb-tile" data-existing-rp="<%- encodeURIComponent(t.relativePath) %>">
<img src="<%= t.relativePath %>" alt="<%= fn %>" loading="lazy" decoding="async" />
<button type="button" class="use-case-thumb-remove" aria-label="<%= fn %> 제거" title="제거">×</button>
</figure>
<% }); %>
<% } %>
</div>
</div>
<div class="use-case-field">
<span class="label-block">첨부 파일 <span class="opt">(선택, 최대 <%= maxAttachCount %>개·20MB)</span></span>
<% if (typeof editPayload !== "undefined" && editPayload) { %>
<p class="field-hint use-case-attach-hint">수정 시: 아래 <strong>기존 첨부</strong>를 체크해 삭제한 뒤 새 파일을 선택할 수 있습니다(최대 <%= maxAttachCount %>개).</p>
<% } %>
<input type="file" id="ucFiles" name="attachments" class="use-case-file-multi" />
<% if (typeof editPayload !== "undefined" && editPayload && editPayload.existingAttachments && editPayload.existingAttachments.length) { %>
<p class="field-hint use-case-existing-attach__title">기존 첨부 — 제거하려면 체크</p>
<ul class="use-case-existing-attach">
<% editPayload.existingAttachments.forEach(function (a) { var fn = (a.originalName || "").replace(/[<>"]/g, "") || (a.relativePath && a.relativePath.split("/").pop()) || "첨부"; %>
<li>
<label class="use-case-cb"
><input type="checkbox" class="js-uc-remove-attach" data-rp="<%- encodeURIComponent(a.relativePath) %>" /> <%= fn %></label
>
</li>
<% }); %>
</ul>
<% } %>
<ul class="use-case-file-list" id="ucFileList" hidden></ul>
</div>
</div>
<div class="use-case-actions">
<a href="/ai-cases" class="btn-ghost use-case-btn-cancel">취소</a>
<button type="submit" class="top-action use-case-btn-save" id="ucSubmit"><%= editPayload ? "수정 저장" : "저장하기" %></button>
</div>
<p class="form-message use-case-form-msg" id="ucFormMsg" hidden></p>
<% if (editPayload) { %>
<script type="application/json" id="uc-edit-json">
<%- JSON.stringify(editPayload).replace(/</g, "\\u003c") %>
</script>
<% } %>
</form>
</main>
</div>
</div>
<script src="https://uicdn.toast.com/editor/3.2.2/toastui-editor-all.min.js"></script>
<script src="https://uicdn.toast.com/editor/3.2.2/i18n/ko-kr.min.js"></script>
<script>
(function () {
var maxTotal = <%= maxBodyTotal %>;
var maxThumbCount = <%= maxThumbCount %>;
var maxAttachCount = <%= maxAttachCount %>;
var ph = {
situation: <%- JSON.stringify(placeholders.situation) %>,
task: <%- JSON.stringify(placeholders.task) %>,
action: <%- JSON.stringify(placeholders.action) %>,
result: <%- JSON.stringify(placeholders.result) %>,
};
var charLine = document.getElementById("ucCharLine");
var editJson = document.getElementById("uc-edit-json");
var editPayload = null;
if (editJson) {
try {
editPayload = JSON.parse(editJson.textContent);
} catch (e) {
editPayload = null;
}
}
var form = document.getElementById("use-case-form");
var msg = document.getElementById("ucFormMsg");
var submitBtn = document.getElementById("ucSubmit");
var thumb = document.getElementById("ucThumb");
var thumbGrid = document.getElementById("ucThumbPreviewGrid");
var pendingThumbFiles = [];
var pendingThumbUrls = [];
var filesIn = document.getElementById("ucFiles");
var fileList = document.getElementById("ucFileList");
var sectionIds = ["uc-situation", "uc-task", "uc-action", "uc-result"];
var fieldKeys = ["situation", "taskGoal", "actionTaken", "resultOutcome"];
var phKeysByField = {
situation: "situation",
taskGoal: "task",
actionTaken: "action",
resultOutcome: "result",
};
var placeholdersActive = !editPayload;
var examplesComposeMode = !editPayload;
if (typeof toastui === "undefined" || !toastui.Editor) {
if (form) {
var p = document.createElement("p");
p.className = "form-message use-case-form-msg";
p.style.color = "#b91c1c";
p.textContent = "에디터를 불러오지 못했습니다. 네트워크·방화벽을 확인하거나 uicdn.toast.com 접속을 허용해 주세요.";
form.insertBefore(p, form.firstChild);
}
return;
}
function esc(s) {
if (s == null) return "";
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function paraFromPh(text) {
if (!text) return "<p><br></p>";
return (
'<p class="uc-example-text" data-uc-example="1">' +
esc(text).replace(/\r\n/g, "\n").split("\n").join("<br>") +
"</p>"
);
}
function buildHtmlFromSections(sec) {
sec = sec || {};
return (
'<div class="uc-doc">' +
'<div id="uc-situation" class="uc-section"><h2>1. Situation (배경)</h2>' +
(sec.situation || "<p><br></p>") +
"</div>" +
'<div id="uc-task" class="uc-section"><h2>2. Task (과제/목표)</h2>' +
(sec.taskGoal || "<p><br></p>") +
"</div>" +
'<div id="uc-action" class="uc-section"><h2>3. Action (행동)</h2>' +
(sec.actionTaken || "<p><br></p>") +
"</div>" +
'<div id="uc-result" class="uc-section"><h2>4. Result (결과)</h2>' +
(sec.resultOutcome || "<p><br></p>") +
"</div>" +
"</div>"
);
}
function buildInitialHtml() {
return buildHtmlFromSections({
situation: paraFromPh(ph.situation),
taskGoal: paraFromPh(ph.task),
actionTaken: paraFromPh(ph.action),
resultOutcome: paraFromPh(ph.result),
});
}
function normalizePlain(s) {
return String(s || "")
.replace(/\s+/g, " ")
.trim();
}
function isPlaceholderBody(html, phKey) {
var phPlain = normalizePlain(ph[phKey]);
if (!phPlain) return false;
return normalizePlain(stripForCount(html)) === phPlain;
}
function sectionHasExampleMarker(html) {
return /uc-example-text|data-uc-example/.test(String(html || ""));
}
function isSectionBodyEmpty(html) {
return stripForCount(html).length === 0;
}
function hasUserBodyInput() {
var ex = extractSections(editor.getHTML());
for (var i = 0; i < fieldKeys.length; i++) {
var body = ex[fieldKeys[i]] || "";
if (sectionHasExampleMarker(body)) return false;
if (!isSectionBodyEmpty(body) && !isPlaceholderBody(body, phKeysByField[fieldKeys[i]])) {
return true;
}
}
return false;
}
function restoreExamplesIfEmptyDraft() {
if (!examplesComposeMode || placeholdersActive) return;
if (hasUserBodyInput()) return;
placeholdersActive = true;
editor.setHTML(buildInitialHtml());
count();
}
/** 본문 영역 첫 클릭 시 STAR 1~4 예시 문단만 제거(제목은 유지) */
function clearExampleContentOnActivate() {
if (!placeholdersActive) return;
placeholdersActive = false;
var empty = "<p><br></p>";
var wrapper = document.createElement("div");
wrapper.innerHTML = editor.getHTML();
var clearedById = false;
for (var i = 0; i < sectionIds.length; i++) {
var sec = wrapper.querySelector("#" + sectionIds[i]);
if (!sec) continue;
var head = sec.querySelector(":scope > h1, :scope > h2, :scope > h3, :scope > h4");
sec.innerHTML = (head ? head.outerHTML : "") + empty;
clearedById = true;
}
setTimeout(function () {
if (clearedById) {
editor.setHTML(wrapper.innerHTML);
} else {
editor.setHTML(
buildHtmlFromSections({
situation: empty,
taskGoal: empty,
actionTaken: empty,
resultOutcome: empty,
})
);
}
count();
}, 0);
}
/** @returns {{ situation: string, taskGoal: string, actionTaken: string, resultOutcome: string, ok: boolean, err?: string }} */
function extractSections(html) {
var out = { situation: "", taskGoal: "", actionTaken: "", resultOutcome: "", ok: true };
var w = document.createElement("div");
w.innerHTML = html;
var got = 0;
for (var i = 0; i < sectionIds.length; i++) {
var sec = w.querySelector("#" + sectionIds[i]);
if (sec) {
var c = sec.cloneNode(true);
var hx = c.querySelector(":scope > h1, :scope > h2, :scope > h3, :scope > h4");
if (hx) hx.remove();
out[fieldKeys[i]] = c.innerHTML.trim();
got++;
}
}
if (got === 4) {
out.ok = true;
return out;
}
var byH = extractByHeadings(w);
if (byH) {
byH.ok = true;
return byH;
}
return { situation: out.situation, taskGoal: out.taskGoal, actionTaken: out.actionTaken, resultOutcome: out.resultOutcome, ok: false, err: "split" };
}
function extractByHeadings(wrap) {
var patterns = [
{ key: "situation", re: /1\.\s*Situation/i },
{ key: "taskGoal", re: /2\.\s*Task/i },
{ key: "actionTaken", re: /3\.\s*Action/i },
{ key: "resultOutcome", re: /4\.\s*Result/i },
];
var heads = wrap.querySelectorAll("h1, h2, h3, h4");
var found = [];
for (var h = 0; h < heads.length; h++) {
var t = (heads[h].textContent || "").replace(/\s+/g, " ").trim();
for (var p = 0; p < patterns.length; p++) {
if (patterns[p].re.test(t)) {
found.push({ key: patterns[p].key, el: heads[h] });
break;
}
}
}
if (found.length < 4) {
return null;
}
var seen = {};
for (var f = 0; f < found.length; f++) {
if (seen[found[f].key]) return null;
seen[found[f].key] = true;
}
if (Object.keys(seen).length !== 4) return null;
found.sort(function (a, b) {
var po = a.el.compareDocumentPosition(b.el);
if (po & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
if (po & Node.DOCUMENT_POSITION_PRECEDING) return 1;
return 0;
});
var res = { situation: "", taskGoal: "", actionTaken: "", resultOutcome: "" };
for (var s = 0; s < found.length; s++) {
var head = found[s].el;
var boundary = s + 1 < found.length ? found[s + 1].el : null;
var n = head.nextSibling;
var parts = [];
while (n) {
if (boundary && n === boundary) break;
if (n.nodeType === 1) {
parts.push(n.outerHTML);
}
n = n.nextSibling;
}
res[found[s].key] = parts.join("").trim();
}
return res;
}
function stripForCount(html) {
if (!html) return "";
return String(html)
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&#[0-9]+;/g, " ")
.replace(/&[a-zA-Z0-9]+;/g, " ")
.replace(/\s+/g, " ")
.trim();
}
var edHost = document.getElementById("edUseCaseBody");
var editor = new toastui.Editor({
el: edHost,
height: "720px",
initialEditType: "wysiwyg",
previewStyle: "vertical",
hideModeSwitch: true,
useCommandShortcut: true,
usageStatistics: false,
language: "ko",
placeholder: "STAR 4구역(1~4번)이 보이지 않으면 페이지를 새로고침하세요.",
toolbarItems: [
["heading", "bold", "italic", "strike"],
["hr", "quote"],
["ul", "ol", "task", "indent", "outdent"],
["table", "link"],
["code", "codeblock"],
],
});
if (editPayload && editPayload.html) {
document.getElementById("uctitle").value = editPayload.title || "";
document.getElementById("uctags").value = editPayload.tags || "";
editor.setHTML(editPayload.html);
if (editPayload.existingThumbnails && editPayload.existingThumbnails.length) {
var h = document.getElementById("ucThumbFieldHint");
if (h) h.textContent = "새로 선택하면 목록 끝에 추가됩니다. (최대 " + maxThumbCount + "개, 개당 5MB, 1:1 권장)";
if (thumbGrid) thumbGrid.hidden = false;
}
} else {
editor.setHTML(buildInitialHtml());
}
var titleInput = document.getElementById("uctitle");
function focusTitleInput() {
if (titleInput) titleInput.focus({ preventScroll: true });
}
if (examplesComposeMode) {
edHost.addEventListener(
"mousedown",
function (ev) {
if (!placeholdersActive || ev.button !== 0) return;
if (!ev.target.closest(".toastui-editor-contents")) return;
clearExampleContentOnActivate();
},
true
);
document.addEventListener(
"mousedown",
function (ev) {
if (ev.button !== 0 || placeholdersActive) return;
if (edHost.contains(ev.target)) return;
restoreExamplesIfEmptyDraft();
},
true
);
}
focusTitleInput();
setTimeout(focusTitleInput, 0);
setTimeout(focusTitleInput, 100);
function count() {
var ex = extractSections(editor.getHTML());
var n = 0;
for (var i = 0; i < fieldKeys.length; i++) {
n += stripForCount(ex[fieldKeys[i]] || "").length;
}
if (charLine) {
charLine.textContent = n + " / " + maxTotal + "자";
}
return n;
}
editor.on("change", function () {
count();
});
count();
function countKeptExistingThumbs() {
if (!thumbGrid) return 0;
return thumbGrid.querySelectorAll(".use-case-thumb-tile[data-existing-rp]:not(.is-removed)").length;
}
function countPendingThumbs() {
return pendingThumbFiles.length;
}
function countRemainingThumbsOnSubmit() {
return countKeptExistingThumbs() + countPendingThumbs();
}
function revokePendingThumbUrls() {
for (var ui = 0; ui < pendingThumbUrls.length; ui++) {
try {
URL.revokeObjectURL(pendingThumbUrls[ui]);
} catch (e) {}
}
pendingThumbUrls = [];
}
function updateThumbGridVisibility() {
if (!thumbGrid) return;
var visible =
thumbGrid.querySelectorAll(".use-case-thumb-tile:not(.is-removed)").length > 0 ||
countPendingThumbs() > 0;
thumbGrid.hidden = !visible;
}
function renderPendingThumbTiles() {
if (!thumbGrid) return;
var oldPending = thumbGrid.querySelectorAll(".use-case-thumb-tile[data-pending-index]");
for (var oi = 0; oi < oldPending.length; oi++) {
oldPending[oi].remove();
}
revokePendingThumbUrls();
for (var pi = 0; pi < pendingThumbFiles.length; pi++) {
var file = pendingThumbFiles[pi];
var url = URL.createObjectURL(file);
pendingThumbUrls.push(url);
var fig = document.createElement("figure");
fig.className = "use-case-thumb-tile";
fig.setAttribute("data-pending-index", String(pi));
var img = document.createElement("img");
img.src = url;
img.alt = file.name || "썸네일";
img.loading = "lazy";
img.decoding = "async";
var btn = document.createElement("button");
btn.type = "button";
btn.className = "use-case-thumb-remove";
btn.setAttribute("aria-label", (file.name || "썸네일") + " 제거");
btn.title = "제거";
btn.textContent = "×";
fig.appendChild(img);
fig.appendChild(btn);
thumbGrid.appendChild(fig);
}
updateThumbGridVisibility();
}
function notifyThumbLimitPopup(addedCount, selectedCount) {
var base = "썸네일은 최대 " + maxThumbCount + "개까지 업로드할 수 있습니다.";
if (typeof addedCount === "number" && typeof selectedCount === "number" && selectedCount > addedCount && addedCount > 0) {
window.alert(base + "\n\n선택하신 " + selectedCount + "개 중 " + addedCount + "개만 추가되었습니다.");
return;
}
if (typeof addedCount === "number" && addedCount === 0) {
window.alert(base + "\n\n더 이상 추가할 수 없습니다. 기존 썸네일을 삭제한 뒤 다시 선택해 주세요.");
return;
}
window.alert(base);
}
function addPendingThumbFiles(fileList) {
if (!fileList || !fileList.length) return true;
var keptExisting = countKeptExistingThumbs();
var remaining = maxThumbCount - keptExisting - pendingThumbFiles.length;
if (remaining <= 0) {
notifyThumbLimitPopup(0, fileList.length);
return false;
}
var selectedCount = fileList.length;
var addedCount = 0;
for (var ai = 0; ai < fileList.length; ai++) {
if (addedCount >= remaining) break;
var f = fileList[ai];
if (!/^image\//.test(f.type || "")) {
msg.textContent = "썸네일은 이미지 파일만 업로드할 수 있습니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return false;
}
if (f.size > 5 * 1024 * 1024) {
msg.textContent = '"' + (f.name || "파일") + '"은 5MB 이하여야 합니다.';
msg.style.color = "#b91c1c";
msg.hidden = false;
return false;
}
pendingThumbFiles.push(f);
addedCount++;
}
if (selectedCount > remaining) {
notifyThumbLimitPopup(addedCount, selectedCount);
}
msg.hidden = true;
renderPendingThumbTiles();
return true;
}
if (thumbGrid) {
thumbGrid.addEventListener("click", function (ev) {
var btn = ev.target.closest(".use-case-thumb-remove");
if (!btn) return;
ev.preventDefault();
var tile = btn.closest(".use-case-thumb-tile");
if (!tile) return;
if (tile.hasAttribute("data-existing-rp")) {
tile.classList.add("is-removed");
updateThumbGridVisibility();
return;
}
if (tile.hasAttribute("data-pending-index")) {
var idx = parseInt(tile.getAttribute("data-pending-index"), 10);
if (!Number.isNaN(idx) && idx >= 0 && idx < pendingThumbFiles.length) {
pendingThumbFiles.splice(idx, 1);
renderPendingThumbTiles();
}
}
});
}
if (thumb) {
thumb.addEventListener("change", function () {
if (!thumb.files || !thumb.files.length) return;
addPendingThumbFiles(thumb.files);
thumb.value = "";
});
}
if (filesIn && fileList) {
filesIn.addEventListener("change", function () {
fileList.innerHTML = "";
if (!filesIn.files || !filesIn.files.length) {
fileList.hidden = true;
return;
}
if (filesIn.files.length > maxAttachCount) {
msg.textContent = "첨부 파일은 최대 " + maxAttachCount + "개만 선택할 수 있습니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
filesIn.value = "";
fileList.hidden = true;
return;
}
var keptAttach = 0;
var attachCbs = document.querySelectorAll(".js-uc-remove-attach");
for (var ai = 0; ai < attachCbs.length; ai++) {
if (!attachCbs[ai].checked) keptAttach++;
}
if (keptAttach + filesIn.files.length > maxAttachCount) {
msg.textContent =
"첨부 파일은 최대 " +
maxAttachCount +
"개입니다. 기존 첨부를 제거한 뒤 새 파일을 선택해 주세요.";
msg.style.color = "#b91c1c";
msg.hidden = false;
filesIn.value = "";
fileList.hidden = true;
return;
}
msg.hidden = true;
fileList.hidden = false;
for (var k = 0; k < filesIn.files.length; k++) {
var li = document.createElement("li");
li.textContent = filesIn.files[k].name;
fileList.appendChild(li);
}
});
}
if (form) {
form.addEventListener("submit", function (ev) {
ev.preventDefault();
var ex = extractSections(editor.getHTML());
if (ex && ex.ok === false) {
msg.textContent =
"1~4번 구역(#uc-situation 등)을 찾을 수 없습니다. 구역을 삭제하지 말고, 제목(1. Situation …)이 보이는지 확인해 주세요.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
if (count() > maxTotal) {
msg.textContent = "본문(4개 섹션 합계)은 " + maxTotal + "자 이하여야 합니다. (보이는 텍스트 기준)";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
for (var v = 0; v < fieldKeys.length; v++) {
var fk = fieldKeys[v];
var body = ex[fk];
if (
isSectionBodyEmpty(body) ||
isPlaceholderBody(body, phKeysByField[fk]) ||
sectionHasExampleMarker(body)
) {
msg.textContent =
(v + 1) +
"번 구역에 내용이 없습니다. 예시는 본문 영역 클릭 시 자동으로 지워집니다. 본인 내용을 입력해 주세요. (빈 줄·서식만이면 제출할 수 없습니다.)";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
}
if (!editPayload) {
if (countPendingThumbs() < 1) {
msg.textContent = "썸네일 이미지를 1개 이상 선택해 주세요.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
} else if (countRemainingThumbsOnSubmit() < 1) {
msg.textContent = "썸네일은 1개 이상 남겨야 합니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
if (countRemainingThumbsOnSubmit() > maxThumbCount) {
notifyThumbLimitPopup();
msg.textContent = "썸네일은 최대 " + maxThumbCount + "개까지 등록할 수 있습니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
for (var tv = 0; tv < pendingThumbFiles.length; tv++) {
if (pendingThumbFiles[tv].size > 5 * 1024 * 1024) {
msg.textContent = "썸네일 " + (tv + 1) + "번째 파일은 5MB 이하여야 합니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
}
var isEdit = editPayload && editPayload.id;
var fd = new FormData();
fd.append("title", document.getElementById("uctitle").value.trim());
fd.append("situation", ex.situation);
fd.append("taskGoal", ex.taskGoal);
fd.append("actionTaken", ex.actionTaken);
fd.append("resultOutcome", ex.resultOutcome);
fd.append("tags", document.getElementById("uctags").value);
for (var t = 0; t < pendingThumbFiles.length; t++) {
fd.append("thumbnail", pendingThumbFiles[t]);
}
var keptAttachSubmit = 0;
var attachCbsSubmit = document.querySelectorAll(".js-uc-remove-attach");
for (var asi = 0; asi < attachCbsSubmit.length; asi++) {
if (!attachCbsSubmit[asi].checked) keptAttachSubmit++;
}
var newAttachCount = filesIn && filesIn.files ? filesIn.files.length : 0;
if (keptAttachSubmit + newAttachCount > maxAttachCount) {
msg.textContent =
"첨부 파일은 최대 " +
maxAttachCount +
"개입니다. 기존 첨부를 제거한 뒤 새 파일을 선택해 주세요.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
if (filesIn && filesIn.files) {
for (var f = 0; f < filesIn.files.length; f++) {
if (filesIn.files[f].size > 20 * 1024 * 1024) {
msg.textContent = "첨부 파일은 20MB 이하여야 합니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
return;
}
fd.append("attachments", filesIn.files[f]);
}
}
if (isEdit) {
var rmThumb = [];
if (thumbGrid) {
var removedTiles = thumbGrid.querySelectorAll(".use-case-thumb-tile[data-existing-rp].is-removed");
for (var ti = 0; ti < removedTiles.length; ti++) {
try {
var trp = decodeURIComponent(removedTiles[ti].getAttribute("data-existing-rp") || "");
if (trp) rmThumb.push(trp);
} catch (e) {}
}
}
if (rmThumb.length) {
fd.append("removeThumbnailPaths", JSON.stringify(rmThumb));
}
var rm = [];
var cbs = document.querySelectorAll(".js-uc-remove-attach");
for (var ci = 0; ci < cbs.length; ci++) {
if (cbs[ci].checked) {
try {
var rp = decodeURIComponent(cbs[ci].getAttribute("data-rp") || "");
if (rp) rm.push(rp);
} catch (e) {}
}
}
if (rm.length) {
fd.append("removeAttachmentPaths", JSON.stringify(rm));
}
}
msg.hidden = true;
submitBtn.disabled = true;
var url = isEdit ? "/api/ai-use-case-submissions/" + encodeURIComponent(editPayload.id) : "/api/ai-use-case-submissions";
var method = isEdit ? "PUT" : "POST";
fetch(url, { method: method, body: fd, credentials: "same-origin" })
.then(function (r) {
return r.json().then(function (j) {
return { ok: r.ok, j: j, status: r.status };
});
})
.then(function (o) {
if (o.ok && o.j && o.j.ok) {
if (isEdit && o.j && o.j.id) {
window.location.href = "/ai-cases/submit/" + encodeURIComponent(o.j.id) + "?updated=1";
} else {
window.location.href = "/ai-cases?submitted=1";
}
return;
}
msg.textContent = (o.j && o.j.error) || "저장에 실패했습니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
})
.catch(function () {
msg.textContent = "요청에 실패했습니다.";
msg.style.color = "#b91c1c";
msg.hidden = false;
})
.finally(function () {
submitBtn.disabled = false;
});
});
}
})();
</script>
</body>
</html>