관리자 페이지 추가 및 버그 수정

- 관리자 대시보드 추가 (/admin/)
  - 통계: 총 포스트, 공개/비공개, 삭제된 포스트, 회원 수
- 포스트 관리 추가 (/admin/posts)
  - 목록, 검색, 필터링, 페이지네이션
  - 포스트 수정, 삭제, 복구 기능
- 회원 관리 추가 (/admin/members)
  - 회원 목록, 추가, 수정, 삭제
  - 비밀번호 재설정
- 버그 수정
  - g.is_login, g.user_info 기본값 설정
  - index 페이지 빈 포스트 처리
- 관리자 권한: admin, wixon, javamon
- README.md 프로젝트 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
javamon117
2026-01-05 15:30:49 +09:00
parent 73a13852bf
commit d49a33cc6c
10 changed files with 1407 additions and 4 deletions

111
README.md
View File

@@ -1,2 +1,109 @@
"# wixon_blog" "# wixon_blog"
"# wixon_blog"
# WIXON Blog
Flask 기반 블로그 애플리케이션입니다.
## 프로젝트 구조
```
wixon_blog/
├── app.py # Flask 메인 애플리케이션
├── templates/ # Jinja2 템플릿
│ ├── base.html # 기본 레이아웃
│ ├── index.html # 메인 페이지
│ ├── login.html # 로그인 페이지
│ ├── post.html # 포스트 상세
│ ├── write.html # 포스트 작성
│ ├── edit_post.html # 포스트 수정
│ └── admin/ # 관리자 페이지
│ ├── base_admin.html # 관리자 베이스 템플릿
│ ├── dashboard.html # 대시보드
│ ├── posts.html # 포스트 관리
│ ├── post_detail.html # 포스트 수정
│ ├── members.html # 회원 목록
│ └── member_form.html # 회원 추가/수정
├── static/
│ ├── css/
│ │ ├── style.css # 메인 스타일
│ │ └── admin.css # 관리자 스타일
│ ├── images/ # 이미지 파일
│ ├── font/ # 웹 폰트
│ └── upload/img/ # 업로드 이미지
└── README.md
```
## 기술 스택
- **Backend**: Flask (Python)
- **Database**: MySQL
- **Frontend**: UIKit 3.6.16, jQuery, Summernote Editor
- **인증**: bcrypt 암호화, Session 기반
## 주요 기능
### 사용자 기능
- 블로그 포스트 조회
- 로그인/로그아웃
- 포스트 작성 (로그인 필요)
- 포스트 수정/삭제 (작성자 또는 관리자)
### 관리자 기능 (`/admin/`)
- **대시보드**: 통계 (총 포스트, 공개/비공개, 회원 수)
- **포스트 관리**: 목록, 검색, 필터링, 수정, 삭제, 복구
- **회원 관리**: 목록, 추가, 수정, 삭제, 비밀번호 재설정
## URL 구조
| URL | 설명 |
|-----|------|
| `/` | 메인 페이지 |
| `/login` | 로그인 |
| `/logout` | 로그아웃 |
| `/post/<id>` | 포스트 상세 |
| `/write` | 포스트 작성 |
| `/edit_post/<id>` | 포스트 수정 |
| `/admin/` | 관리자 대시보드 |
| `/admin/posts` | 포스트 관리 |
| `/admin/members` | 회원 관리 |
## 실행 방법
```bash
# 의존성 설치
pip install flask pymysql bcrypt beautifulsoup4 markupsafe
# 앱 실행
python3 app.py
```
서버가 `http://0.0.0.0:8899`에서 실행됩니다.
## 데이터베이스 테이블
### blog
| 컬럼 | 설명 |
|------|------|
| id | PK |
| user_id | 작성자 ID (FK) |
| title | 제목 |
| category | 카테고리 (IT, NEWS, ETC) |
| contents | 내용 (HTML) |
| thumbnail_img | 썸네일 URL |
| public_yn | 공개 여부 (Y/N) |
| use_yn | 사용 여부 (Y/N, 소프트 삭제) |
| add_date | 작성일 |
### member
| 컬럼 | 설명 |
|------|------|
| mb_idx | PK |
| mb_id | 로그인 ID |
| mb_passwd | 비밀번호 (bcrypt) |
| mb_name | 이름 |
## 관리자 계정
- 관리자 권한: `admin`, `wixon`, `javamon` 아이디 보유자
## 라이선스
WIXON Associates Inc.

249
app.py
View File

