Files
ai_platform/views/ai-prompts.ejs
dsyoon 073a8343dd 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>
2026-05-26 22:27:48 +09:00

956 lines
47 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>프롬프트 라이브러리 - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: typeof activeMenu !== 'undefined' ? activeMenu : 'prompts', adminMode: typeof adminMode !== 'undefined' ? adminMode : false, opsUserEmail: typeof opsUserEmail !== 'undefined' ? opsUserEmail : false }) %>
<div class="content-area">
<header class="topbar">
<h1>프롬프트</h1>
<a class="top-action-link" href="/ai-explore">AI 목록</a>
</header>
<main class="container container-ai-full prompt-lib">
<a href="/ai-explore" class="prompts-back" aria-label="AI 탐색으로 돌아가기">← AI</a>
<section class="prompts-hero">
<h1>프롬프트 라이브러리</h1>
<p class="prompts-lead">
공식 템플릿을 복사하거나, 팀이 공유한 프롬프트를 고르고 좋아요로 인기를 표시하세요. 워크플로 도움으로 본인 업무에 맞는 지시문 초안을 만들 수 있습니다.
</p>
<% if (typeof openaiPolishAvailable !== "undefined" && !openaiPolishAvailable) { %>
<p class="prompts-stats prompts-stats--info">「워크플로」의 일부 기능은 서버에 OpenAI 키가 있을 때 사용할 수 있습니다.</p>
<% } else { %>
<p class="prompts-stats">공식 <strong><%= (officialPrompts && officialPrompts.length) || 0 %></strong>종 · 팀 공유
<% if (libData && libData.hasDb) { %><strong><%= (libData.community && libData.community.length) || 0 %></strong>건<% } else { %><span>DB 연결 시 활성</span><% } %>
</p>
<% } %>
</section>
<% if (!libData && (!officialPrompts || !officialPrompts.length)) { %>
<section class="panel">
<p class="empty">프롬프트 데이터를 불러오지 못했습니다.</p>
</section>
<% } else { %>
<div class="prompt-lib-tabs" role="tablist" aria-label="프롬프트 메뉴">
<button type="button" class="prompt-lib-tab is-active" data-tab="library" role="tab" aria-selected="true">라이브러리</button>
<button type="button" class="prompt-lib-tab" data-tab="workflow" role="tab" aria-selected="false">워크플로 → 프롬프트</button>
<button type="button" class="prompt-lib-tab" data-tab="submit" role="tab" aria-selected="false">공유하기</button>
<button type="button" class="prompt-lib-tab" data-tab="mine" role="tab" aria-selected="false">내가 올린 것</button>
</div>
<p class="prompt-lib-login-hint" id="promptLibLoginHint" hidden>
좋아요·공유·삭제는 <strong>회사 이메일(OPS) 로그인</strong> 후 사용할 수 있습니다.
</p>
<section class="panel prompt-lib-panel" data-panel="library">
<div class="prompt-lib-toolbar">
<div class="prompt-lib-filters">
<label class="visually-hidden" for="promptFilterSource">출처</label>
<select id="promptFilterSource" class="prompt-lib-select">
<option value="all">전체</option>
<option value="official">공식 템플릿만</option>
<option value="community" <%= libData && libData.hasDb ? "" : "disabled" %>>팀 공유만</option>
</select>
<label class="visually-hidden" for="promptSort">정렬</label>
<select id="promptSort" class="prompt-lib-select">
<option value="popular">인기(좋아요)</option>
<option value="recent">최신</option>
</select>
</div>
<div class="prompt-lib-search">
<label class="visually-hidden" for="promptSearchQ">검색</label>
<input type="search" id="promptSearchQ" class="prompt-lib-search-input" placeholder="제목·설명·태그 검색" autocomplete="off" />
</div>
</div>
<% if (!libData || !libData.hasDb) { %>
<p class="prompt-lib-db-hint">PostgreSQL이 연결되지 않은 환경입니다. 공식 템플릿과 복사만 사용할 수 있으며, 팀 공유·좋아요는 DB 연결 후 사용할 수 있습니다.</p>
<% } %>
<div class="prompts-layout prompt-lib-main">
<div class="prompt-lib-list-wrap">
<h2 class="prompts-grid-title">시나리오 · 공유</h2>
<div class="prompts-grid prompt-lib-grid" id="promptCardList" role="list"></div>
<p class="empty" id="promptListEmpty" hidden>조건에 맞는 항목이 없습니다.</p>
</div>
<div class="prompts-preview-panel prompt-lib-preview">
<h2 id="previewTitle">프롬프트 미리보기</h2>
<p class="prompts-preview-empty" id="previewEmpty">왼쪽에서 항목을 선택하세요.</p>
<div id="previewActive" hidden>
<div class="prompts-preview-toolbar prompt-lib-preview-toolbar">
<button type="button" class="prompt-lib-like" id="promptLikeBtn" title="로그인 후 사용">
<span class="prompt-lib-like-icon" aria-hidden="true">♥</span>
<span id="promptLikeCount">0</span>
</button>
<button type="button" class="prompts-copy-btn" id="copyPromptBtn" disabled>클립보드에 복사</button>
</div>
<p class="prompt-lib-meta" id="previewMeta"></p>
<div class="prompt-lib-preview-files" id="previewFileWrap" hidden></div>
<label class="visually-hidden" for="promptBody">선택한 프롬프트 전문</label>
<textarea id="promptBody" class="prompts-body-textarea" readonly spellcheck="false" aria-describedby="previewHint"></textarea>
<p class="prompts-hint" id="previewHint">대괄호 [ ] 안은 상황에 맞게 바꾼 뒤 사용하세요.</p>
</div>
</div>
</div>
</section>
<section class="panel prompt-lib-panel prompt-lib-panel--submit" data-panel="submit" hidden>
<div class="prompt-lib-submit-hero">
<div class="prompt-lib-submit-hero-icon" aria-hidden="true">✦</div>
<h2>팀에 프롬프트 공개하기</h2>
<p>동료들이 참고할 수 있도록 지시문과(선택) <strong>참고·결과 파일</strong>을 함께 올릴 수 있습니다.</p>
</div>
<p class="prompt-lib-policy">
작성자는 로그인 이메일로 저장됩니다. <strong>민감·개인정보·기밀</strong>이 포함되지 않게 해 주세요. 첨부는 항목당 최대 5개, 파일당 20MB입니다.
</p>
<form id="promptSubmitForm" class="prompt-lib-form" novalidate enctype="multipart/form-data">
<div class="prompt-lib-fieldset">
<h3 class="prompt-lib-h3">기본 정보</h3>
<div class="prompt-lib-input-row">
<label for="pcTitle" class="prompt-lib-label">제목 <span class="req">*</span></label>
<input type="text" id="pcTitle" name="title" class="prompt-lib-input" maxlength="500" required placeholder="예: 주간 CS 리포트 요약" />
</div>
<div class="prompt-lib-input-row prompt-lib-input-row--2col">
<div>
<label for="pcTag" class="prompt-lib-label">태그</label>
<input type="text" id="pcTag" name="tag" class="prompt-lib-input" maxlength="100" placeholder="예: CS, 요약" />
</div>
<div>
<label for="pcDesc" class="prompt-lib-label">한줄 설명</label>
<input type="text" id="pcDesc" name="description" class="prompt-lib-input" maxlength="2000" placeholder="이 프롬프트로 어떤 일을 하는지" />
</div>
</div>
</div>
<div class="prompt-lib-fieldset">
<h3 class="prompt-lib-h3">프롬프트 본문 <span class="req">*</span></h3>
<p class="prompt-lib-hintline">채팅에 그대로 붙여 넣을 수 있는 <strong>지시문 전체</strong>를 적어 주세요.</p>
<div class="prompt-lib-body-editor-wrap">
<div class="prompt-lib-body-toolbar" role="tablist" aria-label="프롬프트 본문 표시">
<button
type="button"
class="prompt-lib-body-mode-btn is-active"
id="pcBodyModeRaw"
role="tab"
aria-selected="true"
aria-controls="pcBody"
>
원문 보기
</button>
<button
type="button"
class="prompt-lib-body-mode-btn"
id="pcBodyModeMd"
role="tab"
aria-selected="false"
aria-controls="pcBodyMdPreview"
>
마크다운 보기
</button>
</div>
<textarea
id="pcBody"
name="body"
class="prompt-lib-textarea prompt-lib-textarea--body"
rows="14"
minlength="10"
required
placeholder="ChatGPT, Claude, 자사 채팅 등에 사용할 지시문을 작성합니다."
aria-label="프롬프트 본문"
></textarea>
<div
id="pcBodyMdPreview"
class="prompt-lib-md-preview"
hidden
role="region"
aria-label="마크다운 미리보기"
tabindex="0"
>
<div class="prompt-lib-md-body" id="pcBodyMdInner"></div>
</div>
</div>
</div>
<div class="prompt-lib-fieldset">
<h3 class="prompt-lib-h3">첨부 (선택)</h3>
<div class="prompt-lib-upload-grid">
<div class="prompt-lib-drop prompt-lib-drop--prompt">
<span class="prompt-lib-drop-eyebrow">PR</span>
<strong>프롬프트와 함께 쓰는 자료</strong>
<p>예: 참고 PDF, 사내 양식, 키워드 목록</p>
<input type="file" id="pcPromptFiles" name="promptFiles" class="prompt-lib-file-native" multiple />
<label for="pcPromptFiles" class="prompt-lib-file-btn">파일 선택</label>
<p class="prompt-lib-file-list" id="pcPromptFileNames" aria-live="polite"></p>
</div>
<div class="prompt-lib-drop prompt-lib-drop--result">
<span class="prompt-lib-drop-eyebrow">OUT</span>
<strong>결과(샘플) 파일</strong>
<p>예: AI로 만든 산출물 예시, 기대하는 출력 형식</p>
<input type="file" id="pcResultFiles" name="resultFiles" class="prompt-lib-file-native" multiple />
<label for="pcResultFiles" class="prompt-lib-file-btn">파일 선택</label>
<p class="prompt-lib-file-list" id="pcResultFileNames" aria-live="polite"></p>
</div>
</div>
</div>
<div class="prompt-lib-submit-bar">
<button type="submit" class="prompt-lib-submit-btn" id="pcSubmit">공개하기</button>
</div>
<p class="form-message" id="pcMsg" hidden></p>
</form>
</section>
<section class="panel prompt-lib-panel prompt-lib-panel--workflow" data-panel="workflow" hidden>
<div class="prompt-lib-wf-hero">
<div class="prompt-lib-wf-hero-icon" aria-hidden="true">◇</div>
<h2>워크플로 → 프롬프트</h2>
<p>업무를 한눈에 정리한 뒤, <strong>한 덩어리 초안</strong>을 만듭니다. 필요하면 <strong>AI로 다듬기</strong>로 정리한 지시문을 얻을 수 있습니다.</p>
</div>
<aside class="prompt-lib-wf-api-note" role="note">
<strong>동작 방식</strong>
<ul>
<li>
<span class="prompt-lib-wf-k">초안 합치기</span> — ①~④에 적은 내용을 이 브라우저에서만 이어 붙입니다. <em>인터넷 밖으로 보내지 않습니다</em> (OpenAI/ChatGPT API 호출 없음).<% if (typeof openaiPolishAvailable !== "undefined" && !openaiPolishAvailable) { %>
<br /><span class="prompt-lib-wf-warn">「AI로 다듬기」는 현재 환경에서 API 키가 없어 사용할 수 없습니다.</span><% } %>
</li>
</ul>
</aside>
<div class="prompt-lib-wf-body">
<div class="prompt-lib-wf-fieldset">
<h3 class="prompt-lib-wf-h3">①~④단계 입력</h3>
<div class="prompt-lib-wf-row">
<label for="wfGoal" class="prompt-lib-wf-label"
>맡기려는 일 <span class="prompt-lib-wf-badge">1</span> <span class="req">· 한 문장</span></label
>
<input type="text" id="wfGoal" class="prompt-lib-wf-input" placeholder="예: 거래처 견적 이메일 초안" autocomplete="off" />
</div>
<div class="prompt-lib-wf-row">
<label for="wfIn" class="prompt-lib-wf-label"
>AI에게 줄 자료 <span class="prompt-lib-wf-badge">2</span></label
>
<textarea id="wfIn" class="prompt-lib-wf-textarea" rows="4" placeholder="붙일 메모, 표, 키워드…" spellcheck="false"></textarea>
</div>
<div class="prompt-lib-wf-row">
<label for="wfOut" class="prompt-lib-wf-label"
>원하는 출력·톤 <span class="prompt-lib-wf-badge">3</span></label
>
<textarea id="wfOut" class="prompt-lib-wf-textarea" rows="3" placeholder="불릿, 표, 격식체, 분량…" spellcheck="false"></textarea>
</div>
<div class="prompt-lib-wf-row">
<label for="wfRule" class="prompt-lib-wf-label"
>제약·주의 <span class="prompt-lib-wf-badge">4</span> <span class="opt">선택</span></label
>
<input type="text" id="wfRule" class="prompt-lib-wf-input" placeholder="개인정보 제외, 한국어, …" autocomplete="off" />
</div>
</div>
<div class="prompt-lib-wf-fieldset prompt-lib-wf-fieldset--actions">
<h3 class="prompt-lib-wf-h3">가공하기</h3>
<p class="prompt-lib-wf-lead">먼저 <strong>초안 합치기</strong>로 아래에 텍스트를 만든 뒤, 필요할 때만 <strong>AI로 다듬기</strong>를 누르세요.</p>
<div class="prompt-lib-wf-actions">
<div class="prompt-lib-wf-action">
<button
type="button"
class="prompt-lib-wf-btn prompt-lib-wf-btn--merge"
id="wfMerge"
title="1~4번에 적은 내용을 브라우저에서만 이어 붙여, 아래 결과 칸에 한 덩어리로 넣습니다. 서버·OpenAI로 전송하지 않습니다."
aria-describedby="wfHelpMerge"
>
초안 합치기
</button>
<p id="wfHelpMerge" class="prompt-lib-wf-action-desc">
로컬에서만 병합 · API 미사용
<span class="prompt-lib-wf-tip" title="PC에서만: 마우스를 버튼 위에 잠시 올리면 전체 설명이 툴팁으로 보입니다." tabindex="0" role="img" aria-label="툴팁 안내">ⓘ</span>
</p>
</div>
<div class="prompt-lib-wf-action">
<button
type="button"
class="prompt-lib-wf-btn prompt-lib-wf-btn--ai"
id="wfPolish"
<% if (typeof openaiPolishAvailable === "undefined" || !openaiPolishAvailable) { %>disabled<% } %>
>
AI로 다듬기
</button>
</div>
</div>
</div>
<div class="prompt-lib-wf-fieldset">
<h3 class="prompt-lib-wf-h3">결과(복사해 공유 탭에 붙여 넣을 수 있음)</h3>
<textarea
id="wfResult"
class="prompt-lib-wf-textarea prompt-lib-wf-textarea--result"
rows="12"
placeholder="「초안 합치기」 또는 「AI로 다듬기」 후 여기에 표시됩니다."
spellcheck="false"
></textarea>
</div>
</div>
<p class="form-message" id="wfMsg" hidden></p>
</section>
<section class="panel prompt-lib-panel" data-panel="mine" hidden>
<h2>내가 올린 프롬프트</h2>
<p class="field-hint" style="margin: 0 0 8px" id="mineEmptyHint">로그인 시 목록이 표시됩니다.</p>
<ul class="prompt-lib-mine-list" id="mineList"></ul>
</section>
<% } %>
</main>
</div>
</div>
<script type="application/json" id="prompt-lib-boot">
<%- JSON.stringify({
official: officialPrompts || [],
lib: libData || { hasDb: false, community: [], likeCountOfficial: {}, likeCountCommunity: {}, myOfficialLikes: [], myCommunityLikes: [], mySubmissions: [] },
loggedIn: !!userEmail,
userEmail: userEmail || "",
polish: !!openaiPolishAvailable
}).replace(/</g, "\\u003c") %>
</script>
<script src="/vendor/marked/marked.umd.js"></script>
<script src="/vendor/dompurify/purify.min.js"></script>
<script>
(function () {
var boot = document.getElementById("prompt-lib-boot");
var C = { official: [], lib: { hasDb: false, community: [] }, loggedIn: false, userEmail: "", polish: false };
try {
C = JSON.parse(boot ? boot.textContent : "{}");
} catch (e) {}
var cardList = document.getElementById("promptCardList");
if (!cardList) return;
var filterEl = document.getElementById("promptFilterSource");
var sortEl = document.getElementById("promptSort");
var searchEl = document.getElementById("promptSearchQ");
var listEmpty = document.getElementById("promptListEmpty");
var titleEl = document.getElementById("previewTitle");
var bodyEl = document.getElementById("promptBody");
var copyBtn = document.getElementById("copyPromptBtn");
var emptyEl = document.getElementById("previewEmpty");
var activeWrap = document.getElementById("previewActive");
var previewMeta = document.getElementById("previewMeta");
var likeBtn = document.getElementById("promptLikeBtn");
var likeCountEl = document.getElementById("promptLikeCount");
var loginHint = document.getElementById("promptLibLoginHint");
var previewFileWrap = document.getElementById("previewFileWrap");
var byKey = {};
var currentKey = null;
var lo = (C.lib && C.lib.likeCountOfficial) || {};
var lc = (C.lib && C.lib.likeCountCommunity) || {};
var myO = new Set((C.lib && C.lib.myOfficialLikes) || []);
var myC = new Set((C.lib && C.lib.myCommunityLikes) || []);
function keyOf(src, id) {
return src + ":" + id;
}
function buildMerged() {
var list = [];
(C.official || []).forEach(function (p, i) {
if (!p || !p.id) return;
list.push({
source: "official",
id: p.id,
title: p.title,
description: p.description,
body: p.body,
tag: p.tag,
likeCount: lo[p.id] != null ? lo[p.id] : 0,
myLike: myO.has(p.id),
order: i,
createdT: 0
});
});
(C.lib.community || []).forEach(function (c) {
list.push({
source: "community",
id: c.id,
title: c.title,
description: c.description,
body: c.body,
tag: c.tag,
likeCount: c.likeCount != null ? c.likeCount : (lc[c.id] || 0),
myLike: myC.has(c.id),
authorLabel: c.authorLabel,
promptFiles: c.promptFiles || [],
resultFiles: c.resultFiles || [],
order: 10000,
createdT: c.createdAt ? new Date(c.createdAt).getTime() : 0
});
});
return list;
}
function filterAndSort() {
var all = buildMerged();
var src = filterEl && filterEl.value;
var q = (searchEl && searchEl.value ? searchEl.value : "").trim().toLowerCase();
var sort = sortEl && sortEl.value;
var out = all.filter(function (p) {
if (src === "official" && p.source !== "official") return false;
if (src === "community" && p.source !== "community") return false;
if (!q) return true;
var t =
(p.title + " " + (p.description || "") + " " + (p.tag || "") + " " + (p.source === "community" ? p.authorLabel || "" : "")).toLowerCase();
return t.indexOf(q) !== -1;
});
if (sort === "popular") {
out.sort(function (a, b) {
if (b.likeCount !== a.likeCount) return b.likeCount - a.likeCount;
if (a.source !== b.source) return a.source === "official" ? -1 : 1;
return String(a.title).localeCompare(String(b.title), "ko");
});
} else {
out.sort(function (a, b) {
if (a.source !== b.source) return a.source === "community" ? -1 : 1;
if (a.source === "community" && b.source === "community") return b.createdT - a.createdT;
return (a.order || 0) - (b.order || 0);
});
}
return out;
}
function renderList() {
var rows = filterAndSort();
cardList.innerHTML = "";
if (!rows.length) {
if (listEmpty) listEmpty.hidden = false;
return;
}
if (listEmpty) listEmpty.hidden = true;
rows.forEach(function (p) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "prompt-template-card";
btn.setAttribute("data-key", keyOf(p.source, p.id));
btn.setAttribute("role", "listitem");
var srcLabel = p.source === "official" ? "공식" : "팀";
var likes = p.likeCount != null ? p.likeCount : 0;
var authorHtml =
p.source === "community" && p.authorLabel
? "<span class=\"prompt-card-author\" title=\"작성\">" + escapeHtml(p.authorLabel) + "</span>"
: "";
btn.innerHTML =
"<h3>" + escapeHtml(p.title) + "</h3><p>" + escapeHtml(p.description || "") + "</p>" +
"<div class=\"prompt-card-footer\"><span class=\"prompt-mini-tag\">" + escapeHtml(p.tag || "") + "</span>" +
authorHtml +
"<span class=\"prompt-card-src\">" + srcLabel + "</span>" +
"<span class=\"prompt-card-likes\" aria-label=\"좋아요\">♥ " + likes + "</span></div>";
btn.addEventListener("click", function () {
selectKey(btn.getAttribute("data-key"));
});
cardList.appendChild(btn);
});
}
function escapeHtml(s) {
if (s == null) return "";
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatFileSize(n) {
if (n == null || n <= 0) return "";
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
return (n / 1024 / 1024).toFixed(1) + " MB";
}
function renderPreviewFiles(p) {
if (!previewFileWrap) return;
previewFileWrap.innerHTML = "";
if (p.source !== "community" || (!(p.promptFiles && p.promptFiles.length) && !(p.resultFiles && p.resultFiles.length))) {
previewFileWrap.hidden = true;
return;
}
previewFileWrap.hidden = false;
function addBlock(title, files) {
if (!files || !files.length) return;
var d = document.createElement("div");
d.className = "prompt-lib-preview-file-block";
var h = document.createElement("h4");
h.textContent = title;
d.appendChild(h);
var ul = document.createElement("ul");
ul.className = "prompt-lib-file-link-list";
files.forEach(function (f) {
if (!f || !f.relativePath) return;
var li = document.createElement("li");
var a = document.createElement("a");
a.href = f.relativePath;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = f.originalName || "파일";
var z = f.size != null ? formatFileSize(f.size) : "";
if (z) a.setAttribute("title", z);
li.appendChild(a);
if (z) {
var sp = document.createElement("span");
sp.className = "prompt-lib-file-size";
sp.textContent = z;
li.appendChild(sp);
}
ul.appendChild(li);
});
d.appendChild(ul);
previewFileWrap.appendChild(d);
}
addBlock("프롬프트 관련 자료", p.promptFiles);
addBlock("결과(샘플) 파일", p.resultFiles);
}
function selectKey(k) {
currentKey = k;
var p = byKey[k];
if (!p) {
if (emptyEl) emptyEl.hidden = false;
if (activeWrap) activeWrap.hidden = true;
if (copyBtn) copyBtn.disabled = true;
if (titleEl) titleEl.textContent = "프롬프트 미리보기";
if (likeBtn) likeBtn.classList.remove("is-liked");
if (previewFileWrap) {
previewFileWrap.hidden = true;
previewFileWrap.innerHTML = "";
}
return;
}
document.querySelectorAll(".prompt-template-card").forEach(function (el) {
el.classList.toggle("is-selected", el.getAttribute("data-key") === k);
el.setAttribute("aria-pressed", el.getAttribute("data-key") === k ? "true" : "false");
});
if (emptyEl) emptyEl.hidden = true;
if (activeWrap) activeWrap.hidden = false;
if (titleEl) titleEl.textContent = p.title;
if (bodyEl) bodyEl.value = p.body;
if (copyBtn) copyBtn.disabled = false;
if (likeCountEl) likeCountEl.textContent = String(p.likeCount != null ? p.likeCount : 0);
if (likeBtn) {
likeBtn.classList.toggle("is-liked", !!p.myLike);
likeBtn.disabled = !C.loggedIn || !(C.lib && C.lib.hasDb);
}
if (previewMeta) {
var srcLabel =
p.source === "official" ? "공식 템플릿" : "팀 공유 · " + (p.authorLabel || "팀원");
previewMeta.textContent = srcLabel;
}
renderPreviewFiles(p);
}
function rebuildIndex() {
byKey = {};
buildMerged().forEach(function (p) {
byKey[keyOf(p.source, p.id)] = p;
});
}
rebuildIndex();
renderList();
var firstKey = null;
if (C.official && C.official[0] && C.official[0].id) {
firstKey = keyOf("official", C.official[0].id);
} else if (C.lib && C.lib.community && C.lib.community[0] && C.lib.community[0].id) {
firstKey = keyOf("community", C.lib.community[0].id);
}
if (firstKey && byKey[firstKey]) {
selectKey(firstKey);
} else {
if (emptyEl) emptyEl.hidden = false;
if (activeWrap) activeWrap.hidden = true;
}
var urlParams = new URLSearchParams(window.location.search);
var deepId = urlParams.get("id");
if (deepId) {
var ko = keyOf("official", deepId);
var kc = keyOf("community", deepId);
if (byKey[ko]) selectKey(ko);
else if (byKey[kc]) selectKey(kc);
}
if (filterEl) filterEl.addEventListener("change", renderList);
if (sortEl) sortEl.addEventListener("change", renderList);
if (searchEl) searchEl.addEventListener("input", function () { renderList(); if (currentKey && byKey[currentKey]) selectKey(currentKey); });
if (likeBtn) {
likeBtn.addEventListener("click", function () {
if (!C.loggedIn || !(C.lib && C.lib.hasDb) || !currentKey) return;
var p = byKey[currentKey];
if (!p) return;
fetch("/api/prompts/likes/toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ kind: p.source, id: p.id })
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (o) {
if (o.ok && o.j && o.j.ok) {
p.likeCount = o.j.likeCount;
p.myLike = o.j.liked;
if (p.source === "official") {
lo[p.id] = o.j.likeCount;
if (p.myLike) myO.add(p.id); else myO.delete(p.id);
} else {
lc[p.id] = o.j.likeCount;
if (p.myLike) myC.add(p.id); else myC.delete(p.id);
}
if (likeCountEl) likeCountEl.textContent = String(p.likeCount);
if (likeBtn) likeBtn.classList.toggle("is-liked", p.myLike);
renderList();
selectKey(currentKey);
}
});
});
}
if (copyBtn) {
copyBtn.addEventListener("click", function () {
if (!bodyEl || !bodyEl.value) return;
navigator.clipboard.writeText(bodyEl.value).then(
function () {
var t = copyBtn.textContent;
copyBtn.textContent = "복사됨";
setTimeout(function () { copyBtn.textContent = t; }, 1600);
},
function () { bodyEl.select(); try { document.execCommand("copy"); } catch (e) {} }
);
});
}
if (loginHint) {
loginHint.hidden = C.loggedIn;
}
// Tabs
var tabs = document.querySelectorAll(".prompt-lib-tab");
var panels = document.querySelectorAll(".prompt-lib-panel");
function showTab(name) {
tabs.forEach(function (t) {
var on = t.getAttribute("data-tab") === name;
t.classList.toggle("is-active", on);
t.setAttribute("aria-selected", on ? "true" : "false");
});
panels.forEach(function (p) {
p.hidden = p.getAttribute("data-panel") !== name;
});
}
tabs.forEach(function (t) {
t.addEventListener("click", function () {
showTab(t.getAttribute("data-tab"));
if (t.getAttribute("data-tab") === "mine") renderMine();
});
});
showTab("library");
function renderMine() {
var ul = document.getElementById("mineList");
if (!ul) return;
ul.innerHTML = "";
var hint = document.getElementById("mineEmptyHint");
if (!C.loggedIn) {
if (hint) { hint.textContent = "로그인 후에만 볼 수 있습니다."; }
return;
}
var mine = (C.lib && C.lib.mySubmissions) || [];
if (hint) { hint.textContent = mine.length ? "삭제 시 목록·라이브러리에서 사라집니다." : "아직 올린 프롬프트가 없습니다."; }
mine.forEach(function (m) {
var li = document.createElement("li");
li.className = "prompt-lib-mine-item";
li.innerHTML = "<span class=\"mine-title\">" + escapeHtml(m.title) + "</span>" +
"<span class=\"mine-date\">" + escapeHtml((m.createdAt || "").slice(0, 10)) + "</span>" +
"<button type=\"button\" class=\"btn-ghost mine-del\" data-id=\"" + escapeHtml(m.id) + "\">삭제</button>";
li.querySelector(".mine-del").addEventListener("click", function () {
if (!confirm("이 공유를 삭제할까요?")) return;
fetch("/api/prompts/community/" + encodeURIComponent(m.id), { method: "DELETE", credentials: "same-origin" })
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (o) {
if (o.ok && o.j && o.j.ok) {
C.lib.mySubmissions = (C.lib.mySubmissions || []).filter(function (x) { return x.id !== m.id; });
C.lib.community = (C.lib.community || []).filter(function (x) { return x.id !== m.id; });
rebuildIndex();
renderList();
renderMine();
if (currentKey === keyOf("community", m.id)) {
if (emptyEl) emptyEl.hidden = false;
if (activeWrap) activeWrap.hidden = true;
if (titleEl) titleEl.textContent = "프롬프트 미리보기";
if (previewFileWrap) {
previewFileWrap.hidden = true;
previewFileWrap.innerHTML = "";
}
}
} else {
alert((o.j && o.j.error) || "삭제에 실패했습니다.");
}
});
});
ul.appendChild(li);
});
}
if (C.loggedIn) renderMine();
// Submit
var pcForm = document.getElementById("promptSubmitForm");
if (pcForm) {
pcForm.addEventListener("submit", function (ev) {
ev.preventDefault();
if (!C.loggedIn) {
alert("로그인이 필요합니다.");
return;
}
if (!(C.lib && C.lib.hasDb)) {
alert("DB에 연결된 환경에서만 공유할 수 있습니다.");
return;
}
var msg = document.getElementById("pcMsg");
var body = (document.getElementById("pcBody") && document.getElementById("pcBody").value) || "";
var title = (document.getElementById("pcTitle") && document.getElementById("pcTitle").value) || "";
var descVal = (document.getElementById("pcDesc") && document.getElementById("pcDesc").value) || "";
var tagVal = (document.getElementById("pcTag") && document.getElementById("pcTag").value) || "기타";
if (body.length < 10) {
if (msg) { msg.textContent = "본문은 10자 이상 입력해 주세요."; msg.hidden = false; msg.style.color = "#b91c1c"; }
return;
}
if (msg) msg.hidden = true;
var prIn = document.getElementById("pcPromptFiles");
var reIn = document.getElementById("pcResultFiles");
if (prIn && prIn.files && prIn.files.length > 5) {
if (msg) { msg.textContent = "프롬프트 첨부는 5개 이하로 올려 주세요."; msg.hidden = false; msg.style.color = "#b91c1c"; }
return;
}
if (reIn && reIn.files && reIn.files.length > 5) {
if (msg) { msg.textContent = "결과(샘플) 첨부는 5개 이하로 올려 주세요."; msg.hidden = false; msg.style.color = "#b91c1c"; }
return;
}
var fd = new FormData();
fd.append("title", title);
fd.append("tag", tagVal);
fd.append("description", descVal);
fd.append("body", body);
if (prIn && prIn.files) {
for (var pi = 0; pi < prIn.files.length; pi++) fd.append("promptFiles", prIn.files[pi]);
}
if (reIn && reIn.files) {
for (var ri = 0; ri < reIn.files.length; ri++) fd.append("resultFiles", reIn.files[ri]);
}
var subBtn = document.getElementById("pcSubmit");
if (subBtn) subBtn.disabled = true;
fetch("/api/prompts/community", {
method: "POST",
credentials: "same-origin",
body: fd
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (o) {
if (subBtn) subBtn.disabled = false;
if (o.ok && o.j && o.j.ok) {
if (msg) { msg.textContent = "공개되었습니다. 라이브러리에서 확인하세요."; msg.hidden = false; msg.style.color = "#047857"; }
pcForm.reset();
if (typeof window.promptLibResetPcBodyView === "function") window.promptLibResetPcBodyView();
var pfn = document.getElementById("pcPromptFileNames");
var rfn = document.getElementById("pcResultFileNames");
if (pfn) pfn.textContent = "";
if (rfn) rfn.textContent = "";
C.lib.hasDb = true;
C.lib.community = C.lib.community || [];
C.lib.community.unshift({
id: o.j.id,
title: title.trim(),
description: descVal,
body: body,
tag: tagVal || "기타",
authorLabel: (function (em) {
if (!em) return "팀원";
var a = em.indexOf("@");
if (a < 1) return "팀원";
var loc = em.slice(0, a).trim();
return loc || "팀원";
})(C.userEmail),
likeCount: 0,
createdAt: o.j.createdAt || new Date().toISOString(),
promptFiles: o.j.promptFiles || [],
resultFiles: o.j.resultFiles || []
});
C.lib.mySubmissions = C.lib.mySubmissions || [];
C.lib.mySubmissions.unshift({ id: o.j.id, title: title.trim(), createdAt: o.j.createdAt || "" });
rebuildIndex();
renderList();
showTab("library");
selectKey("community:" + o.j.id);
} else {
if (msg) { msg.textContent = (o.j && o.j.error) || "저장에 실패했습니다."; msg.hidden = false; msg.style.color = "#b91c1c"; }
}
})
.catch(function () {
if (subBtn) subBtn.disabled = false;
if (msg) { msg.textContent = "요청에 실패했습니다."; msg.hidden = false; msg.style.color = "#b91c1c"; }
});
});
}
function wirePromptFileList(inputId, nameId) {
var inp = document.getElementById(inputId);
var out = document.getElementById(nameId);
if (!inp || !out) return;
inp.addEventListener("change", function () {
out.textContent = "";
if (!inp.files || !inp.files.length) return;
var names = [];
for (var i = 0; i < inp.files.length; i++) names.push(inp.files[i].name);
out.textContent = names.join(" · ");
});
}
wirePromptFileList("pcPromptFiles", "pcPromptFileNames");
wirePromptFileList("pcResultFiles", "pcResultFileNames");
// Workflow
var wfMerge = document.getElementById("wfMerge");
var wfPolish = document.getElementById("wfPolish");
var wfRes = document.getElementById("wfResult");
var wfMsg = document.getElementById("wfMsg");
if (wfMerge) {
wfMerge.addEventListener("click", function () {
var g = (document.getElementById("wfGoal") && document.getElementById("wfGoal").value) || "";
var i = (document.getElementById("wfIn") && document.getElementById("wfIn").value) || "";
var o = (document.getElementById("wfOut") && document.getElementById("wfOut").value) || "";
var r = (document.getElementById("wfRule") && document.getElementById("wfRule").value) || "";
var t =
"다음 조건에 따라 결과를 한국어로 생성해 주세요.\n\n" +
"【맡기려는 일】\n" + g + "\n\n" +
"【입력·자료】\n" + i + "\n\n" +
"【원하는 출력·형식·톤】\n" + o + "\n\n" +
(r ? "【제약·주의】\n" + r : "");
if (wfRes) wfRes.value = t;
if (wfMsg) wfMsg.hidden = true;
});
}
if (wfPolish) {
wfPolish.addEventListener("click", function () {
if (!C.loggedIn) {
alert("로그인이 필요합니다.");
return;
}
if (!C.polish) {
if (wfMsg) { wfMsg.textContent = "OpenAI API가 설정되지 않았습니다."; wfMsg.hidden = false; wfMsg.style.color = "#b91c1c"; }
return;
}
var draft = (wfRes && wfRes.value) || "";
if (draft.length < 5) {
if (wfMsg) { wfMsg.textContent = "먼저 아래에 초안을 넣거나 「초안 합치기」를 누르세요."; wfMsg.hidden = false; wfMsg.style.color = "#b91c1c"; }
return;
}
wfPolish.disabled = true;
fetch("/api/prompts/polish-workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ draft: draft })
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (o) {
wfPolish.disabled = false;
if (o.ok && o.j && o.j.text) {
if (wfRes) wfRes.value = o.j.text;
if (wfMsg) { wfMsg.textContent = "다듬기 완료. 필요하면 수동으로 수정하세요."; wfMsg.hidden = false; wfMsg.style.color = "#047857"; }
} else {
if (wfMsg) { wfMsg.textContent = (o.j && o.j.error) || "실패"; wfMsg.hidden = false; wfMsg.style.color = "#b91c1c"; }
}
})
.catch(function () {
wfPolish.disabled = false;
if (wfMsg) { wfMsg.textContent = "요청에 실패했습니다."; wfMsg.hidden = false; wfMsg.style.color = "#b91c1c"; }
});
});
}
})();
</script>
<script>
(function () {
var pcBody = document.getElementById("pcBody");
var pcBodyMdPreview = document.getElementById("pcBodyMdPreview");
var pcBodyMdInner = document.getElementById("pcBodyMdInner");
var btnRaw = document.getElementById("pcBodyModeRaw");
var btnMd = document.getElementById("pcBodyModeMd");
if (!pcBody || !pcBodyMdPreview || !pcBodyMdInner || !btnRaw || !btnMd) return;
function renderMd() {
var text = (pcBody && pcBody.value) || "";
if (typeof marked === "undefined" || typeof DOMPurify === "undefined") {
pcBodyMdInner.innerHTML =
'<p class="prompt-lib-md-fallback">마크다운 뷰어를 불러오지 못했습니다. 원문 보기로 편집하세요.</p>';
return;
}
if (!String(text).trim()) {
pcBodyMdInner.innerHTML =
'<p class="prompt-lib-md-empty">입력한 내용이 없습니다. 원문 보기에서 지시문을 입력하세요.</p>';
return;
}
try {
if (typeof marked.setOptions === "function") {
marked.setOptions({ breaks: true, gfm: true, async: false });
}
var rawHtml = marked.parse(String(text), { async: false });
pcBodyMdInner.innerHTML = DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true } });
} catch (e) {
pcBodyMdInner.innerHTML = '<p class="prompt-lib-md-fallback">렌더링에 실패했습니다.</p>';
}
}
function setMode(isRaw) {
if (isRaw) {
btnRaw.classList.add("is-active");
btnMd.classList.remove("is-active");
btnRaw.setAttribute("aria-selected", "true");
btnMd.setAttribute("aria-selected", "false");
pcBody.removeAttribute("hidden");
pcBody.removeAttribute("aria-hidden");
pcBodyMdPreview.setAttribute("hidden", "");
pcBodyMdPreview.setAttribute("aria-hidden", "true");
} else {
renderMd();
btnRaw.classList.remove("is-active");
btnMd.classList.add("is-active");
btnRaw.setAttribute("aria-selected", "false");
btnMd.setAttribute("aria-selected", "true");
pcBody.setAttribute("hidden", "");
pcBody.setAttribute("aria-hidden", "true");
pcBodyMdPreview.removeAttribute("hidden");
pcBodyMdPreview.removeAttribute("aria-hidden");
}
}
btnRaw.addEventListener("click", function () {
setMode(true);
});
btnMd.addEventListener("click", function () {
setMode(false);
});
window.promptLibResetPcBodyView = function () {
if (pcBodyMdInner) pcBodyMdInner.innerHTML = "";
setMode(true);
};
})();
</script>
<style>
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
</body>
</html>