Files
ai_platform/views/learning-admin.ejs
dsyoon 0e235db32d refactor(admin): 학습센터 관리에서 활성/비활성 제거, 네비에 관리자·관리자 off
- learning-admin: 관리자 모드 토글·인라인 토큰 폼 삭제, 안내 문구는 좌측 메뉴 관리자로 통일
- nav: 관리자 세션 시 로그오프 대신 관리자(모달)·관리자 off(/admin/logout→/learning)
- styles: 미사용 관리자 토글 스타일 정리
- README: 관리자 off 동작 반영

Made-with: Cursor
2026-04-08 17:37:05 +09:00

320 lines
22 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') %>
<% var admTitle = typeof adminPageTitle !== 'undefined' ? adminPageTitle : '학습센터 관리'; %>
<title><%= admTitle %> - XAVIS</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<% if (typeof filters === 'undefined') { filters = { q: '', type: 'all', tag: '' }; } %>
<% if (typeof availableTags === 'undefined') { availableTags = []; } %>
<% if (typeof pagination === 'undefined') { pagination = { page: 1, totalPages: 1, totalCount: 0, hasPrev: false, hasNext: false, prevQuery: '', nextQuery: '', pages: [] }; } %>
<% if (typeof adminRequested === 'undefined') { adminRequested = false; } %>
<% if (typeof adminMode === 'undefined') { adminMode = false; } %>
<% if (typeof tokenRaw === 'undefined') { tokenRaw = ''; } %>
<% if (typeof returnQuery === 'undefined') { returnQuery = ''; } %>
<% if (typeof retryQueued === 'undefined') { retryQueued = 0; } %>
<% if (typeof eventsCleared === 'undefined') { eventsCleared = false; } %>
<% if (typeof lectures === 'undefined') { lectures = []; } %>
<% if (typeof thumbnailQueueInfo === 'undefined') { thumbnailQueueInfo = { pending: 0, working: false, maxRetry: 2 }; } %>
<% if (typeof thumbnailStatusSummary === 'undefined') { thumbnailStatusSummary = { ready: 0, processing: 0, failed: 0, pending: 0 }; } %>
<% if (typeof recentEvents === 'undefined') { recentEvents = []; } %>
<% if (typeof failureReasons === 'undefined') { failureReasons = []; } %>
<% var adminBasePath = typeof adminBasePath !== 'undefined' ? adminBasePath : '/admin'; %>
<% var viewerBasePath = typeof viewerBasePath !== 'undefined' ? viewerBasePath : '/learning'; %>
<% var navMenu = typeof navActiveMenu !== 'undefined' ? navActiveMenu : 'learning'; %>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: navMenu, adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
<div class="content-area">
<header class="topbar">
<h1><%= admTitle %></h1>
<a class="top-action-link" href="<%= viewerBasePath %>">목록 보기</a>
</header>
<main class="container">
<section class="panel admin-panel">
<div class="admin-mode-header">
<h2>관리자 모드</h2>
</div>
<% if (retryQueued > 0) { %>
<p class="admin-ok">실패 건 재시도 <%= retryQueued %>건이 큐에 등록되었습니다.</p>
<% } %>
<% if (adminMode) { %>
<div class="queue-status">
<span>큐: <b><%= thumbnailQueueInfo.pending %></b></span>
<span>워커: <b><%= thumbnailQueueInfo.working ? "작동중" : "대기" %></b></span>
<span>실패 재시도 최대: <b><%= thumbnailQueueInfo.maxRetry %></b></span>
</div>
<div class="queue-status">
<span>PPT 썸네일 - 준비완료 <b><%= thumbnailStatusSummary.ready %></b></span>
<span>처리중 <b><%= thumbnailStatusSummary.processing %></b></span>
<span>대기 <b><%= thumbnailStatusSummary.pending %></b></span>
<span>실패 <b><%= thumbnailStatusSummary.failed %></b></span>
</div>
<% } else { %>
<% if (adminRequested) { %>
<p class="admin-error">입력한 토큰이 올바르지 않습니다. 다시 확인해주세요.</p>
<% } %>
<p class="admin-warn">삭제·썸네일 관리는 좌측 메뉴 하단 <strong>관리자</strong>에서 토큰을 입력해 주세요.</p>
<% } %>
</section>
<div
class="admin-body-gated <%= adminMode ? 'admin-body-gated--unlocked' : 'admin-body-gated--locked' %>"
<%= !adminMode ? 'inert' : '' %>
>
<section id="register" class="panel">
<h2>유튜브 강의 등록</h2>
<form action="/lectures/youtube" method="post" class="form-grid">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<label>제목 <input type="text" name="title" required /></label>
<label>유튜브 링크 <input type="url" name="youtubeUrl" placeholder="https://www.youtube.com/watch?v=..." required /></label>
<label class="full">설명 <textarea name="description" rows="3" placeholder="강의 설명"></textarea></label>
<label class="full">카테고리
<div class="category-radios-inline">
<label><input type="radio" name="category" value="" checked />선택 안함</label>
<label><input type="radio" name="category" value="AX 사고 전환" />AX 사고 전환</label>
<label><input type="radio" name="category" value="AI 툴 활용" />AI 툴 활용</label>
<label><input type="radio" name="category" value="AI Agent" />AI Agent</label>
<label><input type="radio" name="category" value="바이브 코딩" />바이브 코딩</label>
</div>
</label>
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" placeholder="예: AI에이전트, 바이브코딩" /></label>
<button type="submit">유튜브 등록</button>
</form>
</section>
<section class="panel">
<h2>PDF/PowerPoint 강의 등록</h2>
<form action="/lectures/ppt" method="post" enctype="multipart/form-data" class="form-grid">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<label>제목 <input type="text" name="title" required /></label>
<label>PDF 또는 PPT 파일 <input type="file" name="pptFile" accept=".pdf,.pptx" required /></label>
<label class="full">설명 <textarea name="description" rows="3" placeholder="강의 설명"></textarea></label>
<label class="full">카테고리
<div class="category-radios-inline">
<label><input type="radio" name="category" value="" checked />선택 안함</label>
<label><input type="radio" name="category" value="AX 사고 전환" />AX 사고 전환</label>
<label><input type="radio" name="category" value="AI 툴 활용" />AI 툴 활용</label>
<label><input type="radio" name="category" value="AI Agent" />AI Agent</label>
<label><input type="radio" name="category" value="바이브 코딩" />바이브 코딩</label>
</div>
</label>
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" placeholder="예: 프롬프트, 생성형AI" /></label>
<button type="submit">파일 등록</button>
</form>
</section>
<section class="panel">
<h2>동영상 파일 등록</h2>
<p class="muted" style="margin-top:0;font-size:13px;">MP4·WebM·MOV 파일을 서버에 저장한 뒤 브라우저에서 재생합니다. (용량 제한은 서버 설정 <code>LECTURE_VIDEO_MAX_MB</code>, 기본 500MB)</p>
<form action="/lectures/video" method="post" enctype="multipart/form-data" class="form-grid">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<label>제목 <input type="text" name="title" required /></label>
<label>동영상 파일 <input type="file" name="videoFile" accept=".mp4,.webm,.mov,video/mp4,video/webm,video/quicktime" required /></label>
<label class="full">설명 <textarea name="description" rows="3" placeholder="강의 설명"></textarea></label>
<label class="full">카테고리
<div class="category-radios-inline">
<label><input type="radio" name="category" value="" checked />선택 안함</label>
<label><input type="radio" name="category" value="AX 사고 전환" />AX 사고 전환</label>
<label><input type="radio" name="category" value="AI 툴 활용" />AI 툴 활용</label>
<label><input type="radio" name="category" value="AI Agent" />AI Agent</label>
<label><input type="radio" name="category" value="바이브 코딩" />바이브 코딩</label>
</div>
</label>
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" placeholder="예: 온보딩, 내부교육" /></label>
<button type="submit">동영상 등록</button>
</form>
</section>
<section class="panel">
<h2>웹 링크 강의 등록</h2>
<p class="muted" style="margin-top:0;font-size:13px;">외부 사이트(http/https)를 카드로 등록합니다. (유튜브는 위 «유튜브 강의 등록»을 사용하세요.)</p>
<form action="/lectures/link" method="post" class="form-grid">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<label>제목 <input type="text" name="title" required /></label>
<label>URL <input type="url" name="linkUrl" placeholder="https://..." required /></label>
<label class="full">설명 <textarea name="description" rows="3" placeholder="강의 설명"></textarea></label>
<label class="full">카테고리
<div class="category-radios-inline">
<label><input type="radio" name="category" value="" checked />선택 안함</label>
<label><input type="radio" name="category" value="AX 사고 전환" />AX 사고 전환</label>
<label><input type="radio" name="category" value="AI 툴 활용" />AI 툴 활용</label>
<label><input type="radio" name="category" value="AI Agent" />AI Agent</label>
<label><input type="radio" name="category" value="바이브 코딩" />바이브 코딩</label>
</div>
</label>
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" placeholder="예: 참고자료, 링크모음" /></label>
<button type="submit">웹 링크 등록</button>
</form>
</section>
<section class="panel">
<div class="section-head">
<h2>등록된 강의</h2>
<span class="count-chip">총 <%= pagination.totalCount %>건</span>
</div>
<% if (!adminMode && lectures.length > 0) { %>
<p class="admin-warn" style="margin-bottom:12px">수정·삭제는 좌측 메뉴 하단 <strong>관리자</strong>에서 토큰을 입력한 뒤 이용할 수 있습니다.</p>
<% } %>
<form action="<%= adminBasePath %>" method="get" class="filter-grid" style="margin-bottom:12px">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<label>검색 <input type="text" name="q" value="<%= filters.q %>" /></label>
<label>타입 <select name="type"><option value="all" <%= filters.type === "all" ? "selected" : "" %>>전체</option><option value="youtube" <%= filters.type === "youtube" ? "selected" : "" %>>YouTube</option><option value="ppt" <%= filters.type === "ppt" ? "selected" : "" %>>PPT</option><option value="video" <%= filters.type === "video" ? "selected" : "" %>>동영상 파일</option><option value="link" <%= filters.type === "link" ? "selected" : "" %>>웹 링크</option><option value="news" <%= filters.type === "news" ? "selected" : "" %>>뉴스 URL</option></select></label>
<label>태그 <select name="tag"><option value="">전체</option><% (availableTags || []).forEach((t) => { %><option value="<%= t %>" <%= filters.tag === t ? "selected" : "" %>><%= t %></option><% }) %></select></label>
<label>카테고리 <select name="category"><option value="">전체</option><option value="AX 사고 전환" <%= filters.category === 'AX 사고 전환' ? 'selected' : '' %>>AX 사고 전환</option><option value="AI 툴 활용" <%= filters.category === 'AI 툴 활용' ? 'selected' : '' %>>AI 툴 활용</option><option value="AI Agent" <%= filters.category === 'AI Agent' ? 'selected' : '' %>>AI Agent</option><option value="바이브 코딩" <%= filters.category === '바이브 코딩' ? 'selected' : '' %>>바이브 코딩</option></select></label>
<button type="submit">필터</button>
</form>
<% if (!lectures.length) { %>
<p class="empty">등록된 항목이 없습니다.</p>
<% } else { %>
<div class="lecture-grid">
<% lectures.forEach((lecture) => { %>
<article class="lecture-card">
<% if (adminMode) { %>
<div class="lecture-card-actions">
<a href="/lectures/<%= lecture.id %>/edit?<%= returnQuery %>" class="btn-edit">수정</a>
<form action="/lectures/<%= lecture.id %>/delete" method="post" class="delete-form-inline" onsubmit="return confirm('정말 삭제하시겠습니까?');">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<button type="submit" class="danger">삭제</button>
</form>
</div>
<% } %>
<% var _externalUrl = (lecture.type === "link" || lecture.type === "news") && (lecture.newsUrl || "").trim(); %>
<% 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((t) => { %><span class="tag-chip">#<%= t %></span><% }) %></div>
<small><%= new Date(lecture.createdAt).toLocaleString("ko-KR") %></small>
</a>
<% if (adminMode && lecture.type === "ppt") { %>
<div class="thumb-state-row">
<span class="state-chip <%= lecture.thumbnailStatus || "pending" %>"><%= lecture.thumbnailStatus || "pending" %></span>
<% if (lecture.thumbnailError) { %><small class="error-text"><%= lecture.thumbnailError %></small><% } %>
</div>
<div class="thumb-state-row" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<form action="/lectures/<%= lecture.id %>/thumbnail/regenerate" method="post" class="delete-form">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<button type="submit" class="ghost">썸네일 재생성</button>
</form>
<form action="/lectures/<%= lecture.id %>/slides/regenerate" method="post" class="delete-form">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<button type="submit" class="ghost">슬라이드 이미지 재생성</button>
</form>
</div>
<% } %>
</article>
<% }) %>
</div>
<% if (pagination.totalPages > 1) { %>
<nav class="pagination">
<% if (pagination.hasPrev) { %><a href="<%= adminBasePath %>?<%= pagination.prevQuery %>">이전</a><% } %>
<% pagination.pages.forEach((p) => { %><a href="<%= adminBasePath %>?<%= p.query %>" class="<%= p.active ? "active" : "" %>"><%= p.page %></a><% }) %>
<% if (pagination.hasNext) { %><a href="<%= adminBasePath %>?<%= pagination.nextQuery %>">다음</a><% } %>
</nav>
<% } %>
<% } %>
</section>
</div>
<% if (adminMode) { %>
<section class="panel admin-logs-panel">
<h2>썸네일 로그</h2>
<div class="admin-inline" style="margin-bottom:8px">
<form action="/thumbnails/retry-failed" method="post" class="admin-inline">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<button type="submit" class="ghost">실패 썸네일 일괄 재시도</button>
</form>
<form action="/thumbnails/events/clear" method="post" class="admin-inline" onsubmit="return confirm('썸네일 이벤트 로그를 모두 삭제하시겠습니까?');">
<input type="hidden" name="token" value="<%= tokenRaw %>" />
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
<button type="submit" class="ghost">초기화</button>
</form>
</div>
<% if (eventsCleared) { %>
<p class="admin-ok">썸네일 이벤트 로그가 초기화되었습니다.</p>
<% } %>
<p class="api-hint">
<a href="/admin/thumbnail-events?token=<%= tokenRaw %>" class="link-muted">이벤트 로그 페이지 열기</a>
</p>
<div class="dashboard-grid">
<section class="mini-panel">
<h3>실패 원인 TOP</h3>
<% if (!failureReasons.length) { %>
<p class="empty">최근 실패 원인이 없습니다.</p>
<% } else { %>
<ul class="plain-list">
<% failureReasons.slice(0, 5).forEach((item) => { %>
<li><b><%= item.count %>회</b> <span><%= item.reason %></span></li>
<% }) %>
</ul>
<% } %>
</section>
<section class="mini-panel">
<h3>최근 썸네일 이벤트</h3>
<% if (!recentEvents.length) { %>
<p class="empty">이벤트 로그가 없습니다.</p>
<% } else { %>
<ul class="plain-list">
<% recentEvents.forEach((evt) => { %>
<li>
<span class="evt-type <%= evt.type %>"><%= evt.type %></span>
<span><%= evt.lectureTitle || evt.lectureId %></span>
<small><%= new Date(evt.at).toLocaleString("ko-KR") %></small>
</li>
<% }) %>
</ul>
<% } %>
</section>
</div>
</section>
<% } %>
</main>
</div>
</div>
</body>
</html>