@@ -9,6 +9,8 @@ import uuid
import re
from markupsafe import Markup
from jinja2 import filters
from functools import wraps
import math
UPLOAD_FOLDER = 'static/upload/img' # 경로를 Flask 앱 루트 기준으로 수정
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
@@ -25,6 +27,8 @@ def remove_html_tags(text):
@app.before_request
def load_user():
g.is_login = False
g.user_info = None
if 'user_info' in session:
g.is_login = True
g.user_info = session['user_info']
@@ -110,6 +114,7 @@ def index():
r, posts = sql_execute(query, (), is_data=True)
r, random_post = sql_execute(r_query, (), is_data=True)
if random_post:
posts.append(random_post[0])
# 태그 제거 후 150자로 제한
@@ -261,5 +266,249 @@ def delete_post(id):
return jsonify(success=False, message='Could not delete the post'), 500
# ============================================
# 관리자 페이지
# ============================================
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'username' not in session or session['username'] not in ['admin', 'wixon', 'javamon']:
flash('관리자 권한이 필요합니다.')
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
@app.route('/admin/')
@admin_required
def admin_dashboard():
stats = {
'total_posts': 0,
'public_posts': 0,
'private_posts': 0,
'deleted_posts': 0,
'total_members': 0
}
r, data = sql_execute("SELECT COUNT(*) as cnt FROM blog WHERE use_yn='Y'", (), is_data=True)
if r and data:
stats['total_posts'] = data[0]['cnt']
r, data = sql_execute("SELECT COUNT(*) as cnt FROM blog WHERE use_yn='Y' AND public_yn='Y'", (), is_data=True)
if r and data:
stats['public_posts'] = data[0]['cnt']
r, data = sql_execute("SELECT COUNT(*) as cnt FROM blog WHERE use_yn='Y' AND public_yn='N'", (), is_data=True)
if r and data:
stats['private_posts'] = data[0]['cnt']
r, data = sql_execute("SELECT COUNT(*) as cnt FROM blog WHERE use_yn='N'", (), is_data=True)
if r and data:
stats['deleted_posts'] = data[0]['cnt']
r, data = sql_execute("SELECT COUNT(*) as cnt FROM member", (), is_data=True)
if r and data:
stats['total_members'] = data[0]['cnt']
return render_template('admin/dashboard.html', stats=stats)
@app.route('/admin/posts')
@admin_required
def admin_posts():
page = request.args.get('page', 1, type=int)
per_page = 20
offset = (page - 1) * per_page
category = request.args.get('category', '')
public_yn = request.args.get('public_yn', '')
use_yn = request.args.get('use_yn', 'Y')
search = request.args.get('search', '')
where_clauses = []
params = []
if category:
where_clauses.append("blog.category = %s")
params.append(category)
if public_yn:
where_clauses.append("blog.public_yn = %s")
params.append(public_yn)
if use_yn:
where_clauses.append("blog.use_yn = %s")
params.append(use_yn)
if search:
where_clauses.append("blog.title LIKE %s")
params.append(f"%{search}%")
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
count_query = f"SELECT COUNT(*) as cnt FROM blog INNER JOIN member ON blog.user_id = member.mb_idx WHERE {where_sql}"
r, count_data = sql_execute(count_query, tuple(params), is_data=True)
total = count_data[0]['cnt'] if r and count_data else 0
total_pages = math.ceil(total / per_page) if total > 0 else 1
query = f"""
SELECT blog.*, member.mb_name, member.mb_id
FROM blog
INNER JOIN member ON blog.user_id = member.mb_idx
WHERE {where_sql}
ORDER BY blog.add_date DESC
LIMIT %s OFFSET %s
"""
params.extend([per_page, offset])
r, posts = sql_execute(query, tuple(params), is_data=True)
pagination = {
'page': page,
'per_page': per_page,
'total': total,
'total_pages': total_pages,
'has_prev': page > 1,
'has_next': page < total_pages,
'prev_num': page - 1,
'next_num': page + 1
}
return render_template('admin/posts.html', posts=posts if posts else [], pagination=pagination)
@app.route('/admin/posts/<int:post_id>', methods=['GET', 'POST'])
@admin_required
def admin_post_detail(post_id):
if request.method == 'POST':
title = request.form.get('title')
category = request.form.get('category')
public_yn = 'Y' if request.form.get('public') == 'on' else 'N'
contents = request.form.get('contents')
soup = BeautifulSoup(contents, 'html.parser')
first_image = soup.find('img')
thumbnail_img = first_image['src'] if first_image else None
query = "UPDATE blog SET title=%s, category=%s, public_yn=%s, contents=%s, thumbnail_img=%s WHERE id=%s"
res = sql_execute(query, (title, category, public_yn, contents, thumbnail_img, post_id))
if res:
flash('포스트가 수정되었습니다.')
return redirect(url_for('admin_posts'))
else:
flash('포스트 수정에 실패했습니다.')
query = "SELECT blog.*, member.mb_name, member.mb_id FROM blog INNER JOIN member ON blog.user_id = member.mb_idx WHERE blog.id = %s"
r, data = sql_execute(query, (post_id,), is_data=True)
if r and data:
return render_template('admin/post_detail.html', post=data[0])
else:
flash('포스트를 찾을 수 없습니다.')
return redirect(url_for('admin_posts'))
@app.route('/admin/posts/<int:post_id>/delete', methods=['DELETE'])
@admin_required
def admin_post_delete(post_id):
query = "UPDATE blog SET use_yn='N' WHERE id=%s"
res = sql_execute(query, (post_id,))
if res:
return jsonify(success=True, message='포스트가 삭제되었습니다.')
return jsonify(success=False, message='삭제 실패'), 500
@app.route('/admin/posts/<int:post_id>/restore', methods=['POST'])
@admin_required
def admin_post_restore(post_id):
query = "UPDATE blog SET use_yn='Y' WHERE id=%s"
res = sql_execute(query, (post_id,))
if res:
return jsonify(success=True, message='포스트가 복구되었습니다.')
return jsonify(success=False, message='복구 실패'), 500
@app.route('/admin/members')
@admin_required
def admin_members():
query = "SELECT mb_idx, mb_id, mb_name FROM member ORDER BY mb_idx DESC"
r, members = sql_execute(query, (), is_data=True)
return render_template('admin/members.html', members=members if members else [])
@app.route('/admin/members/add', methods=['GET', 'POST'])
@admin_required
def admin_member_add():
if request.method == 'POST':
mb_id = request.form.get('mb_id')
mb_name = request.form.get('mb_name')
password = request.form.get('password').encode('utf-8')
hashed_pw = bcrypt.hashpw(password, bcrypt.gensalt()).decode('utf-8')
r, existing = sql_execute("SELECT mb_id FROM member WHERE mb_id=%s", (mb_id,), is_data=True)
if r and existing:
flash('이미 존재하는 아이디입니다.')
return render_template('admin/member_form.html', member=None)
query = "INSERT INTO member (mb_id, mb_passwd, mb_name) VALUES (%s, %s, %s)"
res = sql_execute(query, (mb_id, hashed_pw, mb_name))
if res:
flash('회원이 추가되었습니다.')
return redirect(url_for('admin_members'))
flash('회원 추가에 실패했습니다.')
return render_template('admin/member_form.html', member=None)
@app.route('/admin/members/<int:mb_idx>', methods=['GET', 'POST'])
@admin_required
def admin_member_detail(mb_idx):
if request.method == 'POST':
mb_name = request.form.get('mb_name')
query = "UPDATE member SET mb_name=%s WHERE mb_idx=%s"
res = sql_execute(query, (mb_name, mb_idx))
if res:
flash('회원 정보가 수정되었습니다.')
return redirect(url_for('admin_members'))
flash('회원 정보 수정에 실패했습니다.')
query = "SELECT mb_idx, mb_id, mb_name FROM member WHERE mb_idx=%s"
r, member = sql_execute(query, (mb_idx,), is_data=True)
if r and member:
return render_template('admin/member_form.html', member=member[0])
else:
flash('회원을 찾을 수 없습니다.')
return redirect(url_for('admin_members'))
@app.route('/admin/members/<int:mb_idx>/delete', methods=['DELETE'])
@admin_required
def admin_member_delete(mb_idx):
if session.get('user_info', {}).get('mb_idx') == mb_idx:
return jsonify(success=False, message='자기 자신은 삭제할 수 없습니다.'), 400
query = "DELETE FROM member WHERE mb_idx=%s"
res = sql_execute(query, (mb_idx,))
if res:
return jsonify(success=True, message='회원이 삭제되었습니다.')
return jsonify(success=False, message='삭제 실패'), 500
@app.route('/admin/members/<int:mb_idx>/reset-password', methods=['POST'])
@admin_required
def admin_member_reset_password(mb_idx):
data = request.get_json()
new_password = data.get('password', '').encode('utf-8')
if len(new_password) < 4:
return jsonify(success=False, message='비밀번호는 4자 이상이어야 합니다.'), 400
hashed_pw = bcrypt.hashpw(new_password, bcrypt.gensalt()).decode('utf-8')
query = "UPDATE member SET mb_passwd=%s WHERE mb_idx=%s"
res = sql_execute(query, (hashed_pw, mb_idx))
if res:
return jsonify(success=True, message='비밀번호가 재설정되었습니다.')
return jsonify(success=False, message='비밀번호 재설정 실패'), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8899)

