xavis 소스·DB 스키마·활용사례/F-Scan/프롬프트 라이브러리 등 기능 반영. @xavis.co.kr → @ncue.net, 관리자 토큰 ncue-admin, 런타임 data/ Git 추적 제외. Co-authored-by: Cursor <cursoragent@cursor.com>
772 lines
34 KiB
Plaintext
772 lines
34 KiB
Plaintext
<!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 활용 사례 > 작성하기 - 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 활용 사례 > <%= 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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
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(/ /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>
|