1023 lines
53 KiB
HTML
Executable File
1023 lines
53 KiB
HTML
Executable File
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Web Portal</title>
|
|
<style>
|
|
:root {
|
|
--primary:#2563eb;--primary-dark:#1d4ed8;--bg:#f1f5f9;--card:#fff;
|
|
--text:#1e293b;--muted:#64748b;--danger:#ef4444;--success:#22c55e;
|
|
--warning:#f59e0b;--border:#e2e8f0;--admin:#7c3aed;
|
|
}
|
|
*{box-sizing:border-box;margin:0;padding:0;}
|
|
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;}
|
|
|
|
/* Login */
|
|
#login-page{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#1e3a5f,#2563eb);}
|
|
.login-box{background:#fff;border-radius:16px;padding:48px 40px;width:400px;box-shadow:0 25px 60px rgba(0,0,0,.3);}
|
|
.login-logo{text-align:center;margin-bottom:32px;}
|
|
.login-logo h1{font-size:1.5rem;font-weight:700;color:var(--primary);margin-top:12px;}
|
|
.login-logo p{color:var(--muted);font-size:.875rem;margin-top:4px;}
|
|
.form-group{margin-bottom:18px;}
|
|
.form-group label{display:block;font-size:.875rem;font-weight:600;margin-bottom:6px;}
|
|
.form-group input{width:100%;padding:11px 14px;border:2px solid var(--border);border-radius:8px;font-size:.95rem;transition:border-color .2s;}
|
|
.form-group input:focus{outline:none;border-color:var(--primary);}
|
|
.pw-wrap{position:relative;}
|
|
.pw-wrap input{padding-right:44px;}
|
|
.pw-toggle{position:absolute;right:12px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--muted);padding:0;width:auto;line-height:0;}
|
|
.btn{width:100%;padding:12px;background:var(--primary);color:#fff;border:none;border-radius:8px;font-size:1rem;font-weight:600;cursor:pointer;transition:background .2s;}
|
|
.btn:hover{background:var(--primary-dark);}
|
|
.btn-sm{width:auto;padding:7px 16px;font-size:.8rem;border-radius:6px;}
|
|
.btn-danger{background:var(--danger);}.btn-danger:hover{background:#dc2626;}
|
|
.btn-success{background:var(--success);}.btn-success:hover{background:#16a34a;}
|
|
.btn-warning{background:var(--warning);color:#fff;}.btn-warning:hover{background:#d97706;}
|
|
.btn-gray{background:#94a3b8;}.btn-gray:hover{background:#64748b;}
|
|
.btn-purple{background:var(--admin);}.btn-purple:hover{background:#6d28d9;}
|
|
.btn-outline{background:#fff;color:var(--primary);border:2px solid var(--primary);}.btn-outline:hover{background:var(--primary);color:#fff;}
|
|
.error-msg{color:var(--danger);font-size:.85rem;margin-top:10px;text-align:center;}
|
|
|
|
/* Force PW */
|
|
#change-pw-page{display:none;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#1e3a5f,#2563eb);}
|
|
.change-pw-box{background:#fff;border-radius:16px;padding:48px 40px;width:400px;box-shadow:0 25px 60px rgba(0,0,0,.3);}
|
|
.change-pw-box h2{font-size:1.3rem;font-weight:700;margin-bottom:8px;}
|
|
.change-pw-box p{color:var(--muted);font-size:.875rem;margin-bottom:24px;line-height:1.5;}
|
|
.notice-box{background:#fef3c7;border:1px solid #fbbf24;border-radius:8px;padding:12px 16px;margin-bottom:20px;font-size:.85rem;color:#92400e;}
|
|
|
|
/* App */
|
|
#app-page{display:none;min-height:100vh;flex-direction:column;}
|
|
header{background:#fff;border-bottom:1px solid var(--border);padding:0 32px;height:60px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 1px 6px rgba(0,0,0,.06);}
|
|
.header-left{display:flex;align-items:center;gap:12px;}
|
|
.header-logo{font-weight:700;font-size:1.1rem;color:var(--primary);}
|
|
.badge-admin{background:var(--admin);color:#fff;font-size:.7rem;font-weight:700;padding:2px 8px;border-radius:20px;}
|
|
.header-right{display:flex;align-items:center;gap:10px;}
|
|
.user-chip{display:flex;align-items:center;gap:7px;background:var(--bg);border-radius:20px;padding:5px 14px;font-size:.875rem;font-weight:500;}
|
|
nav{background:#fff;border-bottom:1px solid var(--border);padding:0 32px;display:flex;gap:4px;}
|
|
.nav-btn{padding:14px 18px;border:none;background:none;cursor:pointer;font-size:.9rem;color:var(--muted);font-weight:500;border-bottom:3px solid transparent;transition:all .2s;}
|
|
.nav-btn.active{color:var(--primary);border-bottom-color:var(--primary);}
|
|
.nav-btn:hover:not(.active){color:var(--text);}
|
|
main{padding:32px;flex:1;}
|
|
.page{display:none;}.page.active{display:block;}
|
|
|
|
/* Cards */
|
|
.page-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;}
|
|
.page-header h2{font-size:1.3rem;font-weight:700;}
|
|
.page-header p{color:var(--muted);font-size:.875rem;margin-top:2px;}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:20px;}
|
|
.card{background:#fff;border-radius:12px;padding:24px;box-shadow:0 1px 4px rgba(0,0,0,.07);border:1px solid var(--border);transition:transform .15s,box-shadow .15s;cursor:pointer;text-decoration:none;color:inherit;display:flex;flex-direction:column;}
|
|
.card:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.1);}
|
|
.card-icon{width:48px;height:48px;border-radius:10px;display:flex;align-items:center;justify-content:center;margin-bottom:14px;overflow:hidden;background:linear-gradient(135deg,var(--primary),#60a5fa);flex-shrink:0;}
|
|
.card-icon img{width:100%;height:100%;object-fit:contain;}
|
|
.card-icon svg{color:#fff;}
|
|
.card h3{font-size:1rem;font-weight:600;margin-bottom:6px;}
|
|
.card p{font-size:.825rem;color:var(--muted);line-height:1.5;}
|
|
.empty-state{text-align:center;padding:80px 20px;color:var(--muted);}
|
|
|
|
/* Table */
|
|
.table-wrapper{background:#fff;border-radius:12px;border:1px solid var(--border);overflow:hidden;}
|
|
table{width:100%;border-collapse:collapse;}
|
|
th{background:var(--bg);padding:12px 16px;text-align:left;font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;}
|
|
td{padding:13px 16px;font-size:.875rem;border-top:1px solid var(--border);vertical-align:middle;}
|
|
tr:hover td{background:#f8fafc;}
|
|
.actions{display:flex;gap:6px;flex-wrap:wrap;}
|
|
.tag{display:inline-block;padding:2px 10px;border-radius:20px;font-size:.75rem;font-weight:600;}
|
|
.tag-admin{background:#ede9fe;color:var(--admin);}
|
|
.tag-user{background:#dbeafe;color:var(--primary);}
|
|
.tag-locked{background:#fee2e2;color:var(--danger);}
|
|
.tag-warning{background:#fef3c7;color:#92400e;}
|
|
.tag-ok{background:#dcfce7;color:#166534;}
|
|
|
|
/* Board */
|
|
.board-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;}
|
|
.board-table{width:100%;border-collapse:collapse;background:#fff;border-radius:12px;overflow:hidden;border:1px solid var(--border);}
|
|
.board-table th{background:var(--bg);padding:12px 16px;text-align:left;font-size:.8rem;font-weight:600;color:var(--muted);text-transform:uppercase;}
|
|
.board-table td{padding:14px 16px;border-top:1px solid var(--border);font-size:.875rem;vertical-align:middle;}
|
|
.board-table tr:hover td{background:#f8fafc;cursor:pointer;}
|
|
.board-table .num{width:60px;text-align:center;color:var(--muted);}
|
|
.board-table .title-cell{font-weight:500;}
|
|
.board-table .author{width:100px;color:var(--muted);}
|
|
.board-table .date{width:160px;color:var(--muted);font-size:.8rem;}
|
|
|
|
/* Post Detail */
|
|
.post-detail{background:#fff;border-radius:12px;border:1px solid var(--border);overflow:hidden;}
|
|
.post-header{padding:24px 28px;border-bottom:1px solid var(--border);}
|
|
.post-header h2{font-size:1.2rem;font-weight:700;margin-bottom:10px;}
|
|
.post-meta{display:flex;gap:16px;font-size:.82rem;color:var(--muted);}
|
|
.post-body{padding:28px;min-height:160px;line-height:1.8;white-space:pre-wrap;border-bottom:1px solid var(--border);}
|
|
.post-actions{padding:16px 28px;display:flex;gap:8px;}
|
|
.replies-section{padding:0 28px 28px;}
|
|
.replies-title{font-size:.9rem;font-weight:600;color:var(--muted);padding:20px 0 12px;border-bottom:1px solid var(--border);margin-bottom:16px;}
|
|
.reply-item{padding:14px 16px;background:var(--bg);border-radius:8px;margin-bottom:10px;}
|
|
.reply-meta{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
|
.reply-author{font-size:.82rem;font-weight:600;color:var(--primary);}
|
|
.reply-date{font-size:.75rem;color:var(--muted);}
|
|
.reply-content{font-size:.875rem;line-height:1.6;white-space:pre-wrap;}
|
|
.reply-del{font-size:.75rem;color:var(--danger);background:none;border:none;cursor:pointer;padding:0;}
|
|
.reply-form{margin-top:16px;background:var(--bg);border-radius:8px;padding:16px;}
|
|
.reply-form textarea{width:100%;border:2px solid var(--border);border-radius:8px;padding:10px 12px;font-size:.875rem;resize:vertical;min-height:80px;font-family:inherit;}
|
|
.reply-form textarea:focus{outline:none;border-color:var(--primary);}
|
|
.reply-form-footer{display:flex;justify-content:flex-end;margin-top:8px;}
|
|
|
|
/* Write Form */
|
|
.write-form{background:#fff;border-radius:12px;border:1px solid var(--border);padding:28px;}
|
|
.write-form h2{font-size:1.2rem;font-weight:700;margin-bottom:20px;}
|
|
.write-form input[type=text]{width:100%;padding:11px 14px;border:2px solid var(--border);border-radius:8px;font-size:.95rem;margin-bottom:14px;}
|
|
.write-form input[type=text]:focus{outline:none;border-color:var(--primary);}
|
|
.write-form textarea{width:100%;padding:14px;border:2px solid var(--border);border-radius:8px;font-size:.9rem;resize:vertical;min-height:220px;font-family:inherit;line-height:1.6;}
|
|
.write-form textarea:focus{outline:none;border-color:var(--primary);}
|
|
.write-form-footer{display:flex;gap:10px;justify-content:flex-end;margin-top:16px;}
|
|
|
|
/* Modal */
|
|
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100;align-items:center;justify-content:center;}
|
|
.modal-overlay.open{display:flex;}
|
|
.modal{background:#fff;border-radius:16px;padding:32px;width:480px;max-width:95vw;box-shadow:0 30px 80px rgba(0,0,0,.25);max-height:90vh;overflow-y:auto;}
|
|
.modal h3{font-size:1.2rem;font-weight:700;margin-bottom:20px;}
|
|
.modal-footer{display:flex;gap:10px;margin-top:24px;justify-content:flex-end;}
|
|
.modal-footer .btn{width:auto;}
|
|
.checkbox-list{max-height:280px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;}
|
|
.checkbox-item{display:flex;align-items:center;gap:10px;padding:11px 14px;border-bottom:1px solid var(--border);cursor:pointer;}
|
|
.checkbox-item:last-child{border-bottom:none;}
|
|
.checkbox-item:hover{background:var(--bg);}
|
|
.checkbox-item input[type=checkbox]{width:16px;height:16px;accent-color:var(--primary);}
|
|
.checkbox-item .item-info{flex:1;}
|
|
.checkbox-item .item-info strong{font-size:.875rem;display:block;}
|
|
.checkbox-item .item-info span{font-size:.75rem;color:var(--muted);}
|
|
.temp-pw-box{background:#f0fdf4;border:2px solid var(--success);border-radius:10px;padding:20px;margin-top:16px;text-align:center;}
|
|
.temp-pw-box p{font-size:.85rem;color:var(--muted);margin-bottom:8px;}
|
|
.temp-pw-box strong{font-size:1.4rem;font-family:monospace;letter-spacing:2px;}
|
|
.temp-pw-box small{display:block;margin-top:8px;font-size:.75rem;color:var(--danger);}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- LOGIN -->
|
|
<div id="login-page">
|
|
<div class="login-box">
|
|
<div class="login-logo">
|
|
<svg width="56" height="56" viewBox="0 0 56 56" fill="none"><rect width="56" height="56" rx="14" fill="#2563eb"/><path d="M14 20h28M14 28h28M14 36h18" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>
|
|
<h1>Web Portal</h1>
|
|
<p>조직 내부 웹페이지 통합 포털</p>
|
|
</div>
|
|
<div class="form-group"><label>아이디</label><input type="text" id="login-user" placeholder="사용자명 입력"/></div>
|
|
<div class="form-group">
|
|
<label>비밀번호</label>
|
|
<div class="pw-wrap">
|
|
<input type="password" id="login-pass" placeholder="비밀번호 입력" onkeydown="if(event.key==='Enter')doLogin()"/>
|
|
<button type="button" class="pw-toggle" onclick="togglePw('login-pass','eye-login')">
|
|
<svg id="eye-login" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button class="btn" onclick="doLogin()">로그인</button>
|
|
<div id="login-error" class="error-msg"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- FORCE CHANGE PW -->
|
|
<div id="change-pw-page">
|
|
<div class="change-pw-box">
|
|
<h2>🔒 비밀번호 변경 필요</h2>
|
|
<p>보안을 위해 비밀번호를 변경해야 합니다.<br>변경 후 서비스를 이용하실 수 있습니다.</p>
|
|
<div class="notice-box">⚠️ 초기 비밀번호는 반드시 변경해주세요.</div>
|
|
<div class="form-group"><label>현재 비밀번호</label><input type="password" id="force-current-pw" placeholder="현재 비밀번호 입력"/></div>
|
|
<div class="form-group"><label>새 비밀번호</label><input type="password" id="force-new-pw" placeholder="6자 이상 입력"/></div>
|
|
<div class="form-group"><label>새 비밀번호 확인</label><input type="password" id="force-confirm-pw" placeholder="새 비밀번호 재입력" onkeydown="if(event.key==='Enter')doForceChangePw()"/></div>
|
|
<button class="btn" onclick="doForceChangePw()">비밀번호 변경</button>
|
|
<div id="force-pw-error" class="error-msg"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- APP -->
|
|
<div id="app-page">
|
|
<header>
|
|
<div class="header-left">
|
|
<span class="header-logo">🌐 Web Portal</span>
|
|
<span id="admin-badge" class="badge-admin" style="display:none">ADMIN</span>
|
|
</div>
|
|
<div class="header-right">
|
|
<div class="user-chip">
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
|
|
<span id="header-username"></span>
|
|
</div>
|
|
<button class="btn btn-sm btn-outline" onclick="showPage('my-password')">🔑 비밀번호 변경</button>
|
|
<button class="btn btn-sm btn-gray" onclick="doLogout()">로그아웃</button>
|
|
</div>
|
|
</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>
|
|
<button class="nav-btn admin-only" onclick="showPage('admin-notify')" style="display:none">🔔 알림 설정</button>
|
|
</nav>
|
|
<main>
|
|
|
|
<!-- MY Page -->
|
|
<div id="page-my-pages" class="page active">
|
|
<div class="page-header"><div><h2>MY Page List</h2><p>접속 가능한 웹페이지입니다. 클릭하면 새 탭에서 열립니다.</p></div></div>
|
|
<div id="my-pages-grid" class="grid"></div>
|
|
</div>
|
|
|
|
<!-- My Password -->
|
|
<div id="page-my-password" class="page">
|
|
<div class="page-header"><div><h2>비밀번호 변경</h2><p>현재 비밀번호 확인 후 새 비밀번호로 변경합니다.</p></div></div>
|
|
<div style="background:#fff;border-radius:12px;padding:32px;max-width:440px;border:1px solid var(--border)">
|
|
<div class="form-group"><label>현재 비밀번호</label><input type="password" id="my-current-pw" placeholder="현재 비밀번호 입력"/></div>
|
|
<div class="form-group"><label>새 비밀번호</label><input type="password" id="my-new-pw" placeholder="6자 이상 입력"/></div>
|
|
<div class="form-group"><label>새 비밀번호 확인</label><input type="password" id="my-confirm-pw" placeholder="새 비밀번호 재입력"/></div>
|
|
<button class="btn" onclick="doChangePw()">변경하기</button>
|
|
<div id="my-pw-msg" class="error-msg"></div>
|
|
</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">
|
|
<div><h2>📋 관리자 요청</h2><p style="color:var(--muted);font-size:.875rem;margin-top:2px">관리자에게 요청사항을 남길 수 있습니다.</p></div>
|
|
<button class="btn btn-sm" onclick="showBoardWrite()">게시글 작성</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="board-list-body"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Board Write -->
|
|
<div id="page-board-write" class="page">
|
|
<div class="page-header"><div><h2>게시글 작성</h2></div></div>
|
|
<div class="write-form">
|
|
<input type="text" id="write-title" placeholder="제목을 입력하세요"/>
|
|
<textarea id="write-content" placeholder="내용을 입력하세요"></textarea>
|
|
<div class="write-form-footer">
|
|
<button class="btn btn-sm btn-gray" onclick="showPage('board')">취소</button>
|
|
<button class="btn btn-sm" onclick="submitPost()">등록</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Board Detail -->
|
|
<div id="page-board-detail" class="page">
|
|
<div class="page-header" style="margin-bottom:16px">
|
|
<button class="btn btn-sm btn-gray" onclick="showPage('board')">← 목록으로</button>
|
|
</div>
|
|
<div class="post-detail">
|
|
<div class="post-header">
|
|
<h2 id="detail-title"></h2>
|
|
<div class="post-meta">
|
|
<span>✍️ <strong id="detail-author"></strong></span>
|
|
<span>🕐 <span id="detail-date"></span></span>
|
|
</div>
|
|
</div>
|
|
<div class="post-body" id="detail-body"></div>
|
|
<div class="post-actions" id="detail-actions"></div>
|
|
<div class="replies-section">
|
|
<div class="replies-title">💬 답글</div>
|
|
<div id="replies-list"></div>
|
|
<div class="reply-form">
|
|
<textarea id="reply-input" placeholder="답글을 입력하세요..."></textarea>
|
|
<div class="reply-form-footer">
|
|
<button class="btn btn-sm" onclick="submitReply()">답글 등록</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin: Pages -->
|
|
<div id="page-admin-pages" class="page">
|
|
<div class="page-header">
|
|
<div><h2>페이지 관리</h2><p>웹페이지를 추가·수정·삭제합니다.</p></div>
|
|
<button class="btn btn-sm" onclick="openPageModal()">+ 페이지 추가</button>
|
|
</div>
|
|
<div class="table-wrapper">
|
|
<table><thead><tr><th>이름</th><th>URL</th><th>설명</th><th>작업</th></tr></thead>
|
|
<tbody id="pages-table-body"></tbody></table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin: Users -->
|
|
<div id="page-admin-users" class="page">
|
|
<div class="page-header">
|
|
<div><h2>사용자 관리</h2><p>계정 생성, 삭제, 비밀번호 변경, 페이지 접근 권한을 설정합니다.</p></div>
|
|
<button class="btn btn-sm" onclick="openUserModal()">+ 사용자 추가</button>
|
|
</div>
|
|
<div class="table-wrapper">
|
|
<table><thead><tr><th>사용자명</th><th>권한</th><th>상태</th><th>작업</th></tr></thead>
|
|
<tbody id="users-table-body"></tbody></table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin: 알림 설정 -->
|
|
<div id="page-admin-notify" class="page">
|
|
<div class="page-header">
|
|
<div><h2>🔔 알림 설정</h2><p>Discord Webhook 및 이메일 알림 수신 설정을 관리합니다.</p></div>
|
|
<button class="btn btn-sm btn-purple" onclick="testNotify()">📨 테스트 발송</button>
|
|
</div>
|
|
<div class="table-wrapper" style="padding:28px;max-width:640px;">
|
|
<div style="margin-bottom:24px;">
|
|
<h3 style="font-size:1rem;font-weight:700;margin-bottom:16px;display:flex;align-items:center;gap:8px;">
|
|
<span style="background:#5865f2;color:#fff;border-radius:8px;padding:4px 10px;font-size:.85rem;">Discord</span>
|
|
Webhook 설정
|
|
</h3>
|
|
<div class="form-group">
|
|
<label>Webhook URL</label>
|
|
<input type="text" id="nc-discord-url" placeholder="https://discord.com/api/webhooks/..."/>
|
|
<p style="font-size:.78rem;color:var(--muted);margin-top:4px">채널 설정 → 연동 → 웹후크에서 URL을 복사하세요.</p>
|
|
</div>
|
|
</div>
|
|
<div style="border-top:1px solid var(--border);padding-top:24px;margin-bottom:24px;">
|
|
<h3 style="font-size:1rem;font-weight:700;margin-bottom:16px;display:flex;align-items:center;gap:8px;">
|
|
<span style="background:#ea4335;color:#fff;border-radius:8px;padding:4px 10px;font-size:.85rem;">Gmail</span>
|
|
이메일 설정
|
|
</h3>
|
|
<div class="form-group">
|
|
<label>발송 Gmail 계정</label>
|
|
<input type="email" id="nc-gmail-user" placeholder="sender@gmail.com"/>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Gmail 앱 비밀번호</label>
|
|
<div class="pw-wrap">
|
|
<input type="password" id="nc-gmail-pw" placeholder="변경 시에만 입력 (xxxx xxxx xxxx xxxx)"/>
|
|
<button class="pw-toggle" onclick="togglePw('nc-gmail-pw',this)">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
|
</button>
|
|
</div>
|
|
<p style="font-size:.78rem;color:var(--muted);margin-top:4px">myaccount.google.com/apppasswords 에서 발급하세요. 변경하지 않을 경우 비워두세요.</p>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>수신 이메일 주소</label>
|
|
<input type="email" id="nc-alert-to" placeholder="receiver@gmail.com"/>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
|
<button class="btn btn-sm btn-gray" onclick="loadNotifyConfig()">초기화</button>
|
|
<button class="btn btn-sm" onclick="saveNotifyConfig()">💾 저장</button>
|
|
</div>
|
|
<div id="notify-save-msg" style="margin-top:12px;font-size:.85rem;text-align:right;"></div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<!-- MODALS -->
|
|
<div id="page-modal" class="modal-overlay">
|
|
<div class="modal">
|
|
<h3 id="page-modal-title">페이지 추가</h3>
|
|
<div class="form-group"><label>페이지 이름 *</label><input type="text" id="pm-name" placeholder="예: Jenkins CI"/></div>
|
|
<div class="form-group"><label>URL *</label><input type="text" id="pm-url" placeholder="예: http://192.168.1.10:8080"/></div>
|
|
<div class="form-group"><label>설명</label><input type="text" id="pm-desc" placeholder="짧은 설명 (선택)"/></div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-sm btn-gray" onclick="closeModal('page-modal')">취소</button>
|
|
<button class="btn btn-sm" onclick="savePage()">저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="user-modal" class="modal-overlay">
|
|
<div class="modal">
|
|
<h3>사용자 추가</h3>
|
|
<div class="form-group"><label>사용자명 *</label><input type="text" id="um-username" placeholder="예: john"/></div>
|
|
<div class="form-group"><label>초기 비밀번호 *</label><input type="password" id="um-password" placeholder="6자 이상"/></div>
|
|
<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer"><input type="checkbox" id="um-admin" style="width:16px;height:16px;accent-color:var(--admin)"> 관리자 권한 부여</label></div>
|
|
<p style="font-size:.8rem;color:var(--muted)">※ 신규 사용자는 최초 로그인 시 비밀번호 변경이 강제됩니다.</p>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-sm btn-gray" onclick="closeModal('user-modal')">취소</button>
|
|
<button class="btn btn-sm" onclick="saveUser()">저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="access-modal" class="modal-overlay">
|
|
<div class="modal">
|
|
<h3 id="access-modal-title">접근 권한 설정</h3>
|
|
<p style="font-size:.85rem;color:var(--muted);margin-bottom:14px">접근 허용할 페이지를 선택하세요.</p>
|
|
<div id="access-checkbox-list" class="checkbox-list"></div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-sm btn-gray" onclick="closeModal('access-modal')">취소</button>
|
|
<button class="btn btn-sm btn-purple" onclick="saveAccess()">저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="admin-pw-modal" class="modal-overlay">
|
|
<div class="modal">
|
|
<h3 id="admin-pw-modal-title">비밀번호 변경</h3>
|
|
<div class="form-group"><label>새 비밀번호 *</label><input type="password" id="apm-password" placeholder="6자 이상 입력"/></div>
|
|
<div class="form-group"><label>새 비밀번호 확인 *</label><input type="password" id="apm-confirm" placeholder="새 비밀번호 재입력"/></div>
|
|
<p style="font-size:.8rem;color:var(--muted)">※ 변경 후 해당 사용자는 다음 로그인 시 비밀번호 변경이 강제됩니다.</p>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-sm btn-gray" onclick="closeModal('admin-pw-modal')">취소</button>
|
|
<button class="btn btn-sm btn-warning" onclick="saveAdminPw()">변경</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="reset-pw-modal" class="modal-overlay">
|
|
<div class="modal">
|
|
<h3>임시 비밀번호 발급 완료</h3>
|
|
<div class="temp-pw-box">
|
|
<p>아래 임시 비밀번호를 사용자에게 전달하세요.</p>
|
|
<strong id="temp-pw-display"></strong>
|
|
<small>⚠️ 이 창을 닫으면 다시 확인할 수 없습니다!</small>
|
|
</div>
|
|
<div class="modal-footer"><button class="btn btn-sm" onclick="closeModal('reset-pw-modal');loadAdminUsers()">확인</button></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API='/api';
|
|
let token=localStorage.getItem('portal_token');
|
|
let currentUser=null;
|
|
let editingPageId=null;
|
|
let editingUserId=null;
|
|
let currentPostId=null;
|
|
|
|
async function apiFetch(path,opts={}){
|
|
const res=await fetch(API+path,{headers:{'Content-Type':'application/json',...(token?{Authorization:'Bearer '+token}:{})}, ...opts});
|
|
if(res.status===401){doLogout();return;}
|
|
if(!res.ok){const e=await res.json().catch(()=>({}));throw new Error(e.detail||'Error');}
|
|
return res.json();
|
|
}
|
|
|
|
function showPage(name){
|
|
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
|
|
document.querySelectorAll('.nav-btn').forEach(b=>b.classList.remove('active'));
|
|
document.getElementById('page-'+name).classList.add('active');
|
|
document.querySelectorAll('.nav-btn').forEach(b=>{
|
|
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();
|
|
if(name==='admin-notify')loadNotifyConfig();
|
|
}
|
|
|
|
function closeModal(id){document.getElementById(id).classList.remove('open');}
|
|
function openModal(id){document.getElementById(id).classList.add('open');}
|
|
|
|
// ── Login ─────────────────────────────────────────────────
|
|
async function doLogin(){
|
|
const username=document.getElementById('login-user').value.trim();
|
|
const password=document.getElementById('login-pass').value;
|
|
document.getElementById('login-error').textContent='';
|
|
try{
|
|
const data=await apiFetch('/auth/login',{method:'POST',body:JSON.stringify({username,password})});
|
|
token=data.token;
|
|
localStorage.setItem('portal_token',token);
|
|
currentUser={username:data.username,is_admin:data.is_admin,must_change_password:data.must_change_password};
|
|
if(data.must_change_password)showForceChangePw();
|
|
else showApp();
|
|
}catch(e){
|
|
document.getElementById('login-error').textContent=e.message;
|
|
document.getElementById('login-pass').value='';
|
|
}
|
|
}
|
|
|
|
function doLogout(){
|
|
token=null;currentUser=null;
|
|
localStorage.removeItem('portal_token');
|
|
document.getElementById('app-page').style.display='none';
|
|
document.getElementById('change-pw-page').style.display='none';
|
|
document.getElementById('login-page').style.display='flex';
|
|
document.getElementById('login-user').value='';
|
|
document.getElementById('login-pass').value='';
|
|
}
|
|
|
|
function showForceChangePw(){
|
|
document.getElementById('login-page').style.display='none';
|
|
document.getElementById('change-pw-page').style.display='flex';
|
|
}
|
|
|
|
async function doForceChangePw(){
|
|
const current=document.getElementById('force-current-pw').value;
|
|
const newPw=document.getElementById('force-new-pw').value;
|
|
const confirm=document.getElementById('force-confirm-pw').value;
|
|
const errEl=document.getElementById('force-pw-error');
|
|
errEl.textContent='';
|
|
if(newPw.length<6){errEl.textContent='새 비밀번호는 6자 이상이어야 합니다.';return;}
|
|
if(newPw!==confirm){errEl.textContent='새 비밀번호가 일치하지 않습니다.';return;}
|
|
try{
|
|
const data=await apiFetch('/auth/change-password',{method:'POST',body:JSON.stringify({current_password:current,new_password:newPw})});
|
|
token=data.token;localStorage.setItem('portal_token',token);
|
|
currentUser.must_change_password=false;
|
|
document.getElementById('change-pw-page').style.display='none';
|
|
showApp();
|
|
}catch(e){errEl.textContent=e.message;}
|
|
}
|
|
|
|
function showApp(){
|
|
document.getElementById('login-page').style.display='none';
|
|
document.getElementById('change-pw-page').style.display='none';
|
|
document.getElementById('app-page').style.display='flex';
|
|
document.getElementById('header-username').textContent=currentUser.username;
|
|
document.getElementById('admin-badge').style.display=currentUser.is_admin?'inline':'none';
|
|
document.querySelectorAll('.admin-only').forEach(el=>{el.style.display=currentUser.is_admin?'inline-block':'none';});
|
|
showPage('my-pages');
|
|
}
|
|
|
|
function togglePw(inputId,iconId){
|
|
const input=document.getElementById(inputId);
|
|
const icon=document.getElementById(iconId);
|
|
if(input.type==='password'){
|
|
input.type='text';
|
|
icon.innerHTML='<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/>';
|
|
}else{
|
|
input.type='password';
|
|
icon.innerHTML='<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>';
|
|
}
|
|
}
|
|
|
|
// ── My Password ───────────────────────────────────────────
|
|
async function doChangePw(){
|
|
const current=document.getElementById('my-current-pw').value;
|
|
const newPw=document.getElementById('my-new-pw').value;
|
|
const confirm=document.getElementById('my-confirm-pw').value;
|
|
const msgEl=document.getElementById('my-pw-msg');
|
|
msgEl.style.color='var(--danger)';msgEl.textContent='';
|
|
if(newPw.length<6){msgEl.textContent='새 비밀번호는 6자 이상이어야 합니다.';return;}
|
|
if(newPw!==confirm){msgEl.textContent='새 비밀번호가 일치하지 않습니다.';return;}
|
|
try{
|
|
const data=await apiFetch('/auth/change-password',{method:'POST',body:JSON.stringify({current_password:current,new_password:newPw})});
|
|
token=data.token;localStorage.setItem('portal_token',token);
|
|
msgEl.style.color='var(--success)';msgEl.textContent='✅ 비밀번호가 성공적으로 변경되었습니다.';
|
|
document.getElementById('my-current-pw').value='';
|
|
document.getElementById('my-new-pw').value='';
|
|
document.getElementById('my-confirm-pw').value='';
|
|
}catch(e){msgEl.textContent=e.message;}
|
|
}
|
|
|
|
// ── MY Page List ──────────────────────────────────────────
|
|
async function loadMyPages(){
|
|
const pages=await apiFetch('/my-pages');
|
|
const grid=document.getElementById('my-pages-grid');
|
|
if(!pages||pages.length===0){
|
|
grid.innerHTML='<div class="empty-state" style="grid-column:1/-1"><p>접근 가능한 페이지가 없습니다.<br>관리자에게 문의하세요.</p></div>';return;
|
|
}
|
|
grid.innerHTML=pages.map(p=>{
|
|
const faviconUrl=getFaviconUrl(p.url);
|
|
return `<a class="card" href="${escHtml(p.url)}" target="_blank" rel="noopener">
|
|
<div class="card-icon">
|
|
<img src="${faviconUrl}" alt=""
|
|
onload="this.style.padding='4px'"
|
|
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"
|
|
style="width:100%;height:100%;object-fit:contain;"/>
|
|
<svg style="display:none;width:100%;height:100%;align-items:center;justify-content:center" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 010 20M12 2a15 15 0 000 20"/></svg>
|
|
</div>
|
|
<h3>${escHtml(p.name)}</h3>
|
|
<p>${escHtml(p.description||'설명 없음')}</p>
|
|
</a>`;
|
|
}).join('');
|
|
}
|
|
|
|
function getFaviconUrl(url){
|
|
try{
|
|
const u=new URL(url);
|
|
return u.origin+'/favicon.ico';
|
|
}catch{return '';}
|
|
}
|
|
|
|
// ── Notice ───────────────────────────────────────────────
|
|
let currentNoticeId=null;
|
|
|
|
async function loadNotice(){
|
|
// 관리자면 작성 버튼 표시
|
|
const btn=document.getElementById('notice-write-btn');
|
|
if(btn)btn.style.display=currentUser&¤tUser.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&¤tUser.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');
|
|
const tbody=document.getElementById('board-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="openPost(${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 showBoardWrite(){
|
|
document.getElementById('write-title').value='';
|
|
document.getElementById('write-content').value='';
|
|
showPage('board-write');
|
|
}
|
|
|
|
async function submitPost(){
|
|
const title=document.getElementById('write-title').value.trim();
|
|
const content=document.getElementById('write-content').value.trim();
|
|
if(!title){alert('제목을 입력해주세요.');return;}
|
|
if(!content){alert('내용을 입력해주세요.');return;}
|
|
try{
|
|
await apiFetch('/board/posts',{method:'POST',body:JSON.stringify({title,content})});
|
|
showPage('board');
|
|
}catch(e){alert(e.message);}
|
|
}
|
|
|
|
async function openPost(id){
|
|
currentPostId=id;
|
|
const data=await apiFetch('/board/posts/'+id);
|
|
const p=data.post;
|
|
document.getElementById('detail-title').textContent=p.title;
|
|
document.getElementById('detail-author').textContent=p.author_name;
|
|
document.getElementById('detail-date').textContent=fmtDate(p.created_at);
|
|
document.getElementById('detail-body').textContent=p.content;
|
|
document.getElementById('reply-input').value='';
|
|
|
|
// 삭제 버튼 (본인 or 관리자)
|
|
const actions=document.getElementById('detail-actions');
|
|
const canDelete=currentUser.is_admin||(p.author_name===currentUser.username);
|
|
actions.innerHTML=canDelete?`<button class="btn btn-sm btn-danger" onclick="deletePost(${p.id})">게시글 삭제</button>`:'';
|
|
|
|
// 답글 렌더링
|
|
renderReplies(data.replies);
|
|
showPage('board-detail');
|
|
}
|
|
|
|
function renderReplies(replies){
|
|
const el=document.getElementById('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.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="deleteReply(${r.id})">삭제</button>`:''}
|
|
</span>
|
|
</div>
|
|
<div class="reply-content">${escHtml(r.content)}</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
async function submitReply(){
|
|
const content=document.getElementById('reply-input').value.trim();
|
|
if(!content){alert('답글 내용을 입력해주세요.');return;}
|
|
try{
|
|
await apiFetch('/board/posts/'+currentPostId+'/replies',{method:'POST',body:JSON.stringify({content})});
|
|
document.getElementById('reply-input').value='';
|
|
const data=await apiFetch('/board/posts/'+currentPostId);
|
|
renderReplies(data.replies);
|
|
}catch(e){alert(e.message);}
|
|
}
|
|
|
|
async function deletePost(id){
|
|
if(!confirm('게시글을 삭제하시겠습니까?'))return;
|
|
try{await apiFetch('/board/posts/'+id,{method:'DELETE'});showPage('board');}
|
|
catch(e){alert(e.message);}
|
|
}
|
|
|
|
async function deleteReply(id){
|
|
if(!confirm('답글을 삭제하시겠습니까?'))return;
|
|
try{
|
|
await apiFetch('/board/replies/'+id,{method:'DELETE'});
|
|
const data=await apiFetch('/board/posts/'+currentPostId);
|
|
renderReplies(data.replies);
|
|
}catch(e){alert(e.message);}
|
|
}
|
|
|
|
// ── Admin: Pages ──────────────────────────────────────────
|
|
async function loadAdminPages(){
|
|
const pages=await apiFetch('/admin/webpages');
|
|
const tbody=document.getElementById('pages-table-body');
|
|
tbody.innerHTML=pages.map(p=>`
|
|
<tr>
|
|
<td><strong>${escHtml(p.name)}</strong></td>
|
|
<td><a href="${escHtml(p.url)}" target="_blank" style="color:var(--primary)">${escHtml(p.url)}</a></td>
|
|
<td style="color:var(--muted)">${escHtml(p.description||'-')}</td>
|
|
<td><div class="actions">
|
|
<button class="btn btn-sm btn-gray" onclick="openPageModal(${p.id},'${escJs(p.name)}','${escJs(p.url)}','${escJs(p.description||'')}')">수정</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deletePage(${p.id})">삭제</button>
|
|
</div></td>
|
|
</tr>`).join('')||'<tr><td colspan="4" style="text-align:center;color:var(--muted);padding:40px">등록된 페이지가 없습니다.</td></tr>';
|
|
}
|
|
|
|
function openPageModal(id,name,url,desc){
|
|
editingPageId=id||null;
|
|
document.getElementById('page-modal-title').textContent=id?'페이지 수정':'페이지 추가';
|
|
document.getElementById('pm-name').value=name||'';
|
|
document.getElementById('pm-url').value=url||'';
|
|
document.getElementById('pm-desc').value=desc||'';
|
|
openModal('page-modal');
|
|
}
|
|
|
|
async function savePage(){
|
|
const body={name:document.getElementById('pm-name').value.trim(),url:document.getElementById('pm-url').value.trim(),description:document.getElementById('pm-desc').value.trim()};
|
|
if(!body.name||!body.url){alert('이름과 URL은 필수입니다.');return;}
|
|
try{
|
|
if(editingPageId)await apiFetch('/admin/webpages/'+editingPageId,{method:'PUT',body:JSON.stringify(body)});
|
|
else await apiFetch('/admin/webpages',{method:'POST',body:JSON.stringify(body)});
|
|
closeModal('page-modal');loadAdminPages();
|
|
}catch(e){alert(e.message);}
|
|
}
|
|
|
|
async function deletePage(id){
|
|
if(!confirm('이 페이지를 삭제하시겠습니까?'))return;
|
|
await apiFetch('/admin/webpages/'+id,{method:'DELETE'});loadAdminPages();
|
|
}
|
|
|
|
// ── Admin: Users ──────────────────────────────────────────
|
|
async function loadAdminUsers(){
|
|
const users=await apiFetch('/admin/users');
|
|
const tbody=document.getElementById('users-table-body');
|
|
tbody.innerHTML=users.map(u=>{
|
|
const tags=[];
|
|
if(u.is_locked)tags.push('<span class="tag tag-locked">🔒 잠김</span>');
|
|
else tags.push('<span class="tag tag-ok">정상</span>');
|
|
if(u.must_change_password)tags.push('<span class="tag tag-warning">초기PW</span>');
|
|
if(u.password_change_requested)tags.push('<span class="tag tag-locked">변경요청</span>');
|
|
const acts=[];
|
|
if(!u.is_admin)acts.push(`<button class="btn btn-sm btn-purple" onclick="openAccessModal(${u.id},'${escJs(u.username)}')">권한</button>`);
|
|
if(u.username!=='admin'){
|
|
acts.push(`<button class="btn btn-sm btn-warning" onclick="openAdminPwModal(${u.id},'${escJs(u.username)}')">PW변경</button>`);
|
|
acts.push(`<button class="btn btn-sm btn-gray" onclick="resetPassword(${u.id},'${escJs(u.username)}')">임시PW</button>`);
|
|
}
|
|
if(u.is_locked)acts.push(`<button class="btn btn-sm btn-success" onclick="unlockUser(${u.id})">잠금해제</button>`);
|
|
if(u.username!=='admin')acts.push(`<button class="btn btn-sm btn-danger" onclick="deleteUser(${u.id})">삭제</button>`);
|
|
return `<tr><td><strong>${escHtml(u.username)}</strong></td>
|
|
<td><span class="tag ${u.is_admin?'tag-admin':'tag-user'}">${u.is_admin?'관리자':'일반사용자'}</span></td>
|
|
<td>${tags.join(' ')}</td>
|
|
<td><div class="actions">${acts.join('')}</div></td></tr>`;
|
|
}).join('')||'<tr><td colspan="4" style="text-align:center;color:var(--muted);padding:40px">사용자가 없습니다.</td></tr>';
|
|
}
|
|
|
|
function openUserModal(){
|
|
document.getElementById('um-username').value='';
|
|
document.getElementById('um-password').value='';
|
|
document.getElementById('um-admin').checked=false;
|
|
openModal('user-modal');
|
|
}
|
|
|
|
async function saveUser(){
|
|
const body={username:document.getElementById('um-username').value.trim(),password:document.getElementById('um-password').value,is_admin:document.getElementById('um-admin').checked};
|
|
if(!body.username||!body.password){alert('사용자명과 비밀번호는 필수입니다.');return;}
|
|
try{await apiFetch('/admin/users',{method:'POST',body:JSON.stringify(body)});closeModal('user-modal');loadAdminUsers();}
|
|
catch(e){alert(e.message);}
|
|
}
|
|
|
|
async function deleteUser(id){
|
|
if(!confirm('이 사용자를 삭제하시겠습니까?'))return;
|
|
await apiFetch('/admin/users/'+id,{method:'DELETE'});loadAdminUsers();
|
|
}
|
|
|
|
function openAdminPwModal(userId,username){
|
|
editingUserId=userId;
|
|
document.getElementById('admin-pw-modal-title').textContent=`'${username}' 비밀번호 변경`;
|
|
document.getElementById('apm-password').value='';
|
|
document.getElementById('apm-confirm').value='';
|
|
openModal('admin-pw-modal');
|
|
}
|
|
|
|
async function saveAdminPw(){
|
|
const pw=document.getElementById('apm-password').value;
|
|
const confirm=document.getElementById('apm-confirm').value;
|
|
if(pw.length<6){alert('비밀번호는 6자 이상이어야 합니다.');return;}
|
|
if(pw!==confirm){alert('비밀번호가 일치하지 않습니다.');return;}
|
|
try{
|
|
await apiFetch('/admin/users/'+editingUserId+'/password',{method:'PUT',body:JSON.stringify({new_password:pw})});
|
|
closeModal('admin-pw-modal');alert('비밀번호가 변경되었습니다.');loadAdminUsers();
|
|
}catch(e){alert(e.message);}
|
|
}
|
|
|
|
async function resetPassword(userId,username){
|
|
if(!confirm(`'${username}' 의 임시 비밀번호를 발급하시겠습니까?`))return;
|
|
try{
|
|
const data=await apiFetch('/admin/users/'+userId+'/reset-password',{method:'POST'});
|
|
document.getElementById('temp-pw-display').textContent=data.temp_password;
|
|
openModal('reset-pw-modal');
|
|
}catch(e){alert(e.message);}
|
|
}
|
|
|
|
async function unlockUser(userId){
|
|
if(!confirm('이 계정의 잠금을 해제하시겠습니까?'))return;
|
|
await apiFetch('/admin/users/'+userId+'/unlock',{method:'POST'});loadAdminUsers();
|
|
}
|
|
|
|
async function openAccessModal(userId,username){
|
|
editingUserId=userId;
|
|
document.getElementById('access-modal-title').textContent=`'${username}' 접근 권한 설정`;
|
|
const pages=await apiFetch('/admin/users/'+userId+'/pages');
|
|
const list=document.getElementById('access-checkbox-list');
|
|
list.innerHTML=pages.map(p=>`
|
|
<label class="checkbox-item">
|
|
<input type="checkbox" value="${p.id}" ${p.has_access?'checked':''}>
|
|
<div class="item-info"><strong>${escHtml(p.name)}</strong><span>${escHtml(p.url)}</span></div>
|
|
</label>`).join('')||'<div style="padding:20px;text-align:center;color:var(--muted)">등록된 페이지가 없습니다.</div>';
|
|
openModal('access-modal');
|
|
}
|
|
|
|
async function saveAccess(){
|
|
const checked=[...document.querySelectorAll('#access-checkbox-list input:checked')].map(i=>parseInt(i.value));
|
|
await apiFetch('/admin/users/'+editingUserId+'/pages',{method:'PUT',body:JSON.stringify({webpage_ids:checked})});
|
|
closeModal('access-modal');alert('권한이 저장되었습니다.');
|
|
}
|
|
|
|
// ── 알림 설정 ──────────────────────────────────────────────
|
|
async function loadNotifyConfig(){
|
|
try{
|
|
const data = await apiFetch('/admin/notify-config');
|
|
document.getElementById('nc-discord-url').value = data.discord_webhook_url || '';
|
|
document.getElementById('nc-gmail-user').value = data.gmail_user || '';
|
|
document.getElementById('nc-gmail-pw').value = data.gmail_app_password || '';
|
|
document.getElementById('nc-alert-to').value = data.alert_email_to || '';
|
|
document.getElementById('notify-save-msg').textContent = '';
|
|
}catch(e){alert('알림 설정 불러오기 실패: '+e.message);}
|
|
}
|
|
|
|
async function saveNotifyConfig(){
|
|
const msgEl = document.getElementById('notify-save-msg');
|
|
const payload = {
|
|
discord_webhook_url: document.getElementById('nc-discord-url').value.trim(),
|
|
gmail_user: document.getElementById('nc-gmail-user').value.trim(),
|
|
gmail_app_password: document.getElementById('nc-gmail-pw').value,
|
|
alert_email_to: document.getElementById('nc-alert-to').value.trim(),
|
|
};
|
|
try{
|
|
await apiFetch('/admin/notify-config',{method:'PUT',body:JSON.stringify(payload)});
|
|
msgEl.style.color = 'var(--success)';
|
|
msgEl.textContent = '✅ 설정이 저장되었습니다.';
|
|
}catch(e){
|
|
msgEl.style.color = 'var(--danger)';
|
|
msgEl.textContent = '❌ 저장 실패: '+e.message;
|
|
}
|
|
}
|
|
|
|
async function testNotify(){
|
|
try{
|
|
await apiFetch('/admin/notify-test');
|
|
alert('✅ 테스트 알림이 발송되었습니다.\nDiscord와 이메일을 확인해주세요.');
|
|
}catch(e){alert('테스트 실패: '+e.message);}
|
|
}
|
|
|
|
// ── Utils ─────────────────────────────────────────────────
|
|
function escHtml(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
function escJs(s){return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'");}
|
|
function fmtDate(s){
|
|
if(!s)return '';
|
|
const d=new Date(s);
|
|
return d.toLocaleDateString('ko-KR',{year:'numeric',month:'2-digit',day:'2-digit'})+' '+
|
|
d.toLocaleTimeString('ko-KR',{hour:'2-digit',minute:'2-digit'});
|
|
}
|
|
|
|
// ── Init ──────────────────────────────────────────────────
|
|
(async function init(){
|
|
if(token){
|
|
try{
|
|
currentUser=await apiFetch('/auth/me');
|
|
if(currentUser.must_change_password)showForceChangePw();
|
|
else showApp();
|
|
}catch{doLogout();}
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|