Initial commit: AI platform app (server, views, lib, data, deploy docs)

Made-with: Cursor
This commit is contained in:
2026-04-03 20:45:17 +09:00
commit da39cfeef9
70 changed files with 17506 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
<%
const label = typeof buttonLabel !== 'undefined' && buttonLabel ? buttonLabel : '관리자';
%>
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
<a href="/admin" class="top-action-link"><%= label %></a>
<% } else { %>
<button type="button" class="top-action-link" onclick="openAdminTokenModal()"><%= label %></button>
<% } %>

View File

@@ -0,0 +1,86 @@
<div id="admin-token-modal" class="modal-overlay" role="dialog" aria-labelledby="admin-modal-title" aria-modal="true" hidden>
<div class="modal-backdrop" onclick="closeAdminTokenModal()"></div>
<div class="modal-content">
<h3 id="admin-modal-title">관리자 모드</h3>
<p class="modal-desc">강의 등록을 위해 관리자 토큰을 입력한 뒤 활성화해주세요.</p>
<p id="admin-token-error" class="admin-error" style="display:none; margin-bottom:12px">입력한 토큰이 올바르지 않습니다. 다시 확인해주세요.</p>
<form id="admin-token-form" class="admin-token-form">
<input type="password" id="admin-token-input" name="token" placeholder="관리자 토큰" required autocomplete="off" />
<div class="modal-actions">
<button type="submit" id="admin-token-submit">활성화</button>
<button type="button" class="ghost" onclick="closeAdminTokenModal()">닫기</button>
</div>
</form>
</div>
</div>
<script>
(function moveAdminModalToBody() {
function run() {
var m = document.getElementById("admin-token-modal");
if (m && m.parentNode && m.parentNode !== document.body) {
document.body.appendChild(m);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", run);
} else {
run();
}
})();
function openAdminTokenModal() {
var m = document.getElementById("admin-token-modal");
m.hidden = false;
document.body.style.overflow = "hidden";
document.getElementById("admin-token-error").style.display = "none";
document.getElementById("admin-token-input").value = "";
}
function closeAdminTokenModal() {
document.getElementById("admin-token-modal").hidden = true;
document.body.style.overflow = "";
}
function initAdminTokenForm() {
var form = document.getElementById("admin-token-form");
if (form) {
form.addEventListener("submit", function(e) {
e.preventDefault();
var input = document.getElementById("admin-token-input");
var token = (input && input.value || "").trim();
var errEl = document.getElementById("admin-token-error");
var btn = document.getElementById("admin-token-submit");
if (!token) {
if (errEl) errEl.style.display = "block";
return;
}
if (btn) btn.disabled = true;
if (errEl) errEl.style.display = "none";
fetch("/api/admin/validate-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: token })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data && data.valid) {
window.location.href = "/admin?token=" + encodeURIComponent(token);
} else {
if (errEl) errEl.style.display = "block";
}
})
.catch(function() {
if (errEl) {
errEl.textContent = "토큰 검증 중 오류가 발생했습니다.";
errEl.style.display = "block";
}
})
.finally(function() {
if (btn) btn.disabled = false;
});
});
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAdminTokenForm);
} else {
initAdminTokenForm();
}
</script>

View File