312
static/css/admin.css Normal file
View File

@@ -0,0 +1,312 @@
/* ============================================
WIXON Blog Admin Styles
============================================ */
/* Admin Layout */
.admin__wrap {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.admin__sidebar {
width: 250px;
background: #1e1e2d;
color: #fff;
padding: 20px;
position: fixed;
height: 100vh;
display: flex;
flex-direction: column;
}
.admin__logo {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #3a3a4d;
}
.admin__logo img {
max-width: 120px;
}
.admin__logo span {
display: block;
color: #a2a3b7;
font-size: 12px;
margin-top: 5px;
}
.admin__menu {
list-style: none;
padding: 0;
margin: 0;
flex: 1;
}
.admin__menu li a {
display: flex;
align-items: center;
padding: 12px 15px;
color: #a2a3b7;
text-decoration: none;
border-radius: 5px;
margin-bottom: 5px;
transition: all 0.3s ease;
}
.admin__menu li a:hover,
.admin__menu li a.active {
background: #1b1b28;
color: #fff;
}
.admin__menu li a span.uk-icon {
margin-right: 10px;
}
/* User Section */
.admin__user {
padding-top: 20px;
border-top: 1px solid #3a3a4d;
}
.admin__user .user__name {
color: #fff;
font-weight: 500;
margin-bottom: 10px;
display: block;
}
.admin__user a {
color: #a2a3b7;
text-decoration: none;
font-size: 13px;
margin-right: 15px;
}
.admin__user a:hover {
color: #fff;
}
/* Main Content */
.admin__content {
flex: 1;
margin-left: 250px;
padding: 30px;
background: #f5f5f5;
min-height: 100vh;
}
.admin__content h1 {
font-size: 24px;
margin-bottom: 25px;
color: #333;
}
/* Stats Cards */
.stat__card {
background: #fff;
border-radius: 8px;
padding: 25px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.stat__card h3 {
font-size: 14px;
color: #666;
margin: 0 0 10px 0;
font-weight: 500;
}
.stat__number {
font-size: 2.5em;
font-weight: bold;
color: #901438;
margin: 0;
}
.stat__number.green {
color: #28a745;
}
.stat__number.orange {
color: #fd7e14;
}
.stat__number.red {
color: #dc3545;
}
.stat__number.blue {
color: #007bff;
}
/* Tables */
.admin__table {
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.admin__table table {
margin: 0;
}
.admin__table th {
background: #f8f9fa;
font-weight: 600;
font-size: 13px;
color: #666;
text-transform: uppercase;
}
.admin__table td {
vertical-align: middle;
}
.admin__table .uk-icon-link {
margin-right: 8px;
color: #666;
}
.admin__table .uk-icon-link:hover {
color: #901438;
}
/* Labels */
.uk-label {
font-size: 11px;
padding: 3px 8px;
border-radius: 3px;
}
.uk-label-success {
background: #28a745;
}
.uk-label-danger {
background: #dc3545;
}
.uk-label-warning {
background: #fd7e14;
}
/* Filter Form */
.filter__form {
background: #fff;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
/* Pagination */
.admin__pagination {
margin-top: 20px;
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
/* Forms */
.admin__form {
background: #fff;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
max-width: 800px;
}
.admin__form .uk-form-label {
font-weight: 600;
color: #333;
}
/* Alerts */
.admin__content .uk-alert {
border-radius: 5px;
}
/* Action Buttons */
.action__buttons {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
/* Header with actions */
.page__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
}
.page__header h1 {
margin: 0;
}
/* Post Title Link */
.post__title__link {
color: #333;
text-decoration: none;
}
.post__title__link:hover {
color: #901438;
}
/* Responsive */
@media (max-width: 960px) {
.admin__sidebar {
width: 70px;
padding: 15px 10px;
}
.admin__sidebar .admin__logo img {
max-width: 40px;
}
.admin__sidebar .admin__logo span,
.admin__menu li a span:not(.uk-icon),
.admin__user .user__name {
display: none;
}
.admin__menu li a {
justify-content: center;
padding: 12px;
}
.admin__menu li a span.uk-icon {
margin-right: 0;
}
.admin__user a {
display: block;
text-align: center;
margin: 5px 0;
}
.admin__content {
margin-left: 70px;
padding: 20px;
}
}
@media (max-width: 640px) {
.page__header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.filter__form .uk-grid > div {
margin-bottom: 10px;
}
}

View File

@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<title>WIXON Blog - Admin</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.6.16/css/uikit.min.css"/>
<link rel="stylesheet" href="/static/css/style.css"/>
<link rel="stylesheet" href="/static/css/admin.css"/>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.6.16/js/uikit.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.6.16/js/uikit-icons.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-lite.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-lite.min.js"></script>
{% block staticfiles %}{% endblock %}
</head>
<body>
<div class="admin__wrap">
<!-- Sidebar -->
<nav class="admin__sidebar">
<div class="admin__logo">
<a href="/admin/">
<img src="/static/images/logo.png" alt="WIXON Admin" />
</a>
<span>Admin Panel</span>
</div>
<ul class="admin__menu">
<li>
<a href="/admin/" class="{% if request.path == '/admin/' %}active{% endif %}">
<span uk-icon="icon: home"></span>
<span>대시보드</span>
</a>
</li>
<li>
<a href="/admin/posts" class="{% if '/admin/posts' in request.path %}active{% endif %}">
<span uk-icon="icon: file-text"></span>
<span>포스트 관리</span>
</a>
</li>
<li>
<a href="/admin/members" class="{% if '/admin/members' in request.path %}active{% endif %}">
<span uk-icon="icon: users"></span>
<span>회원 관리</span>
</a>
</li>
</ul>
<div class="admin__user">
<span class="user__name">{{ g.user_info.mb_name }}</span>
<a href="/">사이트로 이동</a>
<a href="/logout">로그아웃</a>
</div>
</nav>
<!-- Main Content -->
<main class="admin__content">
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="uk-alert-primary" uk-alert>
<a class="uk-alert-close" uk-close></a>
{{ messages[0] }}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</div>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,70 @@
{% extends 'admin/base_admin.html' %}
{% block content %}
<h1>대시보드</h1>
<!-- Stats Cards -->
<div class="uk-grid uk-child-width-1-2@s uk-child-width-1-4@m uk-grid-match" uk-grid>
<div>
<div class="stat__card">
<h3>총 포스트</h3>
<p class="stat__number">{{ stats.total_posts }}</p>
</div>
</div>
<div>
<div class="stat__card">
<h3>공개 포스트</h3>
<p class="stat__number green">{{ stats.public_posts }}</p>
</div>
</div>
<div>
<div class="stat__card">
<h3>비공개 포스트</h3>
<p class="stat__number orange">{{ stats.private_posts }}</p>
</div>
</div>
<div>
<div class="stat__card">
<h3>총 회원 수</h3>
<p class="stat__number blue">{{ stats.total_members }}</p>
</div>
</div>
</div>
<!-- Deleted Posts Alert -->
{% if stats.deleted_posts > 0 %}
<div class="uk-alert-warning uk-margin-top" uk-alert>
<a class="uk-alert-close" uk-close></a>
<p>
<span uk-icon="icon: warning"></span>
삭제된 포스트 <strong>{{ stats.deleted_posts }}개</strong>가 있습니다.
<a href="/admin/posts?use_yn=N">삭제된 포스트 보기</a>
</p>
</div>
{% endif %}
<!-- Quick Links -->
<div class="uk-margin-large-top">
<h2 class="uk-heading-line"><span>바로가기</span></h2>
<div class="uk-grid uk-child-width-1-3@m" uk-grid>
<div>
<a href="/admin/posts" class="uk-card uk-card-default uk-card-body uk-card-hover uk-text-center" style="display: block; text-decoration: none;">
<span uk-icon="icon: file-text; ratio: 2"></span>
<p class="uk-margin-small-top uk-text-bold">포스트 관리</p>
</a>
</div>
<div>
<a href="/admin/members" class="uk-card uk-card-default uk-card-body uk-card-hover uk-text-center" style="display: block; text-decoration: none;">
<span uk-icon="icon: users; ratio: 2"></span>
<p class="uk-margin-small-top uk-text-bold">회원 관리</p>
</a>
</div>
<div>
<a href="/write" class="uk-card uk-card-default uk-card-body uk-card-hover uk-text-center" style="display: block; text-decoration: none;">
<span uk-icon="icon: plus-circle; ratio: 2"></span>
<p class="uk-margin-small-top uk-text-bold">새 포스트 작성</p>
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,108 @@
{% extends 'admin/base_admin.html' %}
{% block content %}
<div class="page__header">
<h1>{% if member %}회원 수정{% else %}회원 추가{% endif %}</h1>
<a href="/admin/members" class="uk-button uk-button-default">
<span uk-icon="icon: arrow-left"></span> 목록으로
</a>
</div>
<div class="admin__form" style="max-width: 500px;">
<form action="{% if member %}/admin/members/{{ member.mb_idx }}{% else %}/admin/members/add{% endif %}" method="post" class="uk-form-stacked">
<div class="uk-margin">
<label class="uk-form-label" for="mb_id">아이디</label>
<div class="uk-form-controls">
{% if member %}
<input class="uk-input" id="mb_id" type="text" value="{{ member.mb_id }}" disabled>
<p class="uk-text-muted uk-text-small uk-margin-small-top">아이디는 변경할 수 없습니다.</p>
{% else %}
<input class="uk-input" id="mb_id" type="text" name="mb_id" placeholder="영문, 숫자 조합" required>
{% endif %}
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="mb_name">이름</label>
<div class="uk-form-controls">
<input class="uk-input" id="mb_name" type="text" name="mb_name" value="{{ member.mb_name if member else '' }}" placeholder="회원 이름" required>
</div>
</div>
{% if not member %}
<div class="uk-margin">
<label class="uk-form-label" for="password">비밀번호</label>
<div class="uk-form-controls">
<input class="uk-input" id="password" type="password" name="password" placeholder="4자 이상" required minlength="4">
</div>
</div>
{% else %}
<div class="uk-margin">
<label class="uk-form-label">비밀번호</label>
<div class="uk-form-controls">
<button type="button" class="uk-button uk-button-secondary" onclick="resetPassword({{ member.mb_idx }})">
<span uk-icon="icon: lock"></span> 비밀번호 재설정
</button>
</div>
</div>
{% endif %}
<hr class="uk-margin-medium">
<div class="uk-margin">
<button type="submit" class="uk-button uk-button-primary uk-button-large">
<span uk-icon="icon: check"></span> {% if member %}수정{% else %}추가{% endif %}
</button>
<a href="/admin/members" class="uk-button uk-button-default uk-button-large">취소</a>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
{% if member %}
<script>
function resetPassword(mb_idx) {
Swal.fire({
title: '새 비밀번호 입력',
input: 'password',
inputPlaceholder: '새 비밀번호를 입력하세요 (4자 이상)',
showCancelButton: true,
confirmButtonColor: '#901438',
cancelButtonColor: '#6c757d',
confirmButtonText: '변경',
cancelButtonText: '취소',
inputValidator: (value) => {
if (!value) return '비밀번호를 입력해주세요';
if (value.length < 4) return '비밀번호는 4자 이상이어야 합니다';
}
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url: "/admin/members/" + mb_idx + "/reset-password",
type: "POST",
contentType: "application/json",
data: JSON.stringify({password: result.value}),
success: function(res) {
if (res.success) {
Swal.fire({
title: '완료',
text: res.message,
icon: 'success'
});
} else {
Swal.fire('오류', res.message, 'error');
}
},
error: function(xhr) {
var msg = xhr.responseJSON ? xhr.responseJSON.message : '비밀번호 변경 중 오류가 발생했습니다.';
Swal.fire('오류', msg, 'error');
}
});
}
});
}
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,139 @@
{% extends 'admin/base_admin.html' %}
{% block content %}
<div class="page__header">
<h1>회원 관리</h1>
<a href="/admin/members/add" class="uk-button uk-button-primary">
<span uk-icon="icon: plus"></span> 회원 추가
</a>
</div>
<!-- Members Table -->
<div class="admin__table">
<table class="uk-table uk-table-striped uk-table-hover uk-table-middle">
<thead>
<tr>
<th style="width: 80px;">번호</th>
<th>아이디</th>
<th>이름</th>
<th style="width: 200px;">관리</th>
</tr>
</thead>
<tbody>
{% if members %}
{% for member in members %}
<tr>
<td>{{ member.mb_idx }}</td>
<td>
{{ member.mb_id }}
{% if member.mb_id in ['admin', 'wixon'] %}
<span class="uk-label uk-label-warning" style="margin-left: 5px;">관리자</span>
{% endif %}
</td>
<td>{{ member.mb_name }}</td>
<td>
<a href="/admin/members/{{ member.mb_idx }}" class="uk-button uk-button-small uk-button-default">
<span uk-icon="icon: pencil; ratio: 0.8"></span> 수정
</a>
<button type="button" class="uk-button uk-button-small uk-button-secondary" onclick="resetPassword({{ member.mb_idx }})">
<span uk-icon="icon: lock; ratio: 0.8"></span> 비밀번호
</button>
{% if member.mb_id not in ['admin', 'wixon'] %}
<button type="button" class="uk-button uk-button-small uk-button-danger" onclick="deleteMember({{ member.mb_idx }}, '{{ member.mb_id }}')">
<span uk-icon="icon: trash; ratio: 0.8"></span> 삭제
</button>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="uk-text-center uk-text-muted">
등록된 회원이 없습니다.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endblock %}
{% block scripts %}
<script>
function resetPassword(mb_idx) {
Swal.fire({
title: '새 비밀번호 입력',
input: 'password',
inputPlaceholder: '새 비밀번호를 입력하세요 (4자 이상)',
showCancelButton: true,
confirmButtonColor: '#901438',
cancelButtonColor: '#6c757d',
confirmButtonText: '변경',
cancelButtonText: '취소',
inputValidator: (value) => {
if (!value) return '비밀번호를 입력해주세요';
if (value.length < 4) return '비밀번호는 4자 이상이어야 합니다';
}
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url: "/admin/members/" + mb_idx + "/reset-password",
type: "POST",
contentType: "application/json",
data: JSON.stringify({password: result.value}),
success: function(res) {
if (res.success) {
Swal.fire({
title: '완료',
text: res.message,
icon: 'success'
});
} else {
Swal.fire('오류', res.message, 'error');
}
},
error: function(xhr) {
var msg = xhr.responseJSON ? xhr.responseJSON.message : '비밀번호 변경 중 오류가 발생했습니다.';
Swal.fire('오류', msg, 'error');
}
});
}
});
}
function deleteMember(mb_idx, mb_id) {
Swal.fire({
title: '회원을 삭제하시겠습니까?',
html: '<strong>' + mb_id + '</strong> 회원을 삭제합니다.<br><span class="uk-text-danger">이 작업은 되돌릴 수 없습니다!</span>',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#dc3545',
cancelButtonColor: '#6c757d',
confirmButtonText: '삭제',
cancelButtonText: '취소'
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url: "/admin/members/" + mb_idx + "/delete",
type: "DELETE",
success: function(res) {
if (res.success) {
Swal.fire({
title: '삭제 완료',
text: res.message,
icon: 'success'
}).then(() => location.reload());
} else {
Swal.fire('오류', res.message, 'error');
}
},
error: function(xhr) {
var msg = xhr.responseJSON ? xhr.responseJSON.message : '삭제 중 오류가 발생했습니다.';
Swal.fire('오류', msg, 'error');
}
});
}
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,122 @@
{% extends 'admin/base_admin.html' %}
{% block content %}
<div class="page__header">
<h1>포스트 수정</h1>
<div>
<a href="/post/{{ post.id }}" target="_blank" class="uk-button uk-button-default">
<span uk-icon="icon: link"></span> 사이트에서 보기
</a>
<a href="/admin/posts" class="uk-button uk-button-default">
<span uk-icon="icon: arrow-left"></span> 목록으로
</a>
</div>
</div>
<div class="admin__form">
<form action="/admin/posts/{{ post.id }}" method="post" class="uk-form-stacked">
<div class="uk-margin">
<label class="uk-form-label" for="title">제목</label>
<div class="uk-form-controls">
<input class="uk-input" id="title" type="text" name="title" value="{{ post.title }}" required>
</div>
</div>
<div class="uk-grid uk-child-width-1-2@m" uk-grid>
<div>
<div class="uk-margin">
<label class="uk-form-label" for="category">카테고리</label>
<div class="uk-form-controls">
<select class="uk-select" id="category" name="category" required>
<option value="IT" {% if post.category == 'IT' %}selected{% endif %}>IT</option>
<option value="NEWS" {% if post.category == 'NEWS' %}selected{% endif %}>NEWS</option>
<option value="ETC" {% if post.category == 'ETC' %}selected{% endif %}>ETC</option>
</select>
</div>
</div>
</div>
<div>
<div class="uk-margin">
<label class="uk-form-label">공개 설정</label>
<div class="uk-form-controls uk-margin-small-top">
<label>
<input class="uk-checkbox" type="checkbox" name="public" {% if post.public_yn == 'Y' %}checked{% endif %}>
이 글을 외부에 공개합니다
</label>
</div>
</div>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label">작성 정보</label>
<div class="uk-form-controls">
<div class="uk-text-muted" style="padding: 10px 0;">
<span uk-icon="icon: user"></span> {{ post.mb_name }} ({{ post.mb_id }})
&nbsp;&nbsp;|&nbsp;&nbsp;
<span uk-icon="icon: clock"></span> {{ post.add_date.strftime('%Y-%m-%d %H:%M') }}
</div>
</div>
</div>
<div class="uk-margin">
<label class="uk-form-label" for="contents">내용</label>
<div class="uk-form-controls">
<textarea class="summernote" id="contents" name="contents" required>{{ post.contents }}</textarea>
</div>
</div>
<div class="uk-margin uk-margin-large-top">
<button type="submit" class="uk-button uk-button-primary uk-button-large">
<span uk-icon="icon: check"></span> 저장
</button>
<a href="/admin/posts" class="uk-button uk-button-default uk-button-large">취소</a>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
$(document).ready(function() {
$('#contents').summernote({
height: 400,
lang: 'ko-KR',
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['fontname', ['fontname']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'picture', 'video']],
['view', ['fullscreen', 'codeview', 'help']]
],
callbacks: {
onImageUpload: function(files) {
var $editor = $(this);
var data = new FormData();
data.append("file", files[0]);
$.ajax({
url: "/upload_image",
method: "POST",
data: data,
processData: false,
contentType: false,
success: function(response) {
if (response.success) {
$editor.summernote("insertImage", response.fileUrl);
} else {
Swal.fire('오류', response.message, 'error');
}
},
error: function() {
Swal.fire('오류', '이미지 업로드에 실패했습니다.', 'error');
}
});
}
}
});
});
</script>
{% endblock %}

