- partials/favicon.ejs로 icon·apple-touch-icon 링크 - 전 페이지 head에 include, /favicon.ico는 동일 PNG 제공 - 인라인 403 HTML에도 favicon 링크 Made-with: Cursor
335 lines
17 KiB
Plaintext
335 lines
17 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>학습센터</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 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 = []; } %>
|
|
<% if (typeof adminRequested === 'undefined') { adminRequested = false; } %>
|
|
<% if (typeof adminMode === 'undefined') { adminMode = false; } %>
|
|
<% if (typeof tokenRaw === 'undefined') { tokenRaw = ''; } %>
|
|
<% if (typeof tokenMasked === 'undefined') { tokenMasked = ''; } %>
|
|
<% if (typeof returnQuery === 'undefined') { returnQuery = ''; } %>
|
|
<% if (typeof retryQueued === 'undefined') { retryQueued = 0; } %>
|
|
<% if (typeof lectures === 'undefined') { lectures = []; } %>
|
|
<% if (typeof escapeHtml === 'undefined') { escapeHtml = function(s) { return String(s || ''); }; } %>
|
|
<div class="app-shell">
|
|
<%- include('partials/nav', { activeMenu: 'learning' }) %>
|
|
|
|
<div class="content-area">
|
|
<header class="topbar">
|
|
<h1>학습센터</h1>
|
|
<button type="button" class="top-action-link" onclick="openAdminTokenModal()">강의 등록하기</button>
|
|
</header>
|
|
|
|
<main class="container">
|
|
<section class="hero panel">
|
|
<h2>최신 컨텐츠로 학습하고, 바로 업무에 적용하세요.</h2>
|
|
<p>유튜브 링크 또는 PPT를 등록한 뒤, 목록에서 클릭하여 강의를 시청할 수 있습니다.</p>
|
|
</section>
|
|
|
|
<section class="panel filter-panel">
|
|
<h2>강의 검색/필터</h2>
|
|
<form action="/" method="get" class="filter-grid">
|
|
<label>
|
|
검색어
|
|
<input type="text" name="q" value="<%= filters.q %>" placeholder="제목" />
|
|
</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>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
태그
|
|
<select name="tag">
|
|
<option value="">전체</option>
|
|
<% availableTags.forEach((oneTag) => { %>
|
|
<option value="<%= oneTag %>" <%= filters.tag === oneTag ? "selected" : "" %>><%= oneTag %></option>
|
|
<% }) %>
|
|
</select>
|
|
</label>
|
|
<% if (adminRequested) { %>
|
|
<input type="hidden" name="admin" value="1" />
|
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
|
<% } %>
|
|
<div class="filter-actions">
|
|
<button type="submit">필터 적용</button>
|
|
<a class="link-muted" href="/">초기화</a>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="panel admin-panel">
|
|
<h2>관리자 모드</h2>
|
|
<% if (retryQueued > 0) { %>
|
|
<p class="admin-ok">실패 건 재시도 <%= retryQueued %>건이 큐에 등록되었습니다.</p>
|
|
<% } %>
|
|
<% if (adminMode) { %>
|
|
<p class="admin-ok">관리자 모드 활성화됨</p>
|
|
<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 { %>
|
|
<p class="admin-warn">삭제 기능은 관리자 토큰이 있어야 활성화됩니다.</p>
|
|
<% } %>
|
|
<form action="/" method="get" class="admin-inline">
|
|
<input type="hidden" name="q" value="<%= filters.q %>" />
|
|
<input type="hidden" name="type" value="<%= filters.type %>" />
|
|
<input type="hidden" name="tag" value="<%= filters.tag %>" />
|
|
<input type="hidden" name="page" value="<%= pagination.page %>" />
|
|
<input type="hidden" name="admin" value="1" />
|
|
<input type="password" name="token" placeholder="관리자 토큰" />
|
|
<button type="submit">관리자 활성화</button>
|
|
</form>
|
|
<% if (adminMode) { %>
|
|
<form action="/thumbnails/retry-failed" method="post" class="admin-inline">
|
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
|
<input type="hidden" name="returnTo" value="?<%= returnQuery %>" />
|
|
<button type="submit" class="ghost">실패 썸네일 일괄 재시도</button>
|
|
</form>
|
|
<p class="api-hint">메트릭 API: <code>/api/queue/metrics?token=***</code></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>
|
|
|
|
<section id="register" class="panel">
|
|
<h2>유튜브 강의 등록</h2>
|
|
<form action="/lectures/youtube" method="post" class="form-grid">
|
|
<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">
|
|
태그 (쉼표 구분)
|
|
<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">
|
|
<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">
|
|
태그 (쉼표 구분)
|
|
<input type="text" name="tags" placeholder="예: 프롬프트, 생성형AI" />
|
|
</label>
|
|
<button type="submit">강의 등록</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2>동영상 파일 등록</h2>
|
|
<form action="/lectures/video" method="post" enctype="multipart/form-data" class="form-grid">
|
|
<label>
|
|
제목
|
|
<input type="text" name="title" required />
|
|
</label>
|
|
<label>
|
|
동영상 파일 (MP4·WebM·MOV)
|
|
<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">
|
|
태그 (쉼표 구분)
|
|
<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 (!lectures.length) { %>
|
|
<p class="empty">등록된 강의가 없습니다.</p>
|
|
<% } %>
|
|
|
|
<div class="lecture-grid">
|
|
<% lectures.forEach((lecture) => { %>
|
|
<% 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>
|
|
<% if (adminMode) { %>
|
|
<form action="/lectures/<%= lecture.id %>/delete" method="post" class="delete-form">
|
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
|
<input type="hidden" name="returnTo" value="?<%= returnQuery %>" />
|
|
<button type="submit" class="danger">삭제</button>
|
|
</form>
|
|
<% if (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>
|
|
<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="?<%= returnQuery %>" />
|
|
<button type="submit" class="ghost">썸네일 재생성</button>
|
|
</form>
|
|
<% } %>
|
|
<% } %>
|
|
</article>
|
|
<% }) %>
|
|
</div>
|
|
|
|
<% if (pagination.totalPages > 1) { %>
|
|
<nav class="pagination">
|
|
<% if (pagination.hasPrev) { %>
|
|
<a href="/?<%= pagination.prevQuery %>">이전</a>
|
|
<% } %>
|
|
|
|
<% pagination.pages.forEach((p) => { %>
|
|
<a href="/?<%= p.query %>" class="<%= p.active ? "active" : "" %>"><%= p.page %></a>
|
|
<% }) %>
|
|
|
|
<% if (pagination.hasNext) { %>
|
|
<a href="/?<%= pagination.nextQuery %>">다음</a>
|
|
<% } %>
|
|
</nav>
|
|
<% } %>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|