feat(notifications): 알림 종 + 공지 기능 추가
- 헤더 프로필 왼쪽 알림 종: 드롭다운·안읽음 배지·60초 폴링(최초 폴링 도입) - 내 프로젝트 좋아요/즐겨찾기/댓글 시 소유자에게 알림(본인 제외·재토글 도배 방지) - 관리자 공지: /announcements 목록 + 작성/수정/삭제, 등록 시 전 직원에게 fan-out 알림 - notifications/announcements 테이블(멱등) + requireAdmin 가드 + bell/megaphone 아이콘 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,10 @@
|
||||
<span class="theme-ico theme-ico-sun"><%- icon("sun", { size: 16 }) %></span>
|
||||
</button>
|
||||
|
||||
<a class="hdr-link" href="/announcements" title="공지사항">
|
||||
<%- icon("megaphone", { size: 15 }) %><span>공지</span>
|
||||
</a>
|
||||
|
||||
<a class="hdr-link" href="<%= manualUrl %>" target="_blank" rel="noopener" title="포털 사용 매뉴얼">
|
||||
<%- icon("book", { size: 15 }) %><span>매뉴얼</span>
|
||||
</a>
|
||||
@@ -53,6 +57,24 @@
|
||||
<span class="admin-pill"><%- icon("lock", { size: 11 }) %>관리자</span>
|
||||
<% } %>
|
||||
|
||||
<!-- 알림(🔔) 종 — 프로필 왼쪽. 목록/배지는 app.js(notifications.js)가 fetch 로 채운다. -->
|
||||
<div class="notif">
|
||||
<button class="icon-btn notif-btn" id="notifBtn" type="button" aria-haspopup="true" aria-expanded="false" title="알림" aria-label="알림">
|
||||
<%- icon("bell", { size: 16 }) %>
|
||||
<span class="notif-badge" id="notifBadge" hidden></span>
|
||||
</button>
|
||||
<div class="notif-pop" id="notifPop" hidden>
|
||||
<div class="notif-head">
|
||||
<span class="notif-title">알림</span>
|
||||
<button type="button" class="notif-readall" id="notifReadAll">모두 읽음</button>
|
||||
</div>
|
||||
<div class="notif-list" id="notifList">
|
||||
<div class="notif-empty">불러오는 중…</div>
|
||||
</div>
|
||||
<a class="notif-foot" href="/announcements"><%- icon("megaphone", { size: 14 }) %>공지 전체 보기</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="usermenu">
|
||||
<button class="usermenu-btn" id="userMenuBtn" type="button" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="who"><%= user.sabun %></span>
|
||||
@@ -66,6 +88,7 @@
|
||||
<div class="usermenu-role"><%= user.isAdmin ? "관리자 (admin)" : "일반 사용자" %></div>
|
||||
</div>
|
||||
<a class="usermenu-item" href="/u/<%= user.sabun %>"><span class="usermenu-ico"><%- icon("repo", { size: 15 }) %></span>내 프로필</a>
|
||||
<a class="usermenu-item" href="/announcements"><span class="usermenu-ico"><%- icon("megaphone", { size: 15 }) %></span>공지사항</a>
|
||||
<a class="usermenu-item" href="/logout"><span class="usermenu-ico"><%- icon("signout", { size: 15 }) %></span>로그아웃</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
76
views/announcements.ejs
Normal file
76
views/announcements.ejs
Normal file
@@ -0,0 +1,76 @@
|
||||
<%- include("_header") %>
|
||||
<%
|
||||
// 뷰 로컬 헬퍼 (빌드 없이 EJS 스크립틀릿으로)
|
||||
function fdate(d) { var x = new Date(d); return x.getFullYear() + "." + String(x.getMonth() + 1).padStart(2, "0") + "." + String(x.getDate()).padStart(2, "0"); }
|
||||
function edited(a) { try { return (+new Date(a.updated_at) - +new Date(a.created_at)) > 1000; } catch (e) { return false; } }
|
||||
%>
|
||||
|
||||
<main class="main">
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<div class="section-titlewrap">
|
||||
<h2 class="section-title">공지사항</h2>
|
||||
<span class="section-sub">관리자 공지 모아보기</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if (user.isAdmin) { %>
|
||||
<!-- 관리자 전용 새 공지 작성 폼 -->
|
||||
<form class="ann-form ann-form-new" method="post" action="/announcements">
|
||||
<input class="ann-input-title" type="text" name="title" maxlength="120" placeholder="공지 제목" required />
|
||||
<textarea class="ann-input-body" name="body" rows="4" placeholder="공지 내용 (마크다운 지원)"></textarea>
|
||||
<div class="ann-form-actions">
|
||||
<button class="btn btn-primary" type="submit"><%- icon("megaphone", { size: 15 }) %>공지 등록</button>
|
||||
</div>
|
||||
</form>
|
||||
<% } %>
|
||||
|
||||
<% if (!announcements.length) { %>
|
||||
<div class="ann-empty">아직 등록된 공지가 없습니다.</div>
|
||||
<% } %>
|
||||
|
||||
<div class="ann-list">
|
||||
<% announcements.forEach(function (a) { %>
|
||||
<article class="ann-item" id="ann-<%= a.id %>">
|
||||
<div class="ann-view" data-ann-view="<%= a.id %>">
|
||||
<div class="ann-item-head">
|
||||
<h3 class="ann-item-title"><%= a.title %></h3>
|
||||
<% if (user.isAdmin) { %>
|
||||
<div class="ann-item-actions">
|
||||
<button type="button" class="btn btn-sm" data-ann-edit="<%= a.id %>"><%- icon("pencil", { size: 13 }) %>수정</button>
|
||||
<form method="post" action="/announcements/<%= a.id %>/delete" style="display:inline">
|
||||
<button type="submit" class="btn btn-sm ann-del" data-confirm="이 공지를 삭제할까요?"><%- icon("trash", { size: 13 }) %>삭제</button>
|
||||
</form>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="ann-meta">
|
||||
<%= a.author_name %> · <%= fdate(a.created_at) %><% if (edited(a)) { %> · 수정됨<% } %>
|
||||
</div>
|
||||
<% if (a.body && a.body.trim()) { %>
|
||||
<div class="ann-body md-body"><%- renderMarkdown(a.body) %></div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (user.isAdmin) { %>
|
||||
<!-- 인라인 수정 폼 (수정 버튼으로 토글) -->
|
||||
<form class="ann-form ann-edit" method="post" action="/announcements/<%= a.id %>/update" data-ann-editform="<%= a.id %>" hidden>
|
||||
<input class="ann-input-title" type="text" name="title" maxlength="120" value="<%= a.title %>" required />
|
||||
<textarea class="ann-input-body" name="body" rows="4"><%= a.body %></textarea>
|
||||
<div class="ann-form-actions">
|
||||
<button class="btn btn-primary btn-sm" type="submit"><%- icon("check", { size: 14 }) %>저장</button>
|
||||
<button class="btn btn-sm" type="button" data-ann-cancel="<%= a.id %>">취소</button>
|
||||
</div>
|
||||
</form>
|
||||
<% } %>
|
||||
</article>
|
||||
<% }); %>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<%- include("_footer") %>
|
||||
|
||||
<script type="module" src="/public/js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user