@@ -0,0 +1,54 @@
<% var _externalUrl = (lecture.type === "link" || lecture.type === "news") && (lecture.newsUrl || "").trim(); %>
<article class="lecture-card">
<% if (_externalUrl) { %>
<a class="lecture-link lecture-link-external" href="<%= _externalUrl %>" target="_blank" rel="noopener noreferrer">
<% } else { %>
<a class="lecture-link" href="/lectures/<%= lecture.id %>">
<% } %>
<div class="thumb <%= lecture.type %>">
<% if (lecture.type === "ppt") { %>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="<%= lecture.title %> 썸네일" class="thumb-image" />
<% } else { %>
<span class="thumb-fallback">썸네일 <%= lecture.thumbnailStatus || "pending" %></span>
<% } %>
<span class="thumb-kicker">PPT 프리뷰</span>
<% if (lecture.previewTitle && lecture.previewTitle !== "제목 없음") { %><strong><%= lecture.previewTitle %></strong><% } %>
<% } else if (lecture.type === "news") { %>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
<% } %>
<span class="thumb-kicker">뉴스</span>
<% if (!lecture.thumbnailUrl) { %><strong>외부 링크</strong><% } %>
<% } else if (lecture.type === "link") { %>
<% if (lecture.thumbnailUrl) { %>
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
<% } %>
<span class="thumb-kicker">웹 링크</span>
<% if (!lecture.thumbnailUrl) { %><strong>외부 페이지</strong><% } %>
<% } else if (lecture.type === "video") { %>
<span class="thumb-fallback thumb-fallback-video">▶</span>
<span class="thumb-kicker">동영상 파일</span>
<strong>업로드 영상</strong>
<% } else { %>
<% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %>
<% if (ytThumb) { %>
<img src="<%= ytThumb %>" alt="<%= lecture.title %> 썸네일" class="thumb-image thumb-image-youtube" />
<% } %>
<span class="thumb-kicker">YouTube</span>
<strong>영상 강의</strong>
<% } %>
</div>
<div class="badge <%= lecture.type %>">
<% if (lecture.type === "youtube") { %>YouTube<% } else if (lecture.type === "news") { %>뉴스<% } else if (lecture.type === "link") { %>링크<% } else if (lecture.type === "video") { %>동영상<% } else { %>PPT<% } %>
</div>
<h3><%= lecture.title %></h3>
<p><%= lecture.description || "설명이 없습니다." %></p>
<div class="tag-row">
<% (lecture.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
</div>
<small><%= new Date(lecture.createdAt).toLocaleString("ko-KR") %></small>
</a>
</article>

View File

@@ -0,0 +1,3 @@
<% (lectures || []).forEach((lecture) => { %>
<%- include('lecture-card', { lecture }) %>
<% }) %>

120
views/partials/nav.ejs Normal file
View File

@@ -0,0 +1,120 @@
<button type="button" class="nav-mobile-toggle" id="nav-mobile-toggle" aria-label="메뉴 열기" aria-expanded="false" aria-controls="nav-drawer">
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z"/></svg>
</button>
<div class="nav-drawer-backdrop" id="nav-drawer-backdrop" hidden></div>
<aside class="left-nav" id="nav-drawer">
<div class="nav-logo-section">
<a
href="https://xavis.co.kr"
class="logo-link logo-link-xavis"
target="_blank"
rel="noopener noreferrer"
aria-label="XAVIS 회사 사이트(새 탭)"
>
<img src="/public/images/xavis-logo.png" alt="XAVIS" class="logo-img logo-img-xavis" />
</a>
<img
src="/public/images/aiplatform-logo.png"
alt="AI PLATFORM"
class="logo-img logo-img-aiplatform"
width="168"
height="auto"
decoding="async"
/>
<div class="nav-logo-divider" role="presentation" aria-hidden="true"></div>
</div>
<a href="/chat" class="nav-item <%= activeMenu === 'chat' ? 'active' : '' %>">채팅</a>
<a href="/ai-explore" class="nav-item <%= activeMenu === 'ai-explore' ? 'active' : '' %>">AI</a>
<a href="/learning" class="nav-item <%= activeMenu === 'learning' ? 'active' : '' %>">학습센터</a>
<a href="/ax-apply" class="nav-item <%= activeMenu === 'ax-apply' ? 'active' : '' %>">과제신청</a>
<a href="/ai-cases" class="nav-item <%= activeMenu === 'ai-cases' ? 'active' : '' %>">성공사례</a>
<div class="nav-footer">
<% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %>
<% if (_opsLoggedIn) { %>
<a href="/logout" class="nav-item nav-item-ops-logout" title="이메일 인증 세션 종료">로그아웃</a>
<% } else { %>
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
<a href="/admin/users" class="nav-item <%= activeMenu === 'admin-users' ? 'active' : '' %>">사용자 현황관리</a>
<div class="nav-separator"></div>
<a href="/admin/logout" class="nav-item nav-item-ghost" title="관리자 세션 종료">로그오프</a>
<% } else { %>
<div class="nav-separator"></div>
<button type="button" class="nav-item nav-item-ghost" onclick="openAdminTokenModal()" title="관리자 모드">관리자</button>
<% } %>
<% } %>
</div>
</aside>
<script>
(function () {
var toggle = document.getElementById("nav-mobile-toggle");
var nav = document.getElementById("nav-drawer");
var backdrop = document.getElementById("nav-drawer-backdrop");
if (!toggle || !nav || !backdrop) return;
function isMobileNav() {
return typeof window.matchMedia === "function" && window.matchMedia("(max-width: 900px)").matches;
}
function openDrawer() {
if (!isMobileNav()) return;
nav.classList.add("nav-drawer-open");
backdrop.removeAttribute("hidden");
document.body.classList.add("nav-mobile-open");
toggle.setAttribute("aria-expanded", "true");
toggle.setAttribute("aria-label", "메뉴 닫기");
}
function closeDrawer() {
nav.classList.remove("nav-drawer-open");
backdrop.setAttribute("hidden", "");
document.body.classList.remove("nav-mobile-open");
toggle.setAttribute("aria-expanded", "false");
toggle.setAttribute("aria-label", "메뉴 열기");
}
function toggleDrawer() {
if (nav.classList.contains("nav-drawer-open")) closeDrawer();
else openDrawer();
}
toggle.addEventListener("click", function (e) {
e.stopPropagation();
toggleDrawer();
});
backdrop.addEventListener("click", closeDrawer);
nav.querySelectorAll("a").forEach(function (el) {
el.addEventListener("click", function () {
if (isMobileNav()) closeDrawer();
});
});
nav.querySelectorAll("button.nav-item").forEach(function (el) {
el.addEventListener("click", function () {
if (isMobileNav()) closeDrawer();
});
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && nav.classList.contains("nav-drawer-open")) closeDrawer();
});
if (typeof window.matchMedia === "function") {
window.matchMedia("(min-width: 901px)").addEventListener("change", function (ev) {
if (ev.matches) closeDrawer();
});
}
})();
(function () {
try {
var sp = new URLSearchParams(window.location.search);
if (sp.get("verified") === "1") {
alert("인증되었습니다.");
var u = new URL(window.location.href);
u.searchParams.delete("verified");
history.replaceState({}, "", u.pathname + (u.search ? u.search : "") + u.hash);
}
} catch (e) {}
})();
</script>
<%- include('admin-token-modal') %>

