feat: 공지 탭 추가
Some checks failed
Build and Push Images / build-backend (push) Has been cancelled

This commit is contained in:
qorgh529
2026-04-10 19:58:43 +09:00
parent 1a6e9b6327
commit 68e9fc0a32
3 changed files with 273 additions and 1 deletions

View File

@@ -205,6 +205,7 @@
</header>
<nav id="main-nav">
<button class="nav-btn active" onclick="showPage('my-pages')">MY Page</button>
<button class="nav-btn" onclick="showPage('notice')">📢 공지</button>
<button class="nav-btn" onclick="showPage('board')">📋 관리자 요청</button>
<button class="nav-btn admin-only" onclick="showPage('admin-pages')" style="display:none">⚙️ 페이지 관리</button>
<button class="nav-btn admin-only" onclick="showPage('admin-users')" style="display:none">👥 사용자 관리</button>
@@ -229,6 +230,59 @@
</div>
</div>
<!-- Notice List -->
<div id="page-notice" class="page">
<div class="board-header">
<div><h2>📢 공지사항</h2><p style="color:var(--muted);font-size:.875rem;margin-top:2px">관리자가 등록한 공지사항입니다.</p></div>
<button id="notice-write-btn" class="btn btn-sm" onclick="showNoticeWrite()" style="display:none">공지 작성</button>
</div>
<table class="board-table">
<thead><tr><th class="num">번호</th><th>제목</th><th class="author">작성자</th><th class="date">작성일</th></tr></thead>
<tbody id="notice-list-body"></tbody>
</table>
</div>
<!-- Notice Write -->
<div id="page-notice-write" class="page">
<div class="page-header"><div><h2>공지 작성</h2></div></div>
<div class="write-form">
<input type="text" id="notice-write-title" placeholder="제목을 입력하세요"/>
<textarea id="notice-write-content" placeholder="내용을 입력하세요"></textarea>
<div class="write-form-footer">
<button class="btn btn-sm btn-gray" onclick="showPage('notice')">취소</button>
<button class="btn btn-sm" onclick="submitNotice()">등록</button>
</div>
</div>
</div>
<!-- Notice Detail -->
<div id="page-notice-detail" class="page">
<div class="page-header" style="margin-bottom:16px">
<button class="btn btn-sm btn-gray" onclick="showPage('notice')">← 목록으로</button>
</div>
<div class="post-detail">
<div class="post-header">
<h2 id="notice-detail-title"></h2>
<div class="post-meta">
<span>✍️ <strong id="notice-detail-author"></strong></span>
<span>🕐 <span id="notice-detail-date"></span></span>
</div>
</div>
<div class="post-body" id="notice-detail-body"></div>
<div class="post-actions" id="notice-detail-actions"></div>
<div class="replies-section">
<div class="replies-title">💬 댓글</div>
<div id="notice-replies-list"></div>
<div class="reply-form">
<textarea id="notice-reply-input" placeholder="댓글을 입력하세요..."></textarea>
<div class="reply-form-footer">
<button class="btn btn-sm" onclick="submitNoticeReply()">댓글 등록</button>
</div>
</div>
</div>
</div>
</div>
<!-- Board List -->
<div id="page-board" class="page">
<div class="board-header">
@@ -392,6 +446,7 @@ function showPage(name){
if(b.getAttribute('onclick')&&b.getAttribute('onclick').includes("'"+name+"'"))b.classList.add('active');
});
if(name==='my-pages')loadMyPages();
if(name==='notice')loadNotice();
if(name==='board')loadBoard();
if(name==='admin-pages')loadAdminPages();
if(name==='admin-users')loadAdminUsers();
@@ -521,6 +576,105 @@ function getFaviconUrl(url){
}catch{return '';}
}
// ── Notice ───────────────────────────────────────────────
let currentNoticeId=null;
async function loadNotice(){
// 관리자면 작성 버튼 표시
const btn=document.getElementById('notice-write-btn');
if(btn)btn.style.display=currentUser&&currentUser.is_admin?'inline-block':'none';
const posts=await apiFetch('/notice/posts');
const tbody=document.getElementById('notice-list-body');
if(!posts||posts.length===0){
tbody.innerHTML='<tr><td colspan="4" style="text-align:center;padding:40px;color:var(--muted)">등록된 공지가 없습니다.</td></tr>';return;
}
tbody.innerHTML=posts.map((p,i)=>`
<tr onclick="openNotice(${p.id})">
<td class="num">${posts.length-i}</td>
<td class="title-cell">📢 ${escHtml(p.title)}</td>
<td class="author">${escHtml(p.author_name)}</td>
<td class="date">${fmtDate(p.created_at)}</td>
</tr>`).join('');
}
function showNoticeWrite(){
document.getElementById('notice-write-title').value='';
document.getElementById('notice-write-content').value='';
showPage('notice-write');
}
async function submitNotice(){
const title=document.getElementById('notice-write-title').value.trim();
const content=document.getElementById('notice-write-content').value.trim();
if(!title){alert('제목을 입력해주세요.');return;}
if(!content){alert('내용을 입력해주세요.');return;}
try{
await apiFetch('/notice/posts',{method:'POST',body:JSON.stringify({title,content})});
showPage('notice');
}catch(e){alert(e.message);}
}
async function openNotice(id){
currentNoticeId=id;
const data=await apiFetch('/notice/posts/'+id);
const p=data.post;
document.getElementById('notice-detail-title').textContent=p.title;
document.getElementById('notice-detail-author').textContent=p.author_name;
document.getElementById('notice-detail-date').textContent=fmtDate(p.created_at);
document.getElementById('notice-detail-body').textContent=p.content;
document.getElementById('notice-reply-input').value='';
const actions=document.getElementById('notice-detail-actions');
actions.innerHTML=currentUser&&currentUser.is_admin
?`<button class="btn btn-sm btn-danger" onclick="deleteNotice(${p.id})">공지 삭제</button>`:'';
renderNoticeReplies(data.replies);
showPage('notice-detail');
}
function renderNoticeReplies(replies){
const el=document.getElementById('notice-replies-list');
if(!replies||replies.length===0){el.innerHTML='<p style="color:var(--muted);font-size:.85rem;padding:8px 0">아직 댓글이 없습니다.</p>';return;}
el.innerHTML=replies.map(r=>{
const canDel=currentUser&&(currentUser.is_admin||(r.author_name===currentUser.username));
return `<div class="reply-item">
<div class="reply-meta">
<span class="reply-author">💬 ${escHtml(r.author_name)}</span>
<span style="display:flex;align-items:center;gap:8px">
<span class="reply-date">${fmtDate(r.created_at)}</span>
${canDel?`<button class="reply-del" onclick="deleteNoticeReply(${r.id})">삭제</button>`:''}
</span>
</div>
<div class="reply-content">${escHtml(r.content)}</div>
</div>`;
}).join('');
}
async function submitNoticeReply(){
const content=document.getElementById('notice-reply-input').value.trim();
if(!content){alert('댓글 내용을 입력해주세요.');return;}
try{
await apiFetch('/notice/posts/'+currentNoticeId+'/replies',{method:'POST',body:JSON.stringify({content})});
document.getElementById('notice-reply-input').value='';
const data=await apiFetch('/notice/posts/'+currentNoticeId);
renderNoticeReplies(data.replies);
}catch(e){alert(e.message);}
}
async function deleteNotice(id){
if(!confirm('공지를 삭제하시겠습니까?'))return;
try{await apiFetch('/notice/posts/'+id,{method:'DELETE'});showPage('notice');}
catch(e){alert(e.message);}
}
async function deleteNoticeReply(id){
if(!confirm('댓글을 삭제하시겠습니까?'))return;
try{
await apiFetch('/notice/replies/'+id,{method:'DELETE'});
const data=await apiFetch('/notice/posts/'+currentNoticeId);
renderNoticeReplies(data.replies);
}catch(e){alert(e.message);}
}
// ── Board ─────────────────────────────────────────────────
async function loadBoard(){
const posts=await apiFetch('/board/posts');

View File

@@ -1,3 +1,6 @@
# IP당 로그인 요청 제한 (분당 5회)
limit_req_zone $binary_remote_addr zone=login_limit:10m rate=5r/m;
server {
listen 80;
root /usr/share/nginx/html;
@@ -7,6 +10,19 @@ server {
try_files $uri $uri/ /index.html;
}
# 로그인 API - rate limiting 적용
location /api/auth/login {
limit_req zone=login_limit burst=3 nodelay;
limit_req_status 429;
proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/api/auth/login;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
}
location /api/ {
proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/api/;
proxy_set_header Host $host;
@@ -19,4 +35,4 @@ server {
location /health {
proxy_pass http://backend-service.web-portal.svc.cluster.local:8000/health;
}
}
}