222
templates/admin/posts.html Normal file
View File

@@ -0,0 +1,222 @@
{% extends 'admin/base_admin.html' %}
{% block content %}
<div class="page__header">
<h1>포스트 관리</h1>
<a href="/write" class="uk-button uk-button-primary">
<span uk-icon="icon: plus"></span> 새 포스트 작성
</a>
</div>
<!-- Filter Form -->
<div class="filter__form">
<form method="get">
<div class="uk-grid uk-grid-small uk-flex-middle" uk-grid>
<div class="uk-width-1-5@m">
<select class="uk-select" name="category">
<option value="">전체 카테고리</option>
<option value="IT" {% if request.args.get('category') == 'IT' %}selected{% endif %}>IT</option>
<option value="NEWS" {% if request.args.get('category') == 'NEWS' %}selected{% endif %}>NEWS</option>
<option value="ETC" {% if request.args.get('category') == 'ETC' %}selected{% endif %}>ETC</option>
</select>
</div>
<div class="uk-width-1-5@m">
<select class="uk-select" name="public_yn">
<option value="">공개 여부</option>
<option value="Y" {% if request.args.get('public_yn') == 'Y' %}selected{% endif %}>공개</option>
<option value="N" {% if request.args.get('public_yn') == 'N' %}selected{% endif %}>비공개</option>
</select>
</div>
<div class="uk-width-1-5@m">
<select class="uk-select" name="use_yn">
<option value="Y" {% if request.args.get('use_yn', 'Y') == 'Y' %}selected{% endif %}>사용중</option>
<option value="N" {% if request.args.get('use_yn') == 'N' %}selected{% endif %}>삭제됨</option>
<option value="" {% if request.args.get('use_yn') == '' %}selected{% endif %}>전체</option>
</select>
</div>
<div class="uk-width-expand@m">
<input class="uk-input" type="text" name="search" placeholder="제목 검색..." value="{{ request.args.get('search', '') }}">
</div>
<div class="uk-width-auto@m">
<button class="uk-button uk-button-primary" type="submit">
<span uk-icon="icon: search"></span> 검색
</button>
<a href="/admin/posts" class="uk-button uk-button-default">초기화</a>
</div>
</div>
</form>
</div>
<!-- Posts Table -->
<div class="admin__table">
<table class="uk-table uk-table-striped uk-table-hover uk-table-middle">
<thead>
<tr>
<th style="width: 60px;">ID</th>
<th>제목</th>
<th style="width: 100px;">카테고리</th>
<th style="width: 100px;">작성자</th>
<th style="width: 80px;">공개</th>
<th style="width: 80px;">상태</th>
<th style="width: 100px;">작성일</th>
<th style="width: 120px;">관리</th>
</tr>
</thead>
<tbody>
{% if posts %}
{% for post in posts %}
<tr>
<td>{{ post.id }}</td>
<td>
<a href="/admin/posts/{{ post.id }}" class="post__title__link">
{{ post.title[:50] }}{% if post.title|length > 50 %}...{% endif %}
</a>
</td>
<td>{{ post.category }}</td>
<td>{{ post.mb_name }}</td>
<td>
{% if post.public_yn == 'Y' %}
<span class="uk-label uk-label-success">공개</span>
{% else %}
<span class="uk-label uk-label-warning">비공개</span>
{% endif %}
</td>
<td>
{% if post.use_yn == 'Y' %}
<span class="uk-label uk-label-success">사용중</span>
{% else %}
<span class="uk-label uk-label-danger">삭제됨</span>
{% endif %}
</td>
<td>{{ post.add_date.strftime('%Y-%m-%d') }}</td>
<td>
<a href="/post/{{ post.id }}" target="_blank" class="uk-icon-link" uk-icon="icon: link" title="사이트에서 보기"></a>
<a href="/admin/posts/{{ post.id }}" class="uk-icon-link" uk-icon="icon: pencil" title="수정"></a>
{% if post.use_yn == 'Y' %}
<a href="javascript:deletePost({{ post.id }})" class="uk-icon-link" uk-icon="icon: trash" title="삭제"></a>
{% else %}
<a href="javascript:restorePost({{ post.id }})" class="uk-icon-link uk-text-success" uk-icon="icon: refresh" title="복구"></a>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="8" class="uk-text-center uk-text-muted">
포스트가 없습니다.
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.total > 0 %}
<div class="admin__pagination">
<div class="uk-flex uk-flex-between uk-flex-middle">
<div class="uk-text-muted">
총 {{ pagination.total }}개 중 {{ ((pagination.page - 1) * pagination.per_page) + 1 }} - {{ [pagination.page * pagination.per_page, pagination.total]|min }}개 표시
</div>
<ul class="uk-pagination uk-margin-remove">
{% if pagination.has_prev %}
<li>
<a href="?page={{ pagination.prev_num }}&category={{ request.args.get('category', '') }}&public_yn={{ request.args.get('public_yn', '') }}&use_yn={{ request.args.get('use_yn', 'Y') }}&search={{ request.args.get('search', '') }}">
<span uk-pagination-previous></span>
</a>
</li>
{% endif %}
{% for page_num in range(1, pagination.total_pages + 1) %}
{% if page_num == pagination.page %}
<li class="uk-active"><span>{{ page_num }}</span></li>
{% elif page_num == 1 or page_num == pagination.total_pages or (page_num >= pagination.page - 2 and page_num <= pagination.page + 2) %}
<li>
<a href="?page={{ page_num }}&category={{ request.args.get('category', '') }}&public_yn={{ request.args.get('public_yn', '') }}&use_yn={{ request.args.get('use_yn', 'Y') }}&search={{ request.args.get('search', '') }}">
{{ page_num }}
</a>
</li>
{% elif page_num == pagination.page - 3 or page_num == pagination.page + 3 %}
<li class="uk-disabled"><span>...</span></li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li>
<a href="?page={{ pagination.next_num }}&category={{ request.args.get('category', '') }}&public_yn={{ request.args.get('public_yn', '') }}&use_yn={{ request.args.get('use_yn', 'Y') }}&search={{ request.args.get('search', '') }}">
<span uk-pagination-next></span>
</a>
</li>
{% endif %}
</ul>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function deletePost(id) {
Swal.fire({
title: '포스트를 삭제하시겠습니까?',
text: "삭제된 포스트는 복구할 수 있습니다.",
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#901438',
cancelButtonColor: '#6c757d',
confirmButtonText: '삭제',
cancelButtonText: '취소'
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url: "/admin/posts/" + id + "/delete",
type: "DELETE",
success: function(res) {
if (res.success) {
Swal.fire({
title: '삭제 완료',
text: res.message,
icon: 'success'
}).then(() => location.reload());
}
},
error: function() {
Swal.fire('오류', '삭제 중 오류가 발생했습니다.', 'error');
}
});
}
});
}
function restorePost(id) {
Swal.fire({
title: '포스트를 복구하시겠습니까?',
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#28a745',
cancelButtonColor: '#6c757d',
confirmButtonText: '복구',
cancelButtonText: '취소'
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url: "/admin/posts/" + id + "/restore",
type: "POST",
success: function(res) {
if (res.success) {
Swal.fire({
title: '복구 완료',
text: res.message,
icon: 'success'
}).then(() => location.reload());
}
},
error: function() {
Swal.fire('오류', '복구 중 오류가 발생했습니다.', 'error');
}
});
}
});
}
</script>
{% endblock %}

View File

@@ -42,7 +42,7 @@
<div class="login_nav">
<ul>
<li class="uk-active"><a href="/write">포스트 작성</a></li>
<li class="uk-active"><a target="_blank" href="http://www.wixon.co.kr/backS1te">관리자 페이지</a></li>
<li class="uk-active"><a href="/admin/">관리자 페이지</a></li>
<li class="uk-active"><a href="/logout">LOGOUT</a></li>
</ul>
</div>