View File

@@ -0,0 +1,43 @@
<% var detailAllowed = typeof successStoryDetailAllowed !== 'undefined' ? successStoryDetailAllowed : true; %>
<article class="success-story-card<%= detailAllowed ? '' : ' success-story-card--locked' %>">
<% if (detailAllowed) { %>
<a class="success-story-link" href="/ai-cases/<%= story.slug %>">
<div class="success-thumb" aria-hidden="true">
<span class="success-thumb-icon">✦</span>
<span class="success-thumb-kicker"><%= story.department || "사내 사례" %></span>
</div>
<div class="success-badge"><%= story.department || "사내" %> · <%= story.author || "" %></div>
<h3><%= story.title %></h3>
<p class="success-excerpt"><%= story.excerpt || "" %></p>
<div class="tag-row">
<% (story.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
</div>
<small class="success-meta">
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
</small>
</a>
<% } else { %>
<div
class="success-story-link"
title="로그인 후 이용 가능합니다."
>
<div class="success-thumb" aria-hidden="true">
<span class="success-thumb-icon">✦</span>
<span class="success-thumb-kicker"><%= story.department || "사내 사례" %></span>
</div>
<div class="success-badge"><%= story.department || "사내" %> · <%= story.author || "" %></div>
<h3><%= story.title %></h3>
<p class="success-excerpt"><%= story.excerpt || "" %></p>
<div class="tag-row">
<% (story.tags || []).forEach((oneTag) => { %>
<span class="tag-chip">#<%= oneTag %></span>
<% }) %>
</div>
<small class="success-meta">
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
</small>
</div>
<% } %>
</article>