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:
771
views/ai-cases-compose.ejs
Normal file
771
views/ai-cases-compose.ejs
Normal 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 활용 사례 > 작성하기 - 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>
|
||||
Reference in New Issue
